@@ -1,311 +1,712 @@
/** @jsxImportSource @opentui/solid */
/**
* TuiApp - Main TUI application shell
* DD §13.2. Real terminal rendering using ANSI escape codes .
* TuiApp - OpenTUI/Solid application shell
* DD §13.2. Projection-only display plus single OpenTUI textarea input owner .
*
* @module packages/tui/src/TuiApp
*/
import { SessionView } from './components/SessionView.js '
import { TaskListView } from './components/TaskListView.js '
import { AgentStatusView } from './components/AgentStatusView. js'
import { HudView } from './components/HudView .js'
import type { SessionProjection } from './ProjectionClient.js'
import { createCliRenderer , type CliRenderer , type TextareaRenderable , type KeyEvent } from '@opentui/core '
import { render , useRenderer , useTerminalDimensions } from '@opentui/solid '
import { createEffect , createSignal , For , onCleanup , onMount , Show } from 'solid- js'
import type { SessionProjection } from './types .js'
export interface TuiAppProps {
client : {
subscribe ( handler : ( projection : SessionProjection ) = > void ) : ( ) = > void
receive_snapshot ( projection : SessionProjection ) : void
get_snapshot ( ) : SessionProjection | null
}
onSubmit ? : ( input : string ) = > void | Promise < void >
onSlashCommand ? : ( input : string ) = > void | Promise < void >
onResolvePermission ? : ( prompt_id : string , selected_option : string ) = > void | Promise < void >
onExit ? : ( ) = > void | Promise < void >
}
export interface TuiAppState {
projection : SessionProjection | null
active_view : 'tasks' | 'agents' | 'tools' | 'diff' | 'help'
busy : boolean
status : string
}
const ANSI = {
reset : '\x1b[0m ' ,
bright : '\x1b[1m ',
dim : '\x1b[2m ',
underscore : '\x1b[4m ',
blink : '\x1b[5m ' ,
reverse : '\x1b[7m ',
hidd en: '\x1b[8m ' ,
fg : {
black : '\x1b[30m ',
red : '\x1b[31m ' ,
green : '\x1b[32m ' ,
yellow : '\x1b[33m' ,
blue : '\x1b[34m' ,
magenta : '\x1b[35m' ,
cyan : '\x1b[36m' ,
white : '\x1b[37m' ,
gray : '\x1b[90m' ,
} ,
bg : {
black : '\x1b[40m' ,
red : '\x1b[41m' ,
green : '\x1b[42m' ,
yellow : '\x1b[43m' ,
blue : '\x1b[44m' ,
magenta : '\x1b[45m' ,
cyan : '\x1b[46m' ,
white : '\x1b[47m' ,
} ,
clear : '\x1b[2J\x1b[H' ,
clearLine : '\x1b[2K' ,
cursor : {
home : '\x1b[H' ,
save : '\x1b[s' ,
restore : '\x1b[u' ,
hide : '\x1b[?25l' ,
show : '\x1b[?25h' ,
up : ( n = 1 ) = > ` \ x1b[ ${ n } A ` ,
down : ( n = 1 ) = > ` \ x1b[ ${ n } B ` ,
right : ( n = 1 ) => ` \ x1b[ ${ n } C ` ,
left : ( n = 1 ) = > ` \ x1b[ ${ n } D ` ,
const THEME = {
bg : '#0b0f14 ' ,
surface : '#111827 ',
surface2 : '#1f2937 ',
text : '#e5e7eb ',
muted : '#9ca3af ' ,
faint : '#6b7280 ',
acc ent : '#22d3ee ' ,
success : '#22c55e' ,
warning : '#f59e0b ',
error : '#ef4444 ' ,
border : '#374151 ' ,
}
const PROMPT_HISTORY_LIMIT = 200
const TEXTAREA_MIN_ROWS = 1
const TEXTAREA_MAX_ROWS = 6
const EXIT_CONFIRM_MS = 5000
type PromptHistoryState = {
items : string [ ]
index : number | null
draft : string
}
type PromptHistoryMove = {
state : PromptHistoryState
apply : boolean
text? : string
cursor? : number
}
function createPromptHistory ( ) : PromptHistoryState {
return { items : [ ] , index : null , draft : '' }
}
function pushPromptHistory ( state : PromptHistoryState , prompt : string ) : PromptHistoryState {
const text = prompt . trim ( )
if ( ! text ) return state
if ( state . items [ state . items . length - 1 ] === text ) {
return { . . . state , index : null , draft : '' }
}
return { items : [ . . . state . items , text ] . slice ( - PROMPT_HISTORY_LIMIT ) , index : null , draft : '' }
}
function movePromptHistory ( state : PromptHistoryState , dir : - 1 | 1 , text : string , cursor : number ) : PromptHistoryMove {
if ( state . items . length === 0 ) return { state , apply : false }
if ( dir === - 1 && cursor !== 0 ) return { state , apply : false }
if ( dir === 1 && cursor !== text . length ) return { state , apply : false }
if ( state . index === null ) {
if ( dir === 1 ) return { state , apply : false }
const idx = state . items . length - 1
return { state : { . . . state , index : idx , draft : text } , text : state.items [ idx ] , cursor : 0 , apply : true }
}
const idx = state . index + dir
if ( idx < 0 ) return { state , apply : false }
if ( idx >= state . items . length ) {
return { state : { . . . state , index : null } , text : state.draft , cursor : state.draft.length , apply : true }
}
return { state : { . . . state , index : idx } , text : state.items [ idx ] , cursor : dir === - 1 ? 0 : state.items [ idx ] . length , apply : true }
}
export class TuiApp {
private client : TuiAppProps [ 'client' ]
private state : TuiAppState
private unsubscribe : ( ( ) = > void ) | null = null
private running : boolean = false
private onSubmit? : TuiAppProps [ 'onSubmit' ]
private onSlashCommand? : TuiAppProps [ 'onSlashCommand' ]
private onResolvePermission? : TuiAppProps [ 'onResolvePermission' ]
private onExit? : TuiAppProps [ 'onExit' ]
private renderer : CliRenderer | null = null
private unsubscribeClient : ( ( ) = > void ) | null = null
private setProjection ? : ( projection : SessionProjection | null ) = > void
private setView ? : ( view : TuiAppState [ 'active_view' ] ) = > void
private setBusy ? : ( busy : boolean ) = > void
private setStatus ? : ( status : string ) = > void
constructor ( props : TuiAppProps ) {
this . client = props . client
this . state = { projection : null , active_view : 'tasks' }
this . onSubmit = props . onSubmit
this . onSlashCommand = props . onSlashCommand
this . onResolvePermission = props . onResolvePermission
this . onExit = props . onExit
}
async start ( ) : Promise < void > {
this . unsubscribe = this . client . subscribe ( ( projection ) = > {
this . state . projection = projection
this . render ( )
if ( this . renderer ) return
this . renderer = await createCliRenderer ( {
targetFps : 30 ,
maxFps : 60 ,
useMouse : false ,
autoFocus : false ,
exitOnCtrlC : false ,
screenMode : 'alternate-screen' ,
externalOutputMode : 'capture-stdout' ,
consoleMode : 'disabled' ,
clearOnShutdown : true ,
openConsoleOnError : false ,
useKittyKeyboard : { } ,
backgroundColor : THEME.bg ,
} )
this . renderer . setBackgroundColor ( THEME . bg )
const snapshot = this . client . get_snapshot ( )
if ( snapshot ) {
this . state . projection = snapshot
}
await render ( ( ) = > (
< AirCodingView
initialProjection = { this . client . get_snapshot ( ) }
bindState = { ( bindings ) = > {
this . setProjection = bindings . setProjection
this . setView = bindings . setView
this . setBusy = bindings . setBusy
this . setStatus = bindings . setStatus
} }
onSubmit = { ( input ) = > this . submit ( input ) }
onSlashCommand = { ( input ) = > this . slash ( input ) }
onResolvePermission = { ( prompt_id , selected_option ) = > this . resolvePermission ( prompt_id , selected_option ) }
onExit = { ( ) = > this . exit ( ) }
/ >
) , this . renderer )
this . r unning = true
this . setup_input ( )
this . render ( )
this . unsubscribeClient = this . client . subscribe ( ( projection ) = > {
this . setProjection ? . ( projection )
} )
}
stop ( ) : void {
this . r unning = false
this . unsubscribe? . ( )
this . unsubscribe = null
process . stdout . write ( ANSI . cursor . sho w + ANSI . reset )
this . unsubscribeClient ? . ( )
this . unsubscribeClient = null
this . setProjection = undefined
this . setVie w = undefined
this . setBusy = undefined
this . setStatus = undefined
if ( this . renderer && ! this . renderer . isDestroyed ) {
this . renderer . setTerminalTitle ( '' )
this . renderer . externalOutputMode = 'passthrough'
this . renderer . destroy ( )
}
this . renderer = null
}
set_view ( view : TuiAppState [ 'active_view' ] ) : void {
this . state . active_view = view
this . render ( )
this . setView ? . ( view )
}
private setup_input ( ) : void {
if ( process . stdin . isTTY ) {
process . stdin . setRawMode ( true )
process . stdin . resume ( )
process . stdin . setEncoding ( 'utf8' )
set_busy ( busy : boolean , status? : string ) : void {
this . setBusy ? . ( busy )
if ( status ) this . setStatus ? . ( status )
}
proces s . stdin . on ( 'data' , ( key : string ) = > {
this . handle_input ( key )
} )
set_statu s( status : string ) : void {
this . setStatus ? . ( status )
}
private async submit ( input : string ) : Promise < void > {
const text = input . trim ( )
if ( ! text ) return
this . setBusy ? . ( true )
this . setStatus ? . ( ` Running: ${ text . slice ( 0 , 72 ) } ` )
try {
await this . onSubmit ? . ( text )
this . setStatus ? . ( 'Ready' )
} catch ( error ) {
this . setStatus ? . ( error instanceof Error ? error.message : String ( error ) )
throw error
} finally {
this . setBusy ? . ( false )
}
}
private handle_input ( key : string ) : void {
switch ( key ) {
case 'q' :
case ' ' : // Ctrl+C
this . stop ( )
process . exit ( 0 )
break
case '1' :
this . set_view ( 'tasks' )
break
case '2' :
this . set_view ( 'agents' )
break
case '3' :
this . set_view ( 'tools' )
break
case '4' :
this . set_view ( 'diff' )
break
case '?' :
case 'h' :
this . set_view ( 'help' )
break
private async slash ( input : string ) : Promise < void > {
const text = input . trim ( )
if ( ! text ) return
if ( text === '/quit' || text === '/exit' ) {
await this . exit ( )
return
}
this . setStatus ? . ( ` Command: ${ text } ` )
await this . onSlashCommand ? . ( text )
}
private render ( ) : void {
if ( ! this . running ) return
private async resolvePermission ( prompt_id : string , selected_option : string ) : Promise < void > {
if ( ! this . onResolvePermission ) {
this . setStatus ? . ( 'Permission selection requires runtime resolver' )
return
}
this . setStatus ? . ( ` Permission: ${ selected_option } ` )
await this . onResolvePermission ( prompt_id , selected_option )
}
const p = this . state . projection
const lines : string [ ] = [ ]
private async exit ( ) : Promise < void > {
await this . onExit ? . ( )
}
}
// Header
lines . push ( ANSI . clear )
lines . push ( ANSI . fg . cyan + ANSI . bright + '═══════════════════════════════════════════════════════════════' + ANSI . reset )
lines . push ( ANSI . fg . cyan + ANSI . bright + ' AirCoding v1.0.0-alpha' + ANSI . reset + ANSI . fg . gray + ' │ ' + ( p ? ` ${ p . tasks . length } tasks ` : 'No session' ) + ' │ ' + this . get_status_indicator ( p ) + ANSI . reset )
lines . push ( ANSI . fg . cyan + '═══════════════════════════════════════════════════════════════' + ANSI . reset )
type StateBindings = {
setProjection : ( projection : SessionProjection | null ) = > void
setView : ( view : TuiAppState [ 'active_view' ] ) = > void
setBusy : ( busy : boolean ) = > void
setStatus : ( status : string ) = > void
}
// Navigation hints
lines . push ( ANSI . fg . gray + ' [1]Tasks [2]Agents [3]Tools [4]Diff [h]Help [q]Quit' + ANSI . reset )
type FooterPhase = 'idle' | 'running' | 'permission' | 'confirm_exit' | 'error'
// Content area
lines . push ( '' )
function AirCodingView ( props : {
initialProjection : SessionProjection | null
bindState : ( bindings : StateBindings ) = > void
onSubmit : ( input : string ) = > void | Promise < void >
onSlashCommand : ( input : string ) = > void | Promise < void >
onResolvePermission : ( prompt_id : string , selected_option : string ) = > void | Promise < void >
onExit : ( ) = > void | Promise < void >
} ) {
const renderer = useRenderer ( )
const dimensions = useTerminalDimensions ( )
const [ projection , setProjection ] = createSignal < SessionProjection | null > ( props . initialProjection )
const [ activeView , setActiveView ] = createSignal < TuiAppState [ 'active_view' ] > ( 'tasks' )
const [ busy , setBusy ] = createSignal ( false )
const [ status , setStatus ] = createSignal ( 'Ready' )
const [ footerPhase , setFooterPhase ] = createSignal < FooterPhase > ( 'idle' )
const [ toast , setToast ] = createSignal ( '' )
let textarea : TextareaRenderable | undefined
let history = createPromptHistory ( )
let pasteTick : ReturnType < typeof setTimeout > | undefined
let exitConfirmUntil = 0
if ( thi s . s tate. active_view === 'help' ) {
lines . push ( . . . this . render_help ( ) )
} else if ( ! p ) {
lines . push ( ANSI . fg . yellow + ' No active session. Run "air init" then "air run".' + ANSI . reset )
lines . p ush ( '' )
lines . push ( ANSI . fg . gray + ' Press q to exit. ' + ANSI . reset )
prop s. bindS tate( {
setProjection ,
setView : setActiveView ,
setBusy : ( next ) = > {
setB usy ( next )
setFooterPhase ( next ? 'running ' : 'idle' )
} ,
setStatus : ( next ) = > {
setStatus ( next )
if ( /error|failed|blocked|失败|错误/i . test ( next ) ) setFooterPhase ( 'error' )
else if ( ! busy ( ) ) setFooterPhase ( 'idle' )
} ,
} )
const focusPrompt = ( ) = > {
if ( textarea && ! textarea . isDestroyed ) textarea . focus ( )
}
const submitPrompt = ( ) = > {
if ( ! textarea || textarea . isDestroyed || busy ( ) ) return
const text = textarea . plainText . trim ( )
if ( ! text ) return
history = pushPromptHistory ( history , text )
textarea . setText ( '' )
exitConfirmUntil = 0
setFooterPhase ( 'running' )
setStatus ( text . startsWith ( '/' ) ? ` Command: ${ text } ` : ` Sending: ${ text . slice ( 0 , 72 ) } ` )
if ( text . startsWith ( '/' ) ) {
void props . onSlashCommand ( text )
} else {
switch ( this . state . active_view ) {
case 'tasks' :
lines . push ( . . . this . render_tasks ( p ) )
break
case 'agents' :
lines . push ( . . . this . render_agents ( p ) )
break
case 'tools' :
lines . push ( . . . this . render_tools ( p ) )
b reak
case 'diff' :
lines . push ( . . . this . render_diff ( p ) )
break
void props . onSubmit ( text )
}
focusPrompt ( )
}
const refreshPasteLayout = ( ) = > {
if ( pasteTick ) clearTimeout ( pasteTick )
pasteTick = setTimeout ( ( ) = > {
pasteTick = undefined
if ( ! textarea || textarea . isDestroyed ) return
textarea . getLayoutNode ( ) . markDirty ( )
renderer . requestRender ( )
void renderer . idle ( ) . then ( ( ) = > renderer . requestRender ( ) ) . catch ( ( ) = > { } )
} , 0 )
}
const applyHistoryMove = ( dir : - 1 | 1 ) = > {
if ( ! textarea || textarea . isDestroyed ) return false
const text = textarea . plainText
const move = movePromptHistory ( history , dir , text , textarea . cursorOffset )
history = move . state
if ( ! move . apply ) return false
textarea . setText ( move . text ? ? '' )
textarea . cursorOffset = move . cursor ? ? 0
textarea . getLayoutNode ( ) . markDirty ( )
renderer . requestRender ( )
return true
}
const activePermission = ( ) = > projection ( ) ? . permission_prompts [ 0 ]
const resolvePermissionByIndex = ( index : number ) = > {
const prompt = activePermission ( )
if ( ! prompt ) return false
const options = prompt . options . length > 0 ? prompt . options : [ 'allow' , 'deny' ]
const selected = options [ index ]
if ( ! selected ) return false
setFooterPhase ( 'permission' )
setStatus ( ` Permission: ${ selected } ` )
void props . onResolvePermission ( prompt . prompt_id , selected )
return true
}
const handleKeyDown = ( event : KeyEvent ) = > {
const prompt = activePermission ( )
if ( prompt ) {
if ( event . name === 'left' || event . name === 'h' ) {
event . preventDefault ( )
setToast ( 'Use 1/2/3 to choose a permission option' )
return
}
if ( /^[1-9]$/ . test ( event . name ) && resolvePermissionByIndex ( Number ( event . name ) - 1 ) ) {
event . preventDefault ( )
return
}
if ( ( event . name === 'a' || event . name === 'y' ) && resolvePermissionByIndex ( 0 ) ) {
event . preventDefault ( )
return
}
if ( ( event . name === 'd' || event . name === 'n' ) && resolvePermissionByIndex ( Math . min ( 1 , ( prompt . options . length || 2 ) - 1 ) ) ) {
event . preventDefault ( )
return
}
}
// Footer
lines . push ( '' )
lines . push ( ANSI . fg . gray + '─' . repeat ( 76 ) + ANSI . reset )
lines . push ( ANSI . fg . gray + ' Status: ' + this . g et_status_text ( p ) + ' │ Session: ' + ( p ? . session_id || 'N/A' ) + ANSI . reset )
if ( event . ctrl && event . name === 'c' ) {
event . preventDefault ( )
if ( textarea && ! textarea . isDestroyed && textarea . plainText . length > 0 ) {
textarea . s etText ( '' )
history = { . . . history , index : null , draft : '' }
setFooterPhase ( 'idle' )
setStatus ( 'Draft cleared; press Ctrl+C again to exit' )
focusPrompt ( )
return
}
const now = Date . now ( )
if ( now < exitConfirmUntil ) {
void props . onExit ( )
return
}
exitConfirmUntil = now + EXIT_CONFIRM_MS
setFooterPhase ( 'confirm_exit' )
setStatus ( 'Press Ctrl+C again within 5s to exit' )
return
}
process . stdout . write ( lines . join ( '\n' ) + '\n' )
}
if ( event . name === 'up' && applyHistoryMove ( - 1 ) ) {
event . preventDefault ( )
return
}
private get_status_indicator ( p : SessionProjection | null ) : string {
if ( ! p ) return ANSI . fg . gray + 'IDLE' + ANSI . reset
switch ( p . status ) {
case 'running' : return ANSI . fg . green + '● RUNNING' + ANSI . reset
case 'completed' : return ANSI . fg . blue + '● COMPLETED' + ANSI . reset
case 'error' : return ANSI . fg . red + '● ERROR' + ANSI . reset
default : return ANSI . fg . gray + '● ' + p . status . toUpperCase ( ) + ANSI . reset
if ( event . name === 'down' && applyHistoryMove ( 1 ) ) {
event . preventDefault ( )
return
}
if ( event . name === 'escape' ) {
event . preventDefault ( )
exitConfirmUntil = 0
setActiveView ( 'tasks' )
setFooterPhase ( busy ( ) ? 'running' : activePermission ( ) ? 'permission' : 'idle' )
focusPrompt ( )
return
}
if ( event . ctrl && event . name === 'l' ) {
event . preventDefault ( )
renderer . requestRender ( )
return
}
if ( ! event . ctrl || event . meta ) return
const next = shortcutToView ( event . name )
if ( next ) {
event . preventDefault ( )
setActiveView ( next )
focusPrompt ( )
}
}
private get_status_text ( p : SessionProjection | null ) : string {
if ( ! p ) return 'No session'
return ` ${ p . status } | ${ p . tasks . length } tasks | ${ p . agents . length } agents `
onMount ( ( ) = > {
renderer . setTerminalTitle ( 'AirCoding' )
focusPrompt ( )
} )
onCleanup ( ( ) = > {
if ( pasteTick ) clearTimeout ( pasteTick )
renderer . setTerminalTitle ( '' )
} )
createEffect ( ( ) = > {
const hasPermission = ( projection ( ) ? . permission_prompts . length ? ? 0 ) > 0
if ( hasPermission ) setFooterPhase ( 'permission' )
else if ( ! busy ( ) && footerPhase ( ) === 'permission' ) setFooterPhase ( 'idle' )
} )
createEffect ( ( ) = > {
projection ( )
activeView ( )
busy ( )
status ( )
footerPhase ( )
toast ( )
renderer . requestRender ( )
} )
return (
< box width = "100%" height = "100%" flexDirection = "column" backgroundColor = { THEME . bg } >
< Header projection = { projection ( ) } / >
< Nav active = { activeView ( ) } / >
< box flexGrow = { 1 } flexShrink = { 1 } paddingLeft = { 2 } paddingRight = { 2 } paddingTop = { 1 } paddingBottom = { 1 } >
< Content projection = { projection ( ) } active = { activeView ( ) } height = { Math . max ( 8 , dimensions ( ) . height - 11 ) } / >
< / box >
< Prompt
busy = { busy ( ) }
phase = { footerPhase ( ) }
status = { status ( ) }
toast = { toast ( ) }
permission = { activePermission ( ) }
textareaRef = { ( area ) = > { textarea = area } }
onSubmit = { submitPrompt }
onKeyDown = { handleKeyDown }
onPaste = { refreshPasteLayout }
onContentChange = { ( ) = > renderer . requestRender ( ) }
/ >
< / box >
)
}
function Header ( props : { projection : SessionProjection | null } ) {
const taskCount = ( ) = > props . projection ? . tasks . length ? ? 0
return (
< box flexDirection = "column" paddingLeft = { 2 } paddingRight = { 2 } paddingTop = { 1 } backgroundColor = { THEME . surface } >
< box flexDirection = "row" justifyContent = "space-between" >
< text fg = { THEME . accent } > AirCoding v1 . 0.0 - alpha < / text >
< text fg = { statusColor ( props . projection ? . status ) } > { ( props . projection ? . status ? ? 'idle' ) . toUpperCase ( ) } < / text >
< / box >
< text fg = { THEME . muted } > { props . projection ? . title ? ? 'No active session' } · { taskCount ( ) } tasks · { props . projection ? . agents . length ? ? 0 } agents < / text >
< / box >
)
}
function Nav ( props : { active : TuiAppState [ 'active_view' ] } ) {
const items : Array < [ TuiAppState [ 'active_view' ] , string ] > = [
[ 'tasks' , 'Ctrl+1 Tasks' ] ,
[ 'agents' , 'Ctrl+2 Agents' ] ,
[ 'tools' , 'Ctrl+3 Tools' ] ,
[ 'diff' , 'Ctrl+4 Diff' ] ,
[ 'help' , 'Ctrl+H Help' ] ,
]
return (
< box flexDirection = "row" gap = { 1 } paddingLeft = { 2 } paddingRight = { 2 } paddingTop = { 1 } paddingBottom = { 1 } backgroundColor = { THEME . surface2 } >
< For each = { items } > { ( [ view , label ] ) = > (
< text fg = { props . active === view ? THEME.accent : THEME.muted } > { label } < / text >
) } < / For >
< / box >
)
}
function Content ( props : { projection : SessionProjection | null ; active : TuiAppState [ 'active_view' ] ; height : number } ) {
const currentProjection = ( ) = > props . projection
return (
< Show when = { currentProjection ( ) } fallback = { < EmptySession / > } >
< box flexDirection = "column" gap = { 1 } height = { props . height } >
< Show when = { props . active === 'tasks' } >
< TasksView projection = { currentProjection ( ) ! } / >
< / Show >
< Show when = { props . active === 'agents' } >
< AgentsView projection = { currentProjection ( ) ! } / >
< / Show >
< Show when = { props . active === 'tools' } >
< ToolsView projection = { currentProjection ( ) ! } / >
< / Show >
< Show when = { props . active === 'diff' } >
< DiffView projection = { currentProjection ( ) ! } / >
< / Show >
< Show when = { props . active === 'help' } >
< HelpView / >
< / Show >
< / box >
< / Show >
)
}
function EmptySession() {
return (
< box flexDirection = "column" gap = { 1 } >
< text fg = { THEME . warning } > No active session projection . < / text >
< text fg = { THEME . muted } > Run air init and air run from a project directory . < / text >
< / box >
)
}
function TasksView ( props : { projection : SessionProjection } ) {
return (
< box flexDirection = "column" gap = { 1 } >
< text fg = { THEME . accent } > Tasks < / text >
< Show when = { props . projection . tasks . length > 0 } fallback = { < text fg = { THEME . muted } > No tasks yet . < / text > } >
< For each = { props . projection . tasks . slice ( 0 , 14 ) } > { ( task ) = > (
< box flexDirection = "column" >
< text fg = { statusColor ( task . status ) } > { statusMark ( task . status ) } { task . title || task . id } < / text >
< text fg = { THEME . faint } > { task . id } · { task . type } · { task . status } · attempts { task . attempts } < / text >
< / box >
) } < / For >
< / Show >
< / box >
)
}
function AgentsView ( props : { projection : SessionProjection } ) {
return (
< box flexDirection = "column" gap = { 1 } >
< text fg = { THEME . accent } > Agents < / text >
< Show when = { props . projection . agents . length > 0 } fallback = { < text fg = { THEME . muted } > No agents yet . < / text > } >
< For each = { props . projection . agents . slice ( 0 , 14 ) } > { ( agent ) = > (
< box flexDirection = "column" >
< text fg = { statusColor ( agent . status ) } > { statusMark ( agent . status ) } { agent . type } < / text >
< text fg = { THEME . faint } > { agent . id } · { agent . status } { agent . task_id ? ` · task ${ agent . task_id } ` : '' } < / text >
< / box >
) } < / For >
< / Show >
< / box >
)
}
function ToolsView ( props : { projection : SessionProjection } ) {
const p = ( ) = > props . projection as SessionProjection & { tool_runs? : Array < { tool_run_id : string ; tool_name : string ; status : string ; duration_ms? : number } > }
return (
< box flexDirection = "column" gap = { 1 } >
< text fg = { THEME . accent } > Tool runs < / text >
< Show when = { ( p ( ) . tool_runs ? ? [ ] ) . length > 0 } fallback = { < text fg = { THEME . muted } > No tool runs yet . < / text > } >
< For each = { ( p ( ) . tool_runs ? ? [ ] ) . slice ( - 14 ) . reverse ( ) } > { ( tool ) = > (
< box flexDirection = "row" gap = { 1 } >
< text fg = { statusColor ( tool . status ) } > { statusMark ( tool . status ) } < / text >
< text fg = { THEME . text } > { tool . tool_name } < / text >
< text fg = { THEME . faint } > { tool . status } { tool . duration_ms ? ` · ${ tool . duration_ms } ms ` : '' } < / text >
< / box >
) } < / For >
< / Show >
< / box >
)
}
function DiffView ( props : { projection : SessionProjection } ) {
const completed = ( ) = > props . projection . tasks . filter ( ( task ) = > task . status === 'completed' ) . length
const failed = ( ) = > props . projection . tasks . filter ( ( task ) = > task . status === 'failed' ) . length
const blocked = ( ) = > props . projection . tasks . filter ( ( task ) = > task . status === 'blocked' ) . length
return (
< box flexDirection = "column" gap = { 1 } >
< text fg = { THEME . accent } > Session summary < / text >
< text fg = { THEME . success } > Completed : { completed ( ) } < / text >
< text fg = { THEME . error } > Failed : { failed ( ) } < / text >
< text fg = { THEME . warning } > Blocked : { blocked ( ) } < / text >
< text fg = { THEME . muted } > Use / results for produced files . Projection data is sourced from runtime events . < / text >
< / box >
)
}
function HelpView() {
return (
< box flexDirection = "column" gap = { 1 } >
< text fg = { THEME . accent } > Help < / text >
< text fg = { THEME . text } > Type a task in the prompt and press Enter . < / text >
< text fg = { THEME . text } > Slash commands : / h e l p , / s t a t u s , / t o o l s , / t a s k s , / r e s u l t s , / q u i t . < / t e x t >
< text fg = { THEME . text } > Navigation : Ctrl + 1 tasks , Ctrl + 2 agents , Ctrl + 3 tools , Ctrl + 4 diff , Ctrl + H help , Esc tasks . < / text >
< text fg = { THEME . text } > Prompt : Up / Down browse history at text boundaries ; paste refreshes layout automatically . < / text >
< text fg = { THEME . muted } > Ctrl + C clears draft first , then asks for a second Ctrl + C within 5 s to exit . Permission prompts use 1 / 2 / 3 or a / d . < / text >
< / box >
)
}
function Prompt ( props : {
busy : boolean
phase : FooterPhase
status : string
toast : string
permission? : SessionProjection [ 'permission_prompts' ] [ number ]
textareaRef : ( area? : TextareaRenderable ) = > void
onSubmit : ( ) = > void
onKeyDown : ( event : KeyEvent ) = > void
onPaste : ( ) = > void
onContentChange : ( ) = > void
} ) {
const permissionOptions = ( ) = > props . permission ? . options . length ? props . permission . options : [ 'allow' , 'deny' ]
const phaseLabel = ( ) = > {
if ( props . permission ) return 'Permission'
if ( props . phase === 'confirm_exit' ) return 'Confirm exit'
if ( props . phase === 'error' ) return 'Attention'
return props . busy ? 'Running' : 'Ready'
}
const phaseColor = ( ) = > {
if ( props . permission || props . phase === 'confirm_exit' ) return THEME . warning
if ( props . phase === 'error' ) return THEME . error
return props . busy ? THEME.warning : THEME.success
}
private render_help ( ) : string [ ] {
return [
ANSI . fg . cyan + ANSI . bright + ' Help' + ANSI . reset ,
'' ,
' Keyboard shortcuts:' ,
' 1 - Tasks view Show task list and status' ,
' 2 - Agents view Show agent status and activity' ,
' 3 - Tools view Show available tools and usage' ,
' 4 - Diff view Show file changes' ,
' h - Help Show this help' ,
' q - Quit Exit AirCoding' ,
'' ,
' Getting started:' ,
' air init <project> Initialize a project' ,
' air run Start coding session' ,
' air doctor Run diagnostics' ,
]
return (
< box flexDirection = "column" paddingLeft = { 2 } paddingRight = { 2 } paddingBottom = { 1 } backgroundColor = { THEME . surface } >
< Show when = { props . permission } >
< box flexDirection = "column" paddingBottom = { 1 } >
< text fg = { THEME . warning } > Permission required : { props . permission ? . subject || props . permission ? . tool_name || props . permission ? . prompt_id } < / text >
< text fg = { THEME . muted } > Risk : { props . permission ? . risk_level || 'unknown' } · { props . permission ? . reason || 'No reason provided' } < / text >
< box flexDirection = "row" gap = { 1 } >
< For each = { permissionOptions ( ) } > { ( option , index ) = > (
< text fg = { index ( ) === 0 ? THEME.success : THEME.warning } > { index ( ) + 1 } . { option } < / text >
) } < / For >
< / box >
< / box >
< / Show >
< box flexDirection = "row" justifyContent = "space-between" paddingBottom = { 1 } >
< text fg = { phaseColor ( ) } > { phaseLabel ( ) } < / text >
< text fg = { props . phase === 'error' ? THEME.error : THEME.muted } > { props . toast || props . status } < / text >
< / box >
< textarea
width = "100%"
minHeight = { TEXTAREA_MIN_ROWS }
maxHeight = { TEXTAREA_MAX_ROWS }
wrapMode = "word"
placeholder = { props . busy ? 'Task is running...' : 'Ask AirCoding to change this project, or type /help' }
placeholderColor = { THEME . faint }
textColor = { THEME . text }
focusedTextColor = { THEME . text }
backgroundColor = { THEME . bg }
focusedBackgroundColor = { THEME . bg }
cursorColor = { THEME . accent }
focused = { ! props . busy }
onSubmit = { props . onSubmit }
onKeyDown = { props . onKeyDown }
onPaste = { props . onPaste }
onContentChange = { props . onContentChange }
ref = { props . textareaRef }
/ >
< / box >
)
}
function shortcutToView ( name : string ) : TuiAppState [ 'active_view' ] | undefined {
switch ( name ) {
case '1' : return 'tasks'
case '2' : return 'agents'
case '3' : return 'tools'
case '4' : return 'diff'
case 'h' : return 'help'
default : return undefined
}
}
private render_tasks ( p : SessionProjection ) : string [ ] {
const lines : string [ ] = [ ]
lines . push ( ANSI . fg . cyan + ANSI . bright + ' Tasks' + ANSI . reset )
if ( p . tasks . length === 0 ) {
lines . push ( ANSI . fg . gray + ' No tasks yet.' + ANSI . reset )
return line s
}
for ( const t ask of p . tasks . slice ( 0 , 10 ) ) {
const status_color = task . status === 'completed' ? ANSI.fg.green : task.status === 'failed' ? ANSI.fg.red : ANSI.fg.yellow
lines . push ( ` ${ status_color } ● ${ ANSI . reset } ${ task . title || task . id } ` )
lines . push ( ANSI . fg . gray + ` ID: ${ task . id } | Status: ${ task . status } ` + ANSI . reset )
}
if ( p . tasks . length > 10 ) {
lines . push ( ANSI . fg . gray + ` ... and ${ p . tasks . length - 10 } more tasks ` + ANSI . reset )
}
return lines
function statusColor ( status : string | undefined ) : string {
switch ( status ) {
case 'completed' :
case 'ok' :
case 'active' :
case 'running' :
return THEME . succes s
case 'failed' :
case 'error' :
c ase 'lost' :
return THEME . error
case 'blocked' :
case 'pending' :
case 'cancelled' :
return THEME . warning
default :
return THEME . muted
}
}
private render_agents ( p : SessionProjection ) : string [ ] {
const lines : string [ ] = [ ]
lines . push ( ANSI . fg . cyan + ANSI . bright + ' Agents' + ANSI . reset )
if ( p . agents . length === 0 ) {
lines . push ( ANSI . fg . gray + ' No active agents.' + ANSI . reset )
return lines
}
for ( const agent of p . agents . slice ( 0 , 10 ) ) {
const status_color = agent . status === 'running' ? ANSI.fg.green : agent.status === 'idle' ? ANSI.fg.gray : ANSI.fg.yellow
lines . push ( ` ${ status_color } ● ${ ANSI . reset } ${ agent . type } ` )
lines . push ( ANSI . fg . gray + ` ID: ${ agent . id ? . slice ( 0 , 8 ) } ... | Status: ${ agent . status } ` + ANSI . reset )
}
return lines
function statusMark ( status : string | undefined ) : string {
switch ( status ) {
case 'completed' :
case 'ok' :
return '✓'
case 'failed' :
case 'error' :
return '✗'
case 'running' :
case 'active' :
return '●'
case 'blocked' :
return '!'
default :
return '○'
}
private render_tools ( p : SessionProjection ) : string [ ] {
const lines : string [ ] = [ ]
lines . push ( ANSI . fg . cyan + ANSI . bright + ' Recent Tool Calls' + ANSI . reset )
const recent_calls = p . tasks . slice ( 0 , 10 )
if ( recent_calls . length === 0 ) {
lines . push ( ANSI . fg . gray + ' No tool calls yet.' + ANSI . reset )
return lines
}
for ( const call of recent_calls ) {
lines . push ( ` ${ ANSI . fg . green } ✓ ${ ANSI . reset } ${ call . title || call . type } ` )
lines . push ( ANSI . fg . gray + ` Task: ${ call . id ? . slice ( 0 , 8 ) } ... | Status: ${ call . status } ` + ANSI . reset )
}
return lines
}
private render_diff ( p : SessionProjection ) : string [ ] {
const lines : string [ ] = [ ]
lines . push ( ANSI . fg . cyan + ANSI . bright + ' Recent Changes' + ANSI . reset )
const task_count = p . tasks . length
const completed = p . tasks . filter ( t = > t . status === 'completed' ) . length
const failed = p . tasks . filter ( t = > t . status === 'failed' ) . length
if ( task_count === 0 ) {
lines . push ( ANSI . fg . gray + ' No changes yet.' + ANSI . reset )
return lines
}
lines . push ( ` ${ ANSI . fg . green } ✓ ${ ANSI . reset } Completed: ${ completed } ` )
lines . push ( ` ${ ANSI . fg . red } ✗ ${ ANSI . reset } Failed: ${ failed } ` )
lines . push ( ` ${ ANSI . fg . yellow } ○ ${ ANSI . reset } Pending: ${ task_count - completed - failed } ` )
lines . push ( '' )
lines . push ( ANSI . fg . gray + ` Total tasks: ${ task_count } ` + ANSI . reset )
return lines
}
}
}