diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 2e3e1c73c3..bcd8380260 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -26,6 +26,7 @@ export class RunFooter implements FooterApi { private rows = TEXTAREA_MIN_ROWS private state: Accessor private setState: Setter + private settle = false constructor( private renderer: CliRenderer, @@ -91,12 +92,14 @@ export class RunFooter implements FooterApi { return } - this.setState((state) => ({ - phase: next.phase ?? state.phase, - status: typeof next.status === "string" ? next.status : state.status, - queue: typeof next.queue === "number" ? Math.max(0, next.queue) : state.queue, - model: typeof next.model === "string" ? next.model : state.model, - })) + const prev = this.state() + const state = { + phase: next.phase ?? prev.phase, + status: typeof next.status === "string" ? next.status : prev.status, + queue: typeof next.queue === "number" ? Math.max(0, next.queue) : prev.queue, + model: typeof next.model === "string" ? next.model : prev.model, + } + this.setState(state) } public append(kind: EntryKind, text: string): void { @@ -109,6 +112,7 @@ export class RunFooter implements FooterApi { } this.renderer.writeToScrollback(entryWriter(kind, text, new Date())) + this.scheduleSettleRender() } public close(): void { @@ -117,10 +121,6 @@ export class RunFooter implements FooterApi { } this.notifyClose() - - if (!this.renderer.isDestroyed) { - this.renderer.destroy() - } } public destroy(): void { @@ -208,4 +208,21 @@ export class RunFooter implements FooterApi { private handleDestroy = (): void => { this.notifyClose() } + + private scheduleSettleRender(): void { + if (this.settle || this.destroyed || this.renderer.isDestroyed) { + return + } + + this.settle = true + void this.renderer.idle().then(() => { + this.settle = false + + if (this.destroyed || this.renderer.isDestroyed || this.closed) { + return + } + + this.renderer.requestRender() + }) + } } diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 2abc024e00..306a75b106 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -166,6 +166,7 @@ export function RunFooterView(props: RunFooterViewProps) { let area: Area | undefined let leader = false let timeout: NodeJS.Timeout | undefined + let rowsTick = false const clearLeader = () => { leader = false @@ -192,6 +193,18 @@ export function RunFooterView(props: RunFooterViewProps) { props.onRows(clampRows(area.virtualLineCount || 1)) } + const scheduleRows = () => { + if (rowsTick) { + return + } + + rowsTick = true + queueMicrotask(() => { + rowsTick = false + syncRows() + }) + } + const push = (text: string) => { if (!text) { return @@ -289,6 +302,12 @@ export function RunFooterView(props: RunFooterViewProps) { } const onKeyDown = (event: Key) => { + if (event.ctrl && event.name === "c") { + props.onExit() + event.preventDefault() + return + } + if (handleCycle(event)) { return } @@ -329,36 +348,33 @@ export function RunFooterView(props: RunFooterViewProps) { push(text) area.setText("") - syncRows() + scheduleRows() area.focus() } - const onLineInfoChange = () => { - syncRows() - } - onMount(() => { if (!area || area.isDestroyed) { return } - area.on("line-info-change", onLineInfoChange) - syncRows() + area.on("line-info-change", scheduleRows) + scheduleRows() area.focus() }) onCleanup(() => { clearLeader() + if (!area || area.isDestroyed) { return } - area.off("line-info-change", onLineInfoChange) + area.off("line-info-change", scheduleRows) }) createEffect(() => { term().width - queueMicrotask(syncRows) + scheduleRows() }) createEffect(() => { @@ -443,7 +459,7 @@ export function RunFooterView(props: RunFooterViewProps) { keyBindings={bindings()} onSubmit={onSubmit} onKeyDown={onKeyDown} - onContentChange={syncRows} + onContentChange={scheduleRows} ref={(item) => { area = item as Area }} diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 6ec3e1eafa..fe2b3fe49d 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -1,4 +1,4 @@ -import { createCliRenderer } from "@opentui/core" +import { createCliRenderer, type CliRenderer } from "@opentui/core" import { TuiConfig } from "../../../config/tui" import { RunFooter } from "./footer" import { formatUnknownError, runPromptTurn } from "./stream" @@ -13,6 +13,24 @@ const DEFAULT_KEYBINDS: FooterKeybinds = { inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j", } +function shutdown(renderer: CliRenderer): void { + if (renderer.isDestroyed) { + return + } + + if (renderer.externalOutputMode === "capture-stdout") { + renderer.externalOutputMode = "passthrough" + } + + if (renderer.screenMode === "split-footer") { + renderer.screenMode = "main-screen" + } + + if (!renderer.isDestroyed) { + renderer.destroy() + } +} + function formatModelLabel(model: NonNullable, variant: string | undefined): string { const variantLabel = variant ? ` · ${variant}` : "" return `${model.modelID} · ${model.providerID}${variantLabel}` @@ -218,16 +236,15 @@ export async function runInteractiveMode(input: RunInput): Promise { useMouse: false, autoFocus: false, openConsoleOnError: false, - exitOnCtrlC: true, + exitOnCtrlC: false, useKittyKeyboard: { events: process.platform === "win32" }, screenMode: "split-footer", footerHeight: FOOTER_HEIGHT, externalOutputMode: "capture-stdout", consoleMode: "disabled", + clearOnShutdown: false, }) - renderer.start() - const footer = new RunFooter(renderer, { ...footerLabels({ agent: input.agent, @@ -278,8 +295,6 @@ export async function runInteractiveMode(input: RunInput): Promise { }) } finally { footer.destroy() - if (!renderer.isDestroyed) { - renderer.destroy() - } + shutdown(renderer) } }