OpenTUI textarea 多行模式下 Enter 默认换行,改为拦截 Enter 调用 submitPrompt() 提交。这是一个命令提示符输入框,不需要多行。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
719 lines
24 KiB
TypeScript
Executable File
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 '○'
|
|
}
|
|
}
|