feat(round2+round3): 完整实现 A/B/C/D 主线 + round3-F/H 修复

Round2 主线:
- A: 事件落库地基 (RuntimeApp EventStore 单例 + 14 repo wiring)
- B: 执行体对齐 (read-before-edit, verification-before-completion)
- C: 界面对齐 (@opentui/solid, 删除 runtime 依赖)
- D: 经验闭环 (ExperienceMiner, DebuggerRole, CompactorRole)

Round2 补充修复:
- fail-on-missing 反作弊门禁
- projection-store-apply.test.ts 补写
- 3个空壳测试转行为 (evidence-store, recovery-impl, knowledge-store)
- ask 项目根支持 AIRCODING_PROJECT_ROOT
- Worker 事件契约修复 (task.attempt.started → checkpoint)

Round3-F: cpp 工具切换
- 删除 BuiltInToolRegistrar cpp.* 闭包
- 接入 toolchain-cpp 真实 CppToolRegistrar
- canonical envelope {status/output/metadata}
- ExecutorRole system prompt 对齐新工具名

Round3-H: Doctor 5 类报告
- toolchain (cmake/ninja/cppcheck/clangd/g++)
- display (X11/Wayland + ImageMagick)
- network (internet connectivity)
- provider (api_key/base_url/model/connectivity)

Secret 脱敏:
- 状态交接.md: sk- → \${OPENAI_API_KEY}
- .gitignore: 添加 .air/ .claude/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-09 16:13:16 +08:00
parent e383d5f6a7
commit 5e282a39b4
44 changed files with 2905 additions and 1343 deletions

View File

@@ -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',
hidden: '\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',
accent: '#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.running = true
this.setup_input()
this.render()
this.unsubscribeClient = this.client.subscribe((projection) => {
this.setProjection?.(projection)
})
}
stop(): void {
this.running = false
this.unsubscribe?.()
this.unsubscribe = null
process.stdout.write(ANSI.cursor.show + ANSI.reset)
this.unsubscribeClient?.()
this.unsubscribeClient = null
this.setProjection = undefined
this.setView = 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)
}
process.stdin.on('data', (key: string) => {
this.handle_input(key)
})
set_status(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 (this.state.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.push('')
lines.push(ANSI.fg.gray + ' Press q to exit.' + ANSI.reset)
props.bindState({
setProjection,
setView: setActiveView,
setBusy: (next) => {
setBusy(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))
break
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.get_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.setText('')
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: /help, /status, /tools, /tasks, /results, /quit.</text>
<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 5s 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 lines
}
for (const task 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.success
case 'failed':
case 'error':
case '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
}
}
}