diff --git a/src/tui/agent-event-reducer.test.ts b/src/tui/agent-event-reducer.test.ts index a78e62f0..dd740d73 100644 --- a/src/tui/agent-event-reducer.test.ts +++ b/src/tui/agent-event-reducer.test.ts @@ -353,6 +353,98 @@ describe("reduceTuiState", () => { expect(errMsg?.text).toBe("Turn failed [tool]: boom"); }); + it("renders a calm stopped-by-user notice with a retry prompt on a cancelled loop_failed", () => { + const initial = createInitialTuiState(fakeSession()); + const next = apply(initial, [ + { type: "agent_event", event: { type: "user_message", text: "count the stars" } }, + { type: "message_submitted" }, + { + type: "agent_event", + event: { + type: "loop_failed", + error: new Error("This operation was aborted"), + category: "cancelled", + }, + }, + ]); + expect(next.status).toBe("idle"); + expect(next.lastRunStatus).toBe("stopped by user"); + expect(next.runHistory[0]?.outcome).toBe("cancelled"); + // No warn-styled "Turn failed" wall: the operator did this on + // purpose and the notice says so, carrying the aborted turn's + // prompt for the [try again] affordance. + const warn = next.messages.find( + (m) => m.role === "system" && m.variant === "warn", + ); + expect(warn).toBeUndefined(); + const notice = next.messages.find((m) => m.role === "system"); + expect(notice?.text).toBe("Agent stopped by user."); + expect(notice?.retryText).toBe("count the stars"); + }); + + it("treats any loop_failed during a requested abort as stopped-by-user", () => { + // The abort races the LLM stream: a killed response can surface as + // `[model] model returned empty content` before the AbortError + // does. With `abort_requested` on the books, that is still the + // operator's stop, not a provider failure. + const initial = createInitialTuiState(fakeSession()); + const next = apply(initial, [ + { type: "agent_event", event: { type: "user_message", text: "count the stars" } }, + { type: "message_submitted" }, + { type: "abort_requested" }, + { + type: "agent_event", + event: { + type: "loop_failed", + error: new Error("model returned empty content"), + category: "model", + }, + }, + ]); + expect(next.lastRunStatus).toBe("stopped by user"); + expect(next.aborting).toBe(false); + const notice = next.messages.find((m) => m.role === "system"); + expect(notice?.text).toBe("Agent stopped by user."); + expect(notice?.retryText).toBe("count the stars"); + }); + + it("keeps the warn styling for a loop_failed with no abort on the books", () => { + const initial = createInitialTuiState(fakeSession()); + const next = apply(initial, [ + { type: "message_submitted" }, + { + type: "agent_event", + event: { + type: "loop_failed", + error: new Error("model returned empty content"), + category: "model", + }, + }, + ]); + const warn = next.messages.find( + (m) => m.role === "system" && m.variant === "warn", + ); + expect(warn?.text).toBe("Turn failed [model]: model returned empty content"); + }); + + it("leaves retryText off the stopped notice when no user message exists to re-run", () => { + const initial = createInitialTuiState(fakeSession()); + const next = apply(initial, [ + { type: "message_submitted" }, + { + type: "agent_event", + event: { + type: "loop_failed", + error: new Error("This operation was aborted"), + category: "cancelled", + }, + }, + ]); + const notice = next.messages.find((m) => m.role === "system"); + expect(notice?.text).toBe("Agent stopped by user."); + expect(notice?.retryText).toBeUndefined(); + }); + it("appends the llama hint on transport failure for a custom-id llama-server route", () => { const initial = createInitialTuiState(fakeSession()); const next = apply(initial, [ diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts index 2f8baaf6..db2abf89 100644 --- a/src/tui/agent-event-reducer.ts +++ b/src/tui/agent-event-reducer.ts @@ -17,6 +17,7 @@ import { finishRun, finishRunWithoutHistory, finishTurn, + lastUserMessage, pushRing, startNewRun, upsertReasoning, @@ -458,6 +459,42 @@ function reduceAgentEvent(state: TuiState, event: AgentLoopEvent): TuiState { ); } case "loop_failed": { + // A user-initiated abort is not a failure and must not dress like + // one: the operator pressed stop (the chip, Esc, Ctrl+C or + // `/abort`) and already knows the turn is dead. Instead of the + // warn-styled `Turn failed [cancelled]: This operation was + // aborted` wall, leave a calm system notice that says who stopped + // it — and carry the aborted turn's user message as `retryText`, + // so a mistaken click is one `[try again]` away from undone. + // + // `state.aborting` is checked alongside the category because the + // abort races the LLM stream: killing a response mid-flight can + // surface as `[model] model returned empty content` (or another + // category) before the AbortError ever propagates, and an + // operator who just pressed stop would read that as a provider + // failure they caused. Every abort entry point dispatches + // `abort_requested` first, and `finishRun` clears the flag, so + // the window is exactly the abort the operator asked for. + if (event.category === "cancelled" || state.aborting) { + const lastRunStatus = "stopped by user"; + const prompt = lastUserMessage(state); + return finishRun( + appendChatMessage( + appendFeed(state, { + kind: "loop_failed", + stepIndex: null, + line: `» ${lastRunStatus}`, + color: "yellow", + }), + { + role: "system", + text: "Agent stopped by user.", + retryText: prompt.length > 0 ? prompt : undefined, + }, + ), + { outcome: "cancelled", reason: lastRunStatus, lastRunStatus }, + ); + } const lastRunStatus = `failed [${event.category}]: ${event.error.message}`; const chatError = formatAgentErrorForChat( event.category, diff --git a/src/tui/components/chat-log.test.tsx b/src/tui/components/chat-log.test.tsx index f9ecc30d..e65dda54 100644 --- a/src/tui/components/chat-log.test.tsx +++ b/src/tui/components/chat-log.test.tsx @@ -228,4 +228,31 @@ describe("ChatLog", () => { expect(text).toContain("Hi"); expect(text).toMatch(/reasoning/); }); + + it("hangs a [try again] under the stopped-by-user notice, and only there", () => { + const state: TuiState = { + ...createInitialTuiState(BASE_SESSION), + messages: [ + { + id: "m1", + role: "system", + text: "Agent stopped by user.", + retryText: "count the stars", + timestamp: 1, + }, + { + id: "m2", + role: "system", + text: "queue cleared", + timestamp: 2, + }, + ], + }; + const { lastFrame } = render(); + const text = strip(lastFrame() ?? ""); + expect(text).toContain("Agent stopped by user."); + // Exactly one button: the notice with `retryText` earns it, the + // plain runtime notice under it does not. + expect(text.match(/\[try again\]/g)).toHaveLength(1); + }); }); diff --git a/src/tui/components/chat-log.tsx b/src/tui/components/chat-log.tsx index d93ee188..04e84751 100644 --- a/src/tui/components/chat-log.tsx +++ b/src/tui/components/chat-log.tsx @@ -270,7 +270,18 @@ function FinalisedMessage({ text={message.text} warn={message.variant === "warn"} /> - + + + {/* + Only the abort notice sets `retryText`, and the button resends + THAT — the stopped turn's user prompt — not the notice's own + text. Same shared footer row as every other role, so + `estimateMessageHeight` stays role-blind. + */} + {message.retryText !== undefined ? ( + + ) : null} + ); } diff --git a/src/tui/components/chat-try-again-button.tsx b/src/tui/components/chat-try-again-button.tsx index 20855b96..84a96dda 100644 --- a/src/tui/components/chat-try-again-button.tsx +++ b/src/tui/components/chat-try-again-button.tsx @@ -77,7 +77,10 @@ export function resubmitChatMessage( * prose; sending it back would open a turn whose prompt is the previous * answer, which is not "try again" in any sense an operator means. A * system message is TUI runtime output — queue listings, turn-failed - * lines — and re-sending one as a prompt is worse than nonsense. Asking + * lines — and re-sending one as a prompt is worse than nonsense. The + * one system notice that carries the button — "Agent stopped by user", + * via `ChatMessage.retryText` — is no exception: what it resends is the + * aborted turn's *user* prompt, never its own text. Asking * the model to have another go at the *same* question is a different * feature (it has to drop the last turn, not append one) and it is not * this button. diff --git a/src/tui/components/composer-stop-button.mouse.test.tsx b/src/tui/components/composer-stop-button.mouse.test.tsx new file mode 100644 index 00000000..aaa305a6 --- /dev/null +++ b/src/tui/components/composer-stop-button.mouse.test.tsx @@ -0,0 +1,160 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; + +import { ClipboardProvider } from "../clipboard/clipboard-context.js"; +import { makeMouseSource } from "../mouse/mouse-source.js"; +import type { TuiMouseEvent } from "../mouse/mouse-event.js"; +import { makeTuiEventBus, TuiApp, type TuiAppCallbacks } from "../tui-app.js"; +import type { TuiSessionInfo } from "../tui-state.js"; + +/** + * The stop chip versus the running turn. + * + * Esc, Ctrl+C and `/abort` all stop the agent, but they are keyboard + * lore; the chip is the one *visible* control. These cases pin down the + * whole loop from the outside — through `TuiApp`, real Ink layout, real + * hit-testing: absent while idle, present while a turn is in flight, + * a click on it lands on `onAbort` (the same callback Esc reaches), + * and it leaves the field when the run ends. + */ + +const SESSION: TuiSessionInfo = { + sessionId: "s1", + workingDir: "/tmp/stop-button-mouse", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: false, + approvalLevel: 5, + maxSteps: 10, + skillCount: 0, +}; + +/** The chip's on-screen label — glyph included, so a hint strip's plain + * "stop" wording can never satisfy the assertions below. */ +const STOP_LABEL = "■ stop"; + +const strip = (value: string): string => value.replace(/\[[0-9;]*m/g, ""); + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitUntil( + condition: () => boolean, + what: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await delay(25); + } + throw new Error(`timed out waiting for ${what}`); +} + +function click(x: number, y: number): TuiMouseEvent { + return { + kind: "press", + button: "left", + wheel: null, + x, + y, + shift: false, + alt: false, + ctrl: false, + }; +} + +/** Screen cell of the LAST line containing `needle`. */ +function locateLast( + frame: string, + needle: string, +): { x: number; y: number } { + const lines = frame.split("\n"); + for (let y = lines.length - 1; y >= 0; y -= 1) { + const x = (lines[y] ?? "").indexOf(needle); + if (x !== -1) return { x, y }; + } + throw new Error(`"${needle}" is not on screen:\n${frame}`); +} + +function mountApp() { + const bus = makeTuiEventBus(); + const mouse = makeMouseSource(); + const submitted: string[] = []; + let aborted = 0; + const clipboard = { + copy: async () => true, + }; + const callbacks: TuiAppCallbacks = { + onApprovalDecision: () => {}, + onAbort: () => { + aborted += 1; + }, + onQuit: () => {}, + onMessageSubmitted: (message) => { + submitted.push(message); + }, + }; + const app = render( + + + , + ); + return { + ...app, + mouse, + submitted, + aborted: () => aborted, + finishRun: () => + bus.emit({ + type: "agent_event", + event: { type: "loop_completed", reason: "reply" }, + }), + frame: () => strip(app.lastFrame() ?? ""), + }; +} + +describe("composer stop button", () => { + it("appears while a turn runs, aborts on click, leaves when the run ends", async () => { + const app = mountApp(); + await waitUntil(() => app.frame().includes("send"), "composer on screen"); + // Idle: no run to stop, so no chip to press. + expect(app.frame()).not.toContain(STOP_LABEL); + + // Submitting a message starts a turn; the chip must come with it. + app.stdin.write("do the thing"); + await waitUntil(() => app.frame().includes("do the thing"), "typed text"); + app.stdin.write("\r"); + await waitUntil(() => app.submitted.length === 1, "message submitted"); + await waitUntil( + () => app.frame().includes(STOP_LABEL), + "stop chip on screen while running", + ); + + // A click on the chip is exactly Esc: `onAbort`, once per press. + // Re-click until it lands — targets register a frame after they + // first paint. + const spot = locateLast(app.frame(), STOP_LABEL); + await waitUntil(() => { + app.mouse.emit(click(spot.x + 1, spot.y)); + return app.aborted() > 0; + }, "stop click to land"); + const landed = app.aborted(); + // The click stopped at the chip: it must not have doubled as a + // submit / steer of the (empty) buffer. + expect(app.submitted.length).toBe(1); + + // One settled press must not have queued extra aborts behind the + // first that landed. + await delay(100); + expect(app.aborted()).toBe(landed); + + // Run over: the chip has nothing left to act on and leaves the field. + app.finishRun(); + await waitUntil( + () => !app.frame().includes(STOP_LABEL), + "stop chip gone after the run ended", + ); + app.unmount(); + }); +}); diff --git a/src/tui/components/composer-stop-button.tsx b/src/tui/components/composer-stop-button.tsx new file mode 100644 index 00000000..dfc262ef --- /dev/null +++ b/src/tui/components/composer-stop-button.tsx @@ -0,0 +1,72 @@ +import { Text } from "ink"; +import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { readableOn } from "../theme/readable-foreground.js"; +import { theme } from "../theme/theme.js"; + +/** The label carries its own padding so the chip's ground reads as a button. */ +const STOP_LABEL = " ■ stop "; + +export interface ComposerStopButtonProps { + onPress: () => void; + /** + * Mouse layer for the click target. Same story as the send chip: the + * composer overlay floats over the chat log, so its button registers + * above the base layer — otherwise a covered chat control could win + * the click. + */ + layer?: number; +} + +/** + * The composer's stop chip, drawn inside the input field while a turn + * is in flight. + * + * Esc, Ctrl+C and `/abort` all stop the run already, but every one of + * them is invisible: an operator watching a turn go wrong has no + * on-screen control that says the run *can* be stopped, let alone where. + * The hint strip advertises `[esc] abort`, yet the strip is one row of + * muted text under everything else — a mouse user staring at a runaway + * task deserves a button next to the field they are typing into. + * + * Unlike Send this chip has no disabled state: it only renders while + * `status === "running"`, and a stop button that renders but refuses to + * press would be worse than none. The caller owns that condition, the + * same way it owns wiring the press to the one abort path Esc uses. + * + * The ground is the palette's `error` — stop is the composer's one + * destructive verb and it should not dress like Send. `error` is a page + * token, not one of the guaranteed chip pairs, so the ink is *measured* + * against it (`readableOn`) instead of assumed; that is what keeps the + * label legible across all eleven palettes without a per-theme table. + */ +export function ComposerStopButton({ + onPress, + layer, +}: ComposerStopButtonProps): ReactElement { + const background = theme.colors.error; + const chip = ( + + {STOP_LABEL} + + ); + const mouse = useMouseCommands(); + // No provider (component tests, the wizard's separate Ink tree): + // render the label and stop. Registering a target that swallows the + // click without acting would be worse than no target. + if (!mouse) return chip; + return ( + { + if (!isPrimaryPress(hit.event)) return false; + onPress(); + return true; + }} + > + {chip} + + ); +} diff --git a/src/tui/components/prompt-shell.tsx b/src/tui/components/prompt-shell.tsx index 8ecc150d..8a8fec1a 100644 --- a/src/tui/components/prompt-shell.tsx +++ b/src/tui/components/prompt-shell.tsx @@ -5,6 +5,7 @@ import { useRotatingPlaceholder } from "../hooks/use-rotating-placeholder.js"; import { readableOn } from "../theme/readable-foreground.js"; import { theme } from "../theme/theme.js"; import { ComposerSendButton } from "./composer-send-button.js"; +import { ComposerStopButton } from "./composer-stop-button.js"; import { MultiLineEditor, type MultiLineEditorProps } from "./multi-line-editor.js"; import { PromptMetaBar } from "./prompt-meta-bar.js"; @@ -82,6 +83,16 @@ export interface PromptShellProps /** Optional context readout, rendered at the action bar's right end. */ contextSlot?: ReactElement | null; modeSlot?: ReactElement | null; + /** + * A turn is in flight. Puts the stop chip into the field, next to + * Send — the one moment the composer has a destructive verb to offer. + */ + running?: boolean; + /** + * Stop the running turn. The chat surface passes the same path Esc + * takes; the chip renders only when both `running` and this are set. + */ + onStop?: () => void; } export function PromptShell(props: PromptShellProps): ReactElement { @@ -97,6 +108,8 @@ export function PromptShell(props: PromptShellProps): ReactElement { rightSlot, contextSlot, modeSlot, + running, + onStop, focus, disabled, value, @@ -196,6 +209,18 @@ export function PromptShell(props: PromptShellProps): ReactElement { bare /> + {running && onStop ? ( + + {/* + Stop sits between the buffer and Send, on exactly the + turns it can act on. Inside the field like Send, and for + the same reason: it is a verb for the run the operator + is watching, and the bar below already spends its slots + on status readouts. + */} + + + ) : null} { + callbacks.onAbort(); + dispatch({ type: "abort_requested" }); + }, [callbacks]); + const onEditorChange = useCallback( (next: string) => { // An editor that is unmounting keeps its `useInput` subscription @@ -2001,6 +2012,8 @@ export function TuiApp({ rightSlot={promptRightSlot} contextSlot={promptContextSlot} modeSlot={promptModeSlot} + running={state.status === "running"} + onStop={onStopRun} focus={editorFocus} disabled={!canTypeMessage(state)} claimKey={composerClaimKey} diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index 4c6d3a25..f3ca8f30 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -135,6 +135,14 @@ export interface ChatMessage { text: string; /** `warn` — failure / runtime error styling in {@link SystemBubble}. */ variant?: ChatMessageVariant; + /** + * A user prompt this notice offers to re-run. Set on the system + * notice a user-initiated abort leaves in the chat: the stop was one + * click, so undoing a mistaken one should be too. `chat-log.tsx` + * renders a `[try again]` beside `[copy]` that resubmits THIS text — + * the aborted turn's user message — never the notice's own text. + */ + retryText?: string; /** Number of tool steps the assistant ran inside this turn. */ toolSteps?: number; /** Tool cards (call + result) attached to this assistant turn. */