Files
AirCoding/packages/tui/src/TuiApp.tsx
AirCoding 2a20a7652f fix(tui): Enter 提交文本,不再换行
OpenTUI textarea 多行模式下 Enter 默认换行,改为拦截 Enter 调用
submitPrompt() 提交。这是一个命令提示符输入框,不需要多行。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 09:24:17 +08:00

719 lines
24 KiB
TypeScript
Executable File

/** @jsxImportSource @opentui/solid */
/**
* TuiApp - OpenTUI/Solid application shell
* DD §13.2. Projection-only display plus single OpenTUI textarea input owner.
*
* @module packages/tui/src/TuiApp
*/
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
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 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 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.onSubmit = props.onSubmit
this.onSlashCommand = props.onSlashCommand
this.onResolvePermission = props.onResolvePermission
this.onExit = props.onExit
}
async start(): Promise<void> {
if (this.renderer) return
this.renderer = await createCliRenderer({
targetFps: 30,
maxFps: 60,
useMouse: false,
autoFocus: false,
exitOnCtrlC: false,
screenMode: 'alternate-screen',
externalOutputMode: 'passthrough',
consoleMode: 'disabled',
clearOnShutdown: true,
openConsoleOnError: false,
useKittyKeyboard: {},
backgroundColor: THEME.bg,
})
this.renderer.setBackgroundColor(THEME.bg)
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.unsubscribeClient = this.client.subscribe((projection) => {
this.setProjection?.(projection)
})
}
stop(): void {
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.setView?.(view)
}
set_busy(busy: boolean, status?: string): void {
this.setBusy?.(busy)
if (status) this.setStatus?.(status)
}
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 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 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)
}
private async exit(): Promise<void> {
await this.onExit?.()
}
}
type StateBindings = {
setProjection: (projection: SessionProjection | null) => void
setView: (view: TuiAppState['active_view']) => void
setBusy: (busy: boolean) => void
setStatus: (status: string) => void
}
type FooterPhase = 'idle' | 'running' | 'permission' | 'confirm_exit' | 'error'
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
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 {
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
}
}
if (event.name === 'return') {
event.preventDefault()
submitPrompt()
return
}
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
}
if (event.name === 'up' && applyHistoryMove(-1)) {
event.preventDefault()
return
}
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()
}
}
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
}
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
}
}
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
}
}
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 '○'
}
}