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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user