Files
LLeMbas-CLI/src/tui/app.tsx
T
HomerandClaude Fable 5.1 2183040db8 Harness spec 2.0.0: background work is a job — commands and subagents alike, seen and steered
One registry per process, every job owned by a session: bash's background commands (and a command
still running at its timeout, which now goes to the background instead of being killed) and
subagents started with task background: true. job_list, job_output, job_stop (the former
bash_list, bash_output, bash_kill) and the new job_send and report; a finished job wakes the owning
session in every front end with its marked note. The TUI counts jobs, lists them (/jobs), opens a
command's output, enters a subagent and messages it, moves a command to the background (ctrl+b)
and asks before quitting with jobs running. The link gains the jobs capability. Subagents get a
session row of their own, the spec's preamble and agent texts, and their errors reach the report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 16:17:00 +00:00

656 lines
32 KiB
TypeScript

import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { stricterMode, type App as Runtime } from "../app.ts"
import { isLocal } from "../config/paths.ts"
import type { Effort, Mode } from "../config/schema.ts"
import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
import { Dialog, type DialogSpec } from "./components/dialog.tsx"
import { Messages } from "./components/messages.tsx"
import { PermissionPrompt } from "./components/permission.tsx"
import { copiedMessage, copyText } from "./clipboard.ts"
import { QuestionCard } from "./components/question.tsx"
import { PlanCard } from "./components/plancard.tsx"
import { PlanPanel } from "./components/plan.tsx"
import { JobView } from "./components/jobview.tsx"
import { SubagentView } from "./components/subagent.tsx"
import { Bus } from "../bus/index.ts"
import { Prompt, type PaletteEntry } from "./components/prompt.tsx"
import { FileIndex } from "../project/files.ts"
import { attachmentsFor } from "../project/attach.ts"
import { customCommands, expandCommand, type CustomCommand } from "../project/commands.ts"
import { skillMessage, slug } from "../skill/index.ts"
import { keyMatcher, VoiceController } from "../voice/index.ts"
import { hardlineCommand } from "../permission/hardline.ts"
import { CONTINUE_PROMPT } from "../session/engine.ts"
import { bashTool } from "../tool/bash.ts"
import { MODES } from "../config/schema.ts"
import { Queued, Spinner } from "./components/spinner.tsx"
import { StatusBar } from "./components/status.tsx"
import { currentBranch } from "../git/branch.ts"
import { Welcome } from "./components/welcome.tsx"
import { Share, type SharedPrompt } from "../acp/share.ts"
import { mentionInfo } from "../acp/prompt.ts"
import { TuiContext, type Ctx } from "./context.ts"
import { createView, type View } from "./state.ts"
import { loadTheme, markdownStyle, paintBackground, reasoningStyle, themeNames, type IconSet, type Theme } from "./theme.ts"
const CYCLE: Mode[] = ["manual", "edit", "plan"]
export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () => void; onReload?: (args: string[]) => void }) {
const renderer = useRenderer()
const { app, view } = p
const [theme, setThemeSignal] = createSignal(p.theme)
let icons: IconSet | undefined = app.loaded.config.icons
const [background, setBackground] = createSignal(app.loaded.config.theme_background)
createEffect(() => paintBackground(renderer, theme(), background()))
const md = createMemo((prev?: ReturnType<typeof markdownStyle>) => {
prev?.destroy()
return markdownStyle(theme())
})
const thought = createMemo((prev?: ReturnType<typeof reasoningStyle>) => {
prev?.destroy()
return reasoningStyle(theme())
})
const [dialog, setDialog] = createSignal<DialogSpec>()
// The engine is not reactive; this signal is bumped whenever its model/mode/effort change.
const [rev, setRev] = createSignal(0)
const refresh = () => setRev((n) => n + 1)
const mode = () => (rev(), app.engine.mode)
const model = () => (rev(), app.engine.model.ref)
const effort = () => (rev(), app.engine.effort ?? "")
const busy = () => view.state.phase !== "idle"
const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined)
// A word in the status bar that goes by itself (a copy): it says something happened without
// putting a line into the conversation.
const [flashText, setFlashText] = createSignal<string>()
let flashTimer: ReturnType<typeof setTimeout> | undefined
const flash = (text: string, ms = 2500) => {
setFlashText(text)
clearTimeout(flashTimer)
flashTimer = setTimeout(() => setFlashText(undefined), ms)
}
onCleanup(() => clearTimeout(flashTimer))
// A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the
// model switched with git.
app.bus.on((e) => (e.type === "mode" || e.type === "done") && refresh())
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
app.bus.on((e) => {
if (e.type !== "setting") return
if (e.key === "theme") setThemeSignal(loadTheme(String(e.value), icons).theme)
if (e.key === "theme_background") setBackground(e.value as "theme" | "terminal")
if (e.key === "icons") {
icons = e.value as IconSet
setThemeSignal(loadTheme(theme().name, icons).theme)
}
refresh()
})
app.settings.checks.set("theme", (v) => {
if (!themeNames().includes(String(v))) throw new Error(`no theme "${v}" — ${themeNames().join(", ")}`)
})
let scroll: ScrollBoxRenderable | undefined
let input: TextareaRenderable | undefined
let quitArmed = 0
const dims = useTerminalDimensions()
const ctx: Ctx = { app, view, theme, md, thought, dims }
// Background jobs: the view's list is the registry's at the start and on every session change
// (resume, /new), then follows the `job` events.
const seedJobs = () => view.setJobs(app.jobs().map((j) => app.registry.info(j)))
seedJobs()
app.bus.on((e) => e.type === "session" && seedJobs())
const running = () => app.registry.running(app.jobsSessionId()).length
// A command job opened from /jobs (its live output), and a subagent entered (its transcript in
// place of the session's, the prompt box sending to it). One View per entered job, kept: its bus
// feeds it from the moment it is first entered, its history loaded from the child's messages.
const [jobPanel, setJobPanel] = createSignal<string>()
const [entered, setEntered] = createSignal<string>()
const childViews = new Map<string, View>()
const enterJob = (id: string) => {
const job = app.registry.get(app.jobsSessionId(), id)
if (!job || job.kind !== "subagent") return view.info(`${id} is not a subagent — /jobs lists them`, "warn")
if (!childViews.has(id)) {
const v = createView(job.subagent?.bus ?? new Bus())
v.load(job.subagent?.engine.messages ?? (job.childSessionId && app.store ? app.store.messages(job.childSessionId) : []))
childViews.set(id, v)
}
view.seenJobs(id)
setEntered(id)
}
const leaveJob = () => setEntered(undefined)
/** What is typed while a subagent is entered goes to it: its inbox while it runs, a new prompt
* under the same job once it has finished. */
const sendToJob = (text: string) => {
const id = entered()!
const job = app.registry.get(app.jobsSessionId(), id)
if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn")
if (job.state === "running") {
job.subagent.send(text)
flash(`sent to ${id} — it reads it at its next step`)
} else {
app.registry.restartSubagent(job)
childViews.get(id)?.user(text)
void job.subagent.restart(text)
flash(`${id} is running again with your message`)
}
}
/** Leaving with jobs running is asked about: `N jobs running — stop them and quit?`. `what`:
* quit or reload; `then`: what happens once they are stopped (or when none run). */
const confirmStop = (what: string, then: () => void) => {
const n = running()
if (!n) return then()
const go = () => {
app.registry.stopAll(app.jobsSessionId())
then()
}
setDialog({
title: `${n} job${n === 1 ? "" : "s"} running — stop ${n === 1 ? "it" : "them"} and ${what}?`,
options: [
{ label: `Stop and ${what}`, value: "yes", description: "a command gets SIGTERM, a subagent is cancelled" },
{ label: "Cancel", value: "no" },
],
hint: `y stop and ${what} · n / esc cancel`,
keys: [
{ when: (k) => k.name === "y" && !k.ctrl, run: () => go() },
{ when: (k) => k.name === "n" && !k.ctrl, run: () => {} },
],
onSelect: (v) => v === "yes" && go(),
})
}
const quit = () => confirmStop("quit", () => p.onQuit())
/** ctrl+b: the foreground command of this step goes to the background now, as it would at its
* timeout — the call returns with the output so far and the job's id. */
const backgroundNow = () => {
const bashRunning = view.state.items.some((it) => it.kind === "tool" && it.name === "bash" && it.status === "running" && !String(it.title ?? "").startsWith("! "))
if (bashRunning && app.engine.backgroundNow()) flash("moving the command to the background…")
else flash("no command is running in the foreground")
}
// Voice: the record key, what was heard, replies read aloud.
const voiceCfg = app.loaded.config.voice
const recordKey = keyMatcher(voiceCfg?.record_key)
if (recordKey.warning) view.info(recordKey.warning, "warn")
const [voiceRev, setVoiceRev] = createSignal(0)
let meter: ReturnType<typeof setInterval> | undefined
const voice = new VoiceController(voiceCfg, {
changed: () => {
setVoiceRev((n) => n + 1)
// While recording, the meter and the clock move.
if (voice.state === "recording" && !meter) meter = setInterval(() => setVoiceRev((n) => n + 1), 150)
if (voice.state !== "recording" && meter) (clearInterval(meter), (meter = undefined))
},
heard: (text) => {
// Sent as words, never as a command: a transcript that starts with "/" or "!" is still speech.
if (voiceCfg?.submit === "send" && app.turns.steer(text)) return
if (voiceCfg?.submit === "send" && !busy()) return void send(text)
const now = input?.plainText ?? ""
input?.setText(now.trim() ? `${now.replace(/\s+$/, "")} ${text}` : text)
input?.gotoBufferEnd()
},
notice: (m, warn) => view.info(m, warn ? "warn" : undefined),
})
onCleanup(() => {
if (meter) clearInterval(meter)
void voice.close()
})
// A finished reply is read out when replies are spoken.
app.bus.on((e) => {
if (e.type !== "done" || e.reason !== "stop" || !voice.speakReplies) return
const last = [...app.engine.messages].reverse().find((m) => m.role === "assistant")
const text = last && last.role === "assistant" ? last.parts.map((x) => (x.type === "text" ? x.text : "")).join("") : ""
if (text.trim()) void voice.speak(text)
})
const voiceStatus = () => {
voiceRev()
rev()
if (voice.state === "recording") {
const bars = " ▁▂▃▄▅▆▇"
const s = Math.floor(voice.seconds)
return `● REC ${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")} ${bars[Math.min(7, Math.floor((Math.min(voice.level, 8000) * 7) / 8000))]} ${recordKey.label} stops`
}
if (voice.state === "transcribing") return "◉ transcribing"
if (voice.speaking) return `${theme().icons.speaker} speaking · esc stops`
if (!voice.canListen && !voice.speakReplies) return ""
return `${voice.canListen ? `${theme().icons.mic} ${recordKey.label}` : ""}${voice.speakReplies ? `${voice.canListen ? " · " : ""}${theme().icons.speaker} on` : ""}`
}
const cmd: CommandCtx = {
...ctx,
voice,
openDialog: (spec) => setDialog(spec),
setMode(m: Mode) {
app.settings.set("mode", m)
},
setEffort(e: Effort | null) {
app.settings.set("effort", e ?? "off")
},
setTheme(name: string) {
try {
app.settings.set("theme", name, "global")
} catch (e) {
view.info((e as Error).message, "warn")
}
},
setIcons(set: IconSet) {
app.settings.set("icons", set, "global")
},
flash,
refresh,
quit,
reload() {
if (!p.onReload) return view.info("/reload is not available here", "warn")
// The new process would not know the jobs; they would run on, orphaned, and their notes be
// lost — so they are stopped first, after asking.
confirmStop("reload", () => {
// Back on this session, with what it was using; everything else comes from the files again.
const e = app.engine
const args = [...(isLocal() ? ["-l"] : []), ...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort ?? "off"]
void app
.close()
.catch(() => {})
.finally(() => {
app.store?.db.close()
p.onReload!(args)
})
})
},
openJob: (id) => setJobPanel(id),
enterJob,
copy: (text: string) => copy(text),
renderer,
send: (text: string) => void send(text),
share: undefined as Share | undefined,
continueTask: () => continueTask(),
sendAs: (prompt: string, shown: string, opts?: { model?: string; mode?: string }) => void sendAs(prompt, shown, opts),
setInput(text: string) {
input?.setText(text)
input?.gotoBufferEnd()
},
}
const files = new FileIndex(app.project.root)
const custom = () => customCommands(app.trusted ? app.project.dir : undefined)
// A skill is also a command, /<its name>, unless a built-in or custom command has the name
// (then /skill <name> reaches it) — Hermes' rule.
const skillCommands = () => {
const taken = new Set(custom().map((c) => c.name))
return app.skills().filter((s) => !findCommand(slug(s.name)) && !taken.has(slug(s.name)))
}
const palette = (): PaletteEntry[] => [
...COMMANDS.map((c) => ({ name: c.name, args: c.args, description: c.description, source: "builtin" as const })),
...custom().filter((c) => !findCommand(c.name)).map((c) => ({ name: c.name, description: c.description, source: c.source, args: c.body.includes("$") ? "[args]" : undefined })),
...skillCommands().map((s) => ({ name: slug(s.name), description: s.description.slice(0, 80), source: "skill" as const, args: "[instruction]" })),
...app.mcp.prompts().map((m) => ({ name: m.name, description: m.description.slice(0, 80), source: "mcp" as const, args: m.args.length ? m.args.map((a) => `<${a}>`).join(" ") : undefined })),
]
/** Send a prompt: @references attached, snapshots around it. `shown` is what the transcript says.
* `attachFrom`: where @references are read from, when not all of `text` is the user's — an MCP
* server's prompt or a skill must not attach ~/.ssh/… by naming it. `given`: a prompt from the web
* UI, its attachments read already and its turn id its own. */
const send = (text: string, shown = text, attachFrom = text, given?: Pick<SharedPrompt, "extra" | "attachments" | "turnId">): Promise<string> => {
voice.stopSpeaking()
const atts = given ? [] : attachmentsFor(attachFrom, app.engine.o.toolCtx, app.engine.model.spec.vision === true)
for (const a of atts) files.used(a.path)
view.user(shown)
if (atts.length) view.info(`attached ${atts.map((a) => (a.image ? `${a.path} (image)` : a.text.includes("not attached") ? `${a.path} (not attached: ${/not attached: ([^)]*)/.exec(a.text)?.[1]})` : a.path)).join(", ")}`)
if (given?.attachments.length) view.info(`attached ${given.attachments.map((a) => a.name).join(", ")}`)
scroll?.scrollTo(scroll.scrollHeight)
const extra = given ? given.extra : atts.flatMap((a) => (a.image ? [a.text, a.image] : [a.text]))
// Chips for the web UI, when this session is shared: what went with the prompt.
const attachments = given ? given.attachments : atts.map((a) => mentionInfo(a.path, app.engine.o.toolCtx.cwd))
return app.turns
.prompt(text, extra, shown, { ...(given ? { turnId: given.turnId } : {}), attachments })
.then((reason) => {
// What was sent during the task and never reached the model: stopped — back into the box,
// to edit or send; otherwise (queue mode, or a turn that ended first) — sent now. After a
// tick, so a command's own model and mode (sendAs) are put back first. (A job's note left
// over the same way is the app's business: app.ts wakes the session with it.)
const left = app.turns.takeInbox()
if (!left.length) return reason
const joined = left.join("\n\n")
if (reason === "cancelled") {
const now = input?.plainText.trim() ?? ""
input?.setText(now ? `${joined}\n\n${now}` : joined)
input?.gotoBufferEnd()
view.info("stopped — what you sent meanwhile is back in the box")
} else setTimeout(() => void send(joined), 0)
return reason
})
.catch((e) => (view.info((e as Error).message, "warn"), "error"))
}
// The web UI as a second keyboard on this session: shared through the service by itself,
// or by name with /remote. What it sends runs here as if typed, and is shown as such.
// The account's balance in the status bar, when it has a plan: read at the start and
// after a reply, at most once a minute; an instance that cannot say leaves nothing there.
const [credits, setCredits] = createSignal("")
let creditsAt = 0
const readCredits = (force = false) => {
if (!force && Date.now() - creditsAt < 60_000) return
creditsAt = Date.now()
void import("../lembas/personal.ts").then((m) => m.accountUsage(5000).then((r) => setCredits(m.balanceLabel(r?.usage)))).catch(() => {})
}
readCredits(true)
app.bus.on((e) => e.type === "task" && e.state === "end" && readCredits())
cmd.setCredits = (text: string) => {
creditsAt = Date.now()
setCredits(text)
}
const share = new Share(app, {
prompt: async (input, started) => {
// Working already: it runs when this turn is done, as a message typed in queue mode
// would. It used to be refused, and the web UI lost it to an error.
while (busy() || app.turns.current) await Bun.sleep(150)
started()
view.info("↓ from the web UI")
// Expanded already (a command, @files, uploads); a command's model and mode hold for
// this prompt only, as when it is typed here.
if (input.model || input.mode) return sendAs(input.prompt, input.shown, { model: input.model, mode: input.mode, given: input })
return send(input.prompt, input.shown, "", input)
},
action: async (name) => {
view.info(`↓ /${name} from the web UI`)
if (name === "compact") {
const summary = await app.engine.compact()
view.clear()
view.info(`compacted in the web UI — the model continues from this summary:\n\n${summary}`)
return { summary }
}
if (name === "undo") {
const r = app.turns.undo()
if (r) view.load(app.engine.messages)
view.info(r ? (r.files.length ? `undone — restored ${r.files.join(", ")}` : "undone — no files had changed") : "nothing to undo in this session")
return { undone: r ?? null }
}
cmd.setMode("plan")
return { mode: "plan" }
},
compact: async () => {
view.info("compacting… (asked in the web UI)")
const summary = await app.engine.compact()
view.clear()
view.info(`compacted in the web UI — the model continues from this summary:\n\n${summary}`)
return summary
},
deleted: () => {
app.newSession()
view.clear()
view.info("the web UI deleted that session — this is a new one", "warn")
},
notice: (text, level) => view.info(text, level),
changed: () => refresh(),
rewound: () => view.load(app.engine.messages),
})
cmd.share = share
view.setCardHooks({
ask: (req, local) => share.ask(req, local),
question: (req, callId, local) => share.question(req, callId, local),
plan: (req, callId, local) => share.plan(req, callId, local),
})
void share.start()
onCleanup(() => share.close())
const shell = (command: string): boolean | void => {
if (busy()) return (view.info("still working — esc stops the current reply; !commands wait for it", "warn"), false)
const hit = hardlineCommand(command, app.engine.o.perm.hardline)
if (hit) return view.info(`not run — ${hit.description}`, "warn")
// Not through the engine's events: this is the user's own command, not a model turn, and the
// view must not go busy for it.
const id = view.shellStart(command)
const t0 = performance.now()
bashTool
.run({ command }, { ...app.engine.o.toolCtx, signal: new AbortController().signal, bashTimeline: false })
.then((r) => {
view.shellEnd(id, r, performance.now() - t0)
app.engine.pendingContext.push(`<shell command="${command.replaceAll('"', "'")}">\n${r.output}\n</shell>`)
view.info("the output goes to the model with your next message (ctrl+o shows it here)")
})
.catch((e) => view.info(`! failed: ${(e as Error).message}`, "warn"))
}
/** false: refused — the text stays in the box. */
const submit = (text: string): boolean | void => {
const t = text.trim()
// Inside a subagent, what is typed is for it; commands are still the session's.
if (entered() && !t.startsWith("/")) return void sendToJob(t)
if (t.startsWith("!") && t.length > 1) return shell(t.slice(1).trim())
if (t.startsWith("/")) {
const [name, ...rest] = t.slice(1).split(/\s+/)
const arg = rest.join(" ")
const c = findCommand(name ?? "")
if (c) {
// Not mid-task: what changes the session under it, and what sends a prompt of its own
// (that would start a second turn on top of the running one).
const midTask = ["new", "delete", "compact", "sessions", "model", "undo", "redo", "commit", "diff", "release", "changelog", "init", "checkpoint", "checkpoints", "branch", "reload", "review", "skill"]
if ((busy() || app.turns.current) && (midTask.includes(c.name) || (c.name === "plan" && arg && arg !== "show"))) return (view.info("wait for the reply to finish (esc stops it)", "warn"), false)
return void c.run(cmd, arg)
}
const cc = custom().find((x) => x.name === name)
const sk = cc ? undefined : skillCommands().find((s) => slug(s.name) === slug(name ?? ""))
const mp = cc || sk ? undefined : app.mcp.prompts().find((m) => m.name === name)
if (!cc && !sk && !mp) return view.info(`no command /${name} — /help lists them`, "warn")
if (busy() || app.turns.current) return (view.info("still working — esc stops the current reply; commands wait for it", "warn"), false)
if (mp)
return void app.mcp
.promptText(mp, arg)
.then((text) => void (text.trim() ? sendAs(text, t, { attachFrom: arg }) : view.info(`/${name}: the prompt came back empty`, "warn")))
.catch((e) => view.info(`/${name}: ${(e as Error).message}`, "warn"))
if (sk) return void sendAs(skillMessage(sk, arg), t, { attachFrom: arg })
return void runCustom(cc!, arg, t)
}
// While a task runs, a message joins it: at the next step, or after it (queue).
if (app.turns.steer(t)) return
if (busy()) return (view.info("still working — esc stops the current reply", "warn"), false)
void send(t)
}
/** The messages waiting to go in, for ↑ in the box. */
const queueAccess = {
count: () => app.engine.queued,
take: (i: number) => app.engine.unqueue(i),
putBack: (i: number, text: string) => app.engine.putBack(i, text),
}
/** Carry on where it stopped: after esc, a step limit, or an answer that ended too soon. What
* it had written before a stop is in the history (the engine keeps it), so it can pick up there. */
const continueTask = () => {
if (busy() || app.turns.current) return view.info("it is still working — esc stops it; ctrl+g carries on after that", "warn")
if (!app.engine.messages.length) return view.info("nothing to continue yet — send a prompt first", "warn")
void send(CONTINUE_PROMPT, "↻ continue")
}
/** Send a prompt with a model and/or mode that hold for this one prompt only. */
const sendAs = async (prompt: string, shown: string, opts: { model?: string; mode?: string; attachFrom?: string; given?: Parameters<typeof send>[3] } = {}) => {
const prev = { model: app.engine.model.ref, mode: app.engine.mode, effort: app.engine.effort }
try {
if (opts.model) app.switchModel(opts.model)
if (opts.mode && (MODES as readonly string[]).includes(opts.mode)) app.engine.mode = opts.mode as Mode
refresh()
return await send(prompt, shown, opts.attachFrom ?? prompt, opts.given)
} catch (e) {
view.info(`${shown.split(" ")[0]}: ${(e as Error).message}`, "warn")
return "error"
} finally {
// The effort as it was too: switching back would set the model's default.
if (opts.model) {
app.switchModel(prev.model)
app.engine.effort = prev.effort
}
app.engine.mode = prev.mode
refresh()
}
}
/** A custom command: its expanded body is the prompt; its frontmatter's model and mode hold for
* this one prompt. */
const runCustom = (cc: CustomCommand, arg: string, typed: string) =>
sendAs(expandCommand(cc, arg, app.project.root, app.engine.o.perm.hardline), typed, {
// @files come from what was typed, not from the command's own text.
attachFrom: arg,
model: cc.model,
// A project's command may make the mode stricter for its prompt, never looser; your own may.
mode: cc.source === "project" && cc.mode && (MODES as readonly string[]).includes(cc.mode) ? stricterMode(cc.mode as Mode, app.engine.mode) : cc.mode,
})
const holdToTalk = voiceCfg?.record_mode === "hold"
let lastPress = 0
useKeyboard(
(key) => {
if (key.eventType === "release") {
// Hold-to-talk: letting go of the key stops the recording (the modifier may go first).
if (holdToTalk && voice.state === "recording" && recordKey.released(key)) void voice.toggle()
return
}
// A card or dialog owns the keyboard: esc there means that card's own thing.
const overlay = dialog() || jobPanel() || view.state.pending || view.state.question || view.state.plan
if (!overlay && recordKey.matches(key) && (voice.canListen || voiceCfg)) {
key.preventDefault()
// Held down, a key repeats: in hold mode (or any repeat) only the first press counts.
const now = Date.now()
const repeat = key.repeated || (holdToTalk && now - lastPress < 500)
lastPress = now
if (!repeat) void voice.toggle()
return
}
if (overlay) return keys(key)
if (key.name === "escape" && voice.state === "recording") {
void voice.cancel().then(() => view.info("recording cancelled"))
return key.preventDefault()
}
if (key.name === "escape" && voice.speaking) {
voice.stopSpeaking()
return key.preventDefault()
}
keys(key)
},
{ release: holdToTalk },
)
const keys = (key: Parameters<Parameters<typeof useKeyboard>[0]>[0]) => {
if (dialog() || jobPanel() || view.state.pending || view.state.question || view.state.plan) return
if (key.name === "escape" && entered()) {
// Back to the session; the model is not stopped by leaving a subagent.
leaveJob()
key.preventDefault()
} else if (key.name === "escape" && busy()) {
app.cancel()
key.preventDefault()
} else if (key.ctrl && key.name === "c" && (key.shift || renderer.hasSelection)) {
// Ctrl+Shift+C (where the terminal passes it on), or Ctrl+C over a selection: copy it.
const sel = renderer.getSelection()?.getSelectedText() ?? ""
if (sel) copy(sel)
else flash("nothing selected — drag over text, or /copy the last reply")
renderer.clearSelection()
key.preventDefault()
} else if (key.ctrl && key.name === "d" && !(input?.plainText ?? "").trim()) {
// An empty box and ctrl+d: leave, as a shell does.
quit()
key.preventDefault()
} else if (key.ctrl && key.name === "b") {
backgroundNow()
key.preventDefault()
} else if (key.ctrl && key.name === "c") {
// The first press stops the model (if busy); a second within 1.5 s always quits.
if (Date.now() - quitArmed < 1500) quit()
else {
if (busy()) app.cancel()
quitArmed = Date.now()
view.info("ctrl+c again to quit")
}
key.preventDefault()
} else if (key.name === "tab" && key.shift) {
const i = CYCLE.indexOf(app.engine.mode)
cmd.setMode(CYCLE[(i + 1) % CYCLE.length]!)
key.preventDefault()
} else if (key.ctrl && key.name === "g") {
continueTask()
key.preventDefault()
} else if (key.ctrl && key.name === "r") {
view.toggleReasoning()
key.preventDefault()
} else if (key.ctrl && key.name === "o") {
view.toggleTools()
key.preventDefault()
} else if (key.name === "pageup") {
// Half a screen: a whole one loses the place.
scroll?.scrollBy(-Math.max(1, Math.floor(scroll.height / 2)))
key.preventDefault()
} else if (key.name === "pagedown") {
scroll?.scrollBy(Math.max(1, Math.floor(scroll.height / 2)))
key.preventDefault()
} else if (key.ctrl && (key.name === "up" || key.name === "down")) {
scroll?.scrollBy(key.name === "up" ? -3 : 3)
key.preventDefault()
}
}
// What the mouse selects is copied when the button comes up (see clipboard.ts). It is said in
// the status bar for a moment, never as a line of the transcript: a line there lands in the
// middle of a reply and splits a thinking block that is still running. Only a copy that could
// not go anywhere is a transcript line, since it says what to do instead.
const copy = (text: string) => {
if (!text) return
const m = copiedMessage(text.length, copyText(renderer as never, text))
if (m.warn) view.info(m.text, "warn")
else flash(m.text)
}
const onSelection = (sel: { getSelectedText(): string } | null) => {
const text = sel?.getSelectedText() ?? ""
if (text.trim()) copy(text)
}
renderer.on("selection", onSelection)
onCleanup(() => renderer.off("selection", onSelection))
return (
<TuiContext.Provider value={ctx}>
<box flexDirection="column" width="100%" height="100%">
<Show
when={entered()}
keyed
fallback={
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
flexGrow={1}
stickyScroll
stickyStart="bottom"
viewportOptions={{ paddingRight: 1 }}
verticalScrollbarOptions={{ trackOptions: { backgroundColor: theme().colors.panel, foregroundColor: theme().colors.dim } }}
>
<Welcome />
<Messages />
</scrollbox>
}
>
{(id: string) => <SubagentView id={id} view={childViews.get(id)!} scrollRef={(r) => (scroll = r)} />}
</Show>
<box flexShrink={0} flexDirection="column">
<PlanPanel />
<Show when={view.state.pending}>
<PermissionPrompt pending={view.state.pending!} />
</Show>
<Show when={view.state.question && !view.state.pending}>
<QuestionCard pending={view.state.question!} />
</Show>
<Show when={view.state.plan && !view.state.pending && !view.state.question}>
<PlanCard pending={view.state.plan!} />
</Show>
<Show when={busy() && !view.state.pending && !view.state.question && !view.state.plan}>
<Spinner />
</Show>
<Queued />
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} credits={credits} />
</box>
<Show when={dialog()}>
<Dialog spec={dialog()!} close={() => setDialog(undefined)} />
</Show>
<Show when={jobPanel()} keyed>
{(id: string) => <JobView id={id} close={() => setJobPanel(undefined)} />}
</Show>
</box>
</TuiContext.Provider>
)
}