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. */