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>
656 lines
32 KiB
TypeScript
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>
|
|
)
|
|
}
|