Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The evidence is in how the product fails today: the personas already produce exc
5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption.
6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why.
7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin.
8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop).
8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` is parent-busy, so Enter is a queued steer, not a new turn. An in-flight TUI-primary `wait_agents` yields as a timeout when that steer is queued so occupancy can deliver it. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges, and held messages list in a pending column stacked on the prompt box — `↑`/`↓` select, `Enter` force-pushes one now, `Ctrl+X` drops it — instead of echoing labelled transcript rows; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop).
9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators collect through mailbox mail the same way. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored.

## User Experience
Expand Down
55 changes: 41 additions & 14 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,27 +628,51 @@ Two mid-run gestures, two delivery times (CL-6290):
- **Enter, mid-run** — soft steer: enqueues kind `"steer"` and delivers at the
next **parent** `tool.boundary` (the parent tool finishing, not a child) via
`Agent.deliver` into the live reactor, not a new `send`. A
long parent `run_shell` is parent-busy and holds steers. A queued steer
delivers at the next parent `tool.boundary` so occupancy can pick it up.
The transcript row says
`[will steer next]` while pending and
`[steering]` once delivered (`submitPrompt`, `drainSteersAtBoundary` in
`runtime-bridge.ts`). If the captured target agent is already closed when
delivery runs, the bridge restores the exact message (and attachments) to an
empty prompt, or FIFO-defers behind a draft the operator already typed — it
never auto-sends to a rebuilt successor. The transcript row is corrected to
long parent `run_shell` or an awaiting `wait_agents` is parent-busy and holds
steers. An in-flight TUI-primary `wait_agents` yields as a timeout when a
steer is queued so occupancy can pick it up. A queued steer delivers at the
next parent `tool.boundary` so occupancy can pick it up. Pending items list
in the pending column above the prompt, not the transcript; the transcript
only ever sees the item that actually delivers, as an ordinary user row
(`submitPrompt`, `drainSteersAtBoundary` in `runtime-bridge.ts`). If the
captured target agent is already closed when delivery runs, the bridge
restores the exact message (and attachments) to an empty prompt, or
FIFO-defers behind a draft the operator already typed — it never
auto-sends to a rebuilt successor. The delivered row is corrected to
`[not delivered]` (or `[delivery uncertain]` for non-closed failures), and
another explicit Enter is required before any new logical delivery.
- **Alt+Enter, mid-run** — follow-up: enqueues kind `"queue"` and delivers
only on **session-idle** (parent-idle and no live fleet lanes) as a `send`.
Does not interrupt or reinject. The transcript row says `[will follow up]`
while pending and `[following up]` once delivered. Idle, or with an empty
Does not interrupt or reinject. Idle, or with an empty
prompt, Alt+Enter does nothing — there is nothing to wait for. (Internal
`"reinject"` remains in the submit API for tests; no product chord wires it.)
Closed-target recovery for follow-ups uses the same prompt-restore / draft-
defer ownership as soft steer; `/clear`, `/new`, and dispose discard both
in-flight deliveries and deferred recoveries with the old session.

Held items never touch the transcript. Both kinds list in the **pending
column** — a transient zone stacked directly on the prompt box, one row per
item (`› steer …` / `› follow-up …`, `▸` on the selected row), oldest items
folding into a leading `+N more` past four shown rows so the newest items —
nearest the prompt, first selected — stay visible, and a guidance row naming
its keys (`pending-column.ts`, painted by `syncPendingRows` in `chrome.ts`).
The transcript only ever sees the item that actually delivers, as an ordinary
user row — pending/delivery labels (`[will steer next]`, `[steering]`,
`[following up]`) are gone on purpose.

While the column has items, `↑` at the prompt buffer's top edge selects the
newest held item and `↑`/`↓` walk the rows; `↓` past the last row hands the
key back to the prompt. On a selected row, **Enter** kills the item out of the
queue and force-pushes it — `onForceDeliver` drops it on the same
`port.deliver` hop a drain uses, so a steer still injects when the parent
cycle is live and otherwise sends immediately. **Ctrl+X** drops the selected
item outright. **Ctrl+G** pops the selected item back into an empty prompt
for editing (dropping it mid-compose); with no selection it pops the newest
held item instead. **Esc** ends the selection; any other composer key ends it
and falls through to normal handling, except `↑`/`↓`, which stay with the
column. While an overlay is open, keys go to the overlay and leave the
selection alone.

When `steer > 0` and a parent tool has been in flight ≥ `STEER_WAIT_NOTICE_MS`
(3s), the notice row adds `waiting on <tool>` (e.g. `waiting on run_shell`).
Follow-up-only does not; a sub-threshold in-flight tool does not. Delivery is
Expand Down Expand Up @@ -691,7 +715,9 @@ session exit still call `subAgentSessions.cancelAll` for an explicit
session-wide cancel; that path is separate from interrupt and must stay off
the soft-steer / follow-up gestures.

Up/Down are caret motion first inside a multi-line buffer. History recall
Up/Down are caret motion first inside a multi-line buffer — except while the
pending column is engaged, when ↑ at the buffer's top edge selects a held item
instead (see "Soft steer vs. follow-up"). History recall
only fires when the caret is already at the first or last wrapped row of the
buffer — i.e., has nowhere further to go
(`promptCaretAtFirstRow`/`promptCaretAtLastRow` in `prompt-input.ts`,
Expand Down Expand Up @@ -800,8 +826,9 @@ list they move the active selection. Only the mouse wheel and the modal's
own page keys (PgUp/PgDn) move a scroll position, and only the surface
holding the current scroll lease responds to them.

`Ctrl+G` (the Emacs/readline "abort" chord) cancels the most recently queued
mid-run message. `Tab` toggles focus between the prompt and the transcript.
`Ctrl+G` (the Emacs/readline "abort" chord) pops the most recently queued
mid-run message back into an empty prompt for editing, or drops it mid-compose.
`Tab` toggles focus between the prompt and the transcript.
`Shift+Tab` cycles reasoning effort for the current model (wrapping the
supported ladder) and flashes the new level; the prompt-border effort
segment updates immediately. A model with no effort levels flashes instead
Expand Down
1 change: 1 addition & 0 deletions src/tui/geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("zone registry", () => {
"progress",
"progress_divider",
"notice",
"pending",
"prompt",
"task",
"agents",
Expand Down
3 changes: 3 additions & 0 deletions src/tui/geometry/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface OverlayInput {
export interface ZoneVisibility {
/** Transient notice row on (default off). */
readonly notice?: boolean;
/** Pending queue column: exact row count requested (bounded by the zone max). */
readonly pending?: boolean | number;
/** Progress: false/omit = 0; true = 2; or explicit 1|2. */
readonly progress?: boolean | 1 | 2;
/** Progress divider (0–1). Default on when progress is shown. */
Expand Down Expand Up @@ -154,6 +156,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights {
progress: clamp(progressRows, 0, ZONE_REGISTRY.progress.max),
progress_divider: progressDivider,
notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault,
pending: clamp(boolOrRows(vis.pending, 1), 0, ZONE_REGISTRY.pending.max),
prompt: promptRows,
task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max),
// The board asks for exactly the rows it will paint; the fraction is what
Expand Down
22 changes: 22 additions & 0 deletions src/tui/geometry/zones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const ZONE_IDS = [
"progress",
"progress_divider",
"notice",
"pending",
"prompt",
"task",
"agents",
Expand Down Expand Up @@ -69,6 +70,13 @@ export const FLEET_FLOOR_MIN_LANES = 2;
*/
export const TASKS_PANEL_MAX_VISIBLE = 5;

/**
* Queued steer/follow-up rows the pending column lists before folding into a
* trailing "+N more" row. The column is a glance at what will send, not a
* full editor for the queue — a deep stack is rarer than the room it costs.
*/
export const PENDING_MAX_VISIBLE = 4;

/**
* Fixed-with-test budgets from the constitution table.
* Residual zones (transcript, overlay_host) use min/max as floor/cap hints;
Expand All @@ -86,6 +94,16 @@ export const ZONE_REGISTRY: Readonly<Record<ZoneId, ZoneDeclaration>> = {
// Transient: rows only while the shell has state worth a row (queue depth,
// latched interrupt, a flash, a live turn). Idle it is off.
notice: { id: "notice", min: 0, max: 1, idleDefault: 0, alwaysOn: false },
// Queued steer/follow-up messages stacked directly on the prompt box —
// one row per shown item, a leading "+N more" fold plus a key-guidance
// row, bounded by the zone max.
pending: {
id: "pending",
min: 0,
max: PENDING_MAX_VISIBLE + 2,
idleDefault: 0,
alwaysOn: false,
},
// Grows with what is being composed; the resolver caps it at PROMPT_CAP_FRACTION
// and collapses it back toward min when the transcript would breach its floor.
prompt: {
Expand Down Expand Up @@ -202,6 +220,9 @@ export const COLLAPSE_ORDER = [
"progress",
"progress_divider",
"notice",
// Pending items are the operator's own queued words: cut last of the
// optionals, just ahead of prompt growth reclaim.
"pending",
// prompt growth reclaimed next (handled specially; never below PROMPT_BASE_ROWS)
"prompt",
] as const satisfies readonly ZoneId[];
Expand All @@ -222,6 +243,7 @@ export const PAINT_ORDER = [
"progress",
"progress_divider",
"notice",
"pending",
"prompt",
] as const satisfies readonly ZoneId[];

Expand Down
9 changes: 4 additions & 5 deletions src/tui/gutter-labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,13 @@ const OVERLAY_KIND_GUTTER = {

const CHROME_LITERALS = ["error", "plan", "report", "stop", "observe"] as const;

// Queued items no longer store a meta — pending state lives in the column
// and delivery paints a plain operator row, so steer/queue/steering/
// following-up are gone from the closed set on purpose. Cancelled items are
// dropped outright instead of marked, so cancelled is gone too.
const STORED_META_LITERALS = [
"thinking",
"steer",
"queue",
"steering",
"following-up",
"reinject",
"cancelled",
"not-delivered",
"delivery-uncertain",
];
Expand Down
68 changes: 62 additions & 6 deletions src/tui/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
setShellExitHandler,
setEffortCycleHandler,
clearShellBridgeHooks,
shellInternals,
type AppShell,
} from "./shell/internals.js";
import { leaveSubagentObserve } from "./shell/observe.js";
Expand Down Expand Up @@ -499,6 +500,61 @@ const PROBES: Readonly<
setShellRunState(shell, "idle");
},
},
"Up / Down / Enter / Ctrl+X": {
group: "session",
probe: ({ h, shell, chords }) => {
// Queue items first via the local path — an exclusive onSubmit hook
// owns enqueueing, so installing it earlier would swallow these.
clearShellBridgeHooks(shell);
setShellRunState(shell, "busy");
shell.prompt.value = "a";
submitPrompt(shell, "queue");
shell.prompt.value = "b";
submitPrompt(shell, "queue");
expect(shell.pendingQueue).toBe(2);
const pushed: string[] = [];
setShellBridgeHooks(shell, {
onSubmit: () => undefined,
onInterrupt: () => undefined,
onForceDeliver: (id) => {
pushed.push(id);
shell.session = {
...shell.session,
items: shell.session.items.filter((i) => i.id !== id),
};
},
exclusive: true,
});

// ↑ at the buffer's top edge selects the newest held item; ↑ walks up.
press(h, chords[0]);
expect(shellInternals(shell)?.pendingSelId).toBe(
shell.session.items[1]?.id,
);
press(h, chords[0]);
expect(shellInternals(shell)?.pendingSelId).toBe(
shell.session.items[0]?.id,
);
// ↓ past the last row hands the key back to the prompt.
press(h, chords[1]);
press(h, chords[1]);
expect(shellInternals(shell)?.pendingSelId).toBeNull();
// ^X drops the selected item outright.
press(h, chords[0]);
press(h, chords[3]);
expect(shell.session.items.map((i) => i.text)).toEqual(["a"]);
// Enter force-pushes the selected item through the bridge.
press(h, chords[0]);
press(h, chords[2]);
expect(pushed).toHaveLength(1);
expect(shell.pendingQueue).toBe(0);

// Shared-shell convention: leave the queue and hooks as found.
shell.session = { ...shell.session, items: [] };
clearShellBridgeHooks(shell);
setShellRunState(shell, "idle");
},
},
"Ctrl+C": {
group: "session",
probe: ({ h, shell, chords }) => {
Expand Down Expand Up @@ -569,18 +625,18 @@ const PROBES: Readonly<

expect(shell.pendingQueue).toBe(1);
expect(defined(shell.session.items[0]).text).toBe("keep");
const rows = shell.streamLog.map((row) => row.meta);
// The retracted message's row is rewritten, not left claiming "queue"
// as though it will still dispatch (the bug that got the first attempt
// at this pulled).
expect(rows).toEqual(["queue", "cancelled"]);
// Queued items live in the pending column, not the transcript — the
// retracted one comes back into the prompt as an editable draft.
expect(shell.streamLog).toHaveLength(0);
expect(shell.prompt.value).toBe("drop me");

// The chord's whole job is what lands on screen, not the model alone —
// assert on the rendered frame, not just streamLog.
await h.renderOnce();
const frame = h.captureCharFrame();
expect(frame).toContain("[cancelled] drop me");
expect(frame).toContain("drop me");
expect(frame).toContain("keep");
expect(frame).not.toContain("cancelled");

setShellRunState(shell, "idle");
},
Expand Down
9 changes: 7 additions & 2 deletions src/tui/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,18 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
{
keys: "Enter",
description:
"soft-steer at the next tool boundary while busy (badge); send straight through when idle",
"soft-steer at the next tool boundary while busy (held above the prompt); send straight through when idle",
},
{
keys: "Alt+Enter",
description:
"queue a follow-up delivered only when the run goes idle; does nothing unless a run is busy",
},
{
keys: "Up / Down / Enter / Ctrl+X",
description:
"on held items above the prompt: select, send now, drop (Esc backs out)",
},
{
keys: "Ctrl+C",
description:
Expand All @@ -38,7 +43,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
{
keys: "Ctrl+G",
description:
"cancel the most recently queued or steered message before it dispatches",
"pop the most recently queued or steered message back into the prompt for editing",
},
{
keys: "Alt+C",
Expand Down
Loading
Loading