Skip to content

Commit bf79cc0

Browse files
List queued steers and follow-ups in a pending column above the prompt (#930)
* List queued steers and follow-ups in a pending column above the prompt Queued input echoed into the transcript twice — [will steer next] at enqueue, [steering]/[following up] at delivery — so a held message read louder than a sent one. A transient column above the prompt keeps the same facts on screen without spending transcript rows; delivery is what earns the row. # Conflicts: # docs/PRODUCT.md # docs/TUI.md # src/tui/gutter-labels.test.ts # src/tui/runtime-bridge.ts # src/tui/shell/chrome.ts # src/tui/stream.ts * Clear pending selection on paste; pop selected row on Ctrl+G A paste is composer input like any key, so it ends the column selection instead of editing under it; Ctrl+G returns the row the operator pointed at, and the no-runtime force-push fallback shares the same pop-to-prompt contract instead of dropping silently. The overlay key paragraph now says which keys end the selection.
1 parent aba6225 commit bf79cc0

24 files changed

Lines changed: 1197 additions & 214 deletions

‎docs/PRODUCT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The evidence is in how the product fails today: the personas already produce exc
4141
5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption.
4242
6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why.
4343
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.
44-
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).
44+
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).
4545
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.
4646

4747
## User Experience

‎docs/TUI.md‎

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

653+
Held items never touch the transcript. Both kinds list in the **pending
654+
column** — a transient zone stacked directly on the prompt box, one row per
655+
item (`› steer …` / `› follow-up …`, `▸` on the selected row), oldest items
656+
folding into a leading `+N more` past four shown rows so the newest items —
657+
nearest the prompt, first selected — stay visible, and a guidance row naming
658+
its keys (`pending-column.ts`, painted by `syncPendingRows` in `chrome.ts`).
659+
The transcript only ever sees the item that actually delivers, as an ordinary
660+
user row — pending/delivery labels (`[will steer next]`, `[steering]`,
661+
`[following up]`) are gone on purpose.
662+
663+
While the column has items, `↑` at the prompt buffer's top edge selects the
664+
newest held item and `↑`/`↓` walk the rows; `↓` past the last row hands the
665+
key back to the prompt. On a selected row, **Enter** kills the item out of the
666+
queue and force-pushes it — `onForceDeliver` drops it on the same
667+
`port.deliver` hop a drain uses, so a steer still injects when the parent
668+
cycle is live and otherwise sends immediately. **Ctrl+X** drops the selected
669+
item outright. **Ctrl+G** pops the selected item back into an empty prompt
670+
for editing (dropping it mid-compose); with no selection it pops the newest
671+
held item instead. **Esc** ends the selection; any other composer key ends it
672+
and falls through to normal handling, except `↑`/`↓`, which stay with the
673+
column. While an overlay is open, keys go to the overlay and leave the
674+
selection alone.
675+
652676
When `steer > 0` and a parent tool has been in flight ≥ `STEER_WAIT_NOTICE_MS`
653677
(3s), the notice row adds `waiting on <tool>` (e.g. `waiting on run_shell`).
654678
Follow-up-only does not; a sub-threshold in-flight tool does not. Delivery is
@@ -691,7 +715,9 @@ session exit still call `subAgentSessions.cancelAll` for an explicit
691715
session-wide cancel; that path is separate from interrupt and must stay off
692716
the soft-steer / follow-up gestures.
693717

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

803-
`Ctrl+G` (the Emacs/readline "abort" chord) cancels the most recently queued
804-
mid-run message. `Tab` toggles focus between the prompt and the transcript.
829+
`Ctrl+G` (the Emacs/readline "abort" chord) pops the most recently queued
830+
mid-run message back into an empty prompt for editing, or drops it mid-compose.
831+
`Tab` toggles focus between the prompt and the transcript.
805832
`Shift+Tab` cycles reasoning effort for the current model (wrapping the
806833
supported ladder) and flashes the new level; the prompt-border effort
807834
segment updates immediately. A model with no effort levels flashes instead

‎src/tui/geometry.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ describe("zone registry", () => {
3131
"progress",
3232
"progress_divider",
3333
"notice",
34+
"pending",
3435
"prompt",
3536
"task",
3637
"agents",

‎src/tui/geometry/resolve.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export interface OverlayInput {
4343
export interface ZoneVisibility {
4444
/** Transient notice row on (default off). */
4545
readonly notice?: boolean;
46+
/** Pending queue column: exact row count requested (bounded by the zone max). */
47+
readonly pending?: boolean | number;
4648
/** Progress: false/omit = 0; true = 2; or explicit 1|2. */
4749
readonly progress?: boolean | 1 | 2;
4850
/** Progress divider (0–1). Default on when progress is shown. */
@@ -154,6 +156,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights {
154156
progress: clamp(progressRows, 0, ZONE_REGISTRY.progress.max),
155157
progress_divider: progressDivider,
156158
notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault,
159+
pending: clamp(boolOrRows(vis.pending, 1), 0, ZONE_REGISTRY.pending.max),
157160
prompt: promptRows,
158161
task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max),
159162
// The board asks for exactly the rows it will paint; the fraction is what

‎src/tui/geometry/zones.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const ZONE_IDS = [
77
"progress",
88
"progress_divider",
99
"notice",
10+
"pending",
1011
"prompt",
1112
"task",
1213
"agents",
@@ -69,6 +70,13 @@ export const FLEET_FLOOR_MIN_LANES = 2;
6970
*/
7071
export const TASKS_PANEL_MAX_VISIBLE = 5;
7172

73+
/**
74+
* Queued steer/follow-up rows the pending column lists before folding into a
75+
* trailing "+N more" row. The column is a glance at what will send, not a
76+
* full editor for the queue — a deep stack is rarer than the room it costs.
77+
*/
78+
export const PENDING_MAX_VISIBLE = 4;
79+
7280
/**
7381
* Fixed-with-test budgets from the constitution table.
7482
* Residual zones (transcript, overlay_host) use min/max as floor/cap hints;
@@ -86,6 +94,16 @@ export const ZONE_REGISTRY: Readonly<Record<ZoneId, ZoneDeclaration>> = {
8694
// Transient: rows only while the shell has state worth a row (queue depth,
8795
// latched interrupt, a flash, a live turn). Idle it is off.
8896
notice: { id: "notice", min: 0, max: 1, idleDefault: 0, alwaysOn: false },
97+
// Queued steer/follow-up messages stacked directly on the prompt box —
98+
// one row per shown item, a leading "+N more" fold plus a key-guidance
99+
// row, bounded by the zone max.
100+
pending: {
101+
id: "pending",
102+
min: 0,
103+
max: PENDING_MAX_VISIBLE + 2,
104+
idleDefault: 0,
105+
alwaysOn: false,
106+
},
89107
// Grows with what is being composed; the resolver caps it at PROMPT_CAP_FRACTION
90108
// and collapses it back toward min when the transcript would breach its floor.
91109
prompt: {
@@ -202,6 +220,9 @@ export const COLLAPSE_ORDER = [
202220
"progress",
203221
"progress_divider",
204222
"notice",
223+
// Pending items are the operator's own queued words: cut last of the
224+
// optionals, just ahead of prompt growth reclaim.
225+
"pending",
205226
// prompt growth reclaimed next (handled specially; never below PROMPT_BASE_ROWS)
206227
"prompt",
207228
] as const satisfies readonly ZoneId[];
@@ -222,6 +243,7 @@ export const PAINT_ORDER = [
222243
"progress",
223244
"progress_divider",
224245
"notice",
246+
"pending",
225247
"prompt",
226248
] as const satisfies readonly ZoneId[];
227249

‎src/tui/gutter-labels.test.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,13 @@ const OVERLAY_KIND_GUTTER = {
3535

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

38+
// Queued items no longer store a meta — pending state lives in the column
39+
// and delivery paints a plain operator row, so steer/queue/steering/
40+
// following-up are gone from the closed set on purpose. Cancelled items are
41+
// dropped outright instead of marked, so cancelled is gone too.
3842
const STORED_META_LITERALS = [
3943
"thinking",
40-
"steer",
41-
"queue",
42-
"steering",
43-
"following-up",
4444
"reinject",
45-
"cancelled",
4645
"not-delivered",
4746
"delivery-uncertain",
4847
];

‎src/tui/keybindings.test.ts‎

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
setShellExitHandler,
4242
setEffortCycleHandler,
4343
clearShellBridgeHooks,
44+
shellInternals,
4445
type AppShell,
4546
} from "./shell/internals.js";
4647
import { leaveSubagentObserve } from "./shell/observe.js";
@@ -499,6 +500,61 @@ const PROBES: Readonly<
499500
setShellRunState(shell, "idle");
500501
},
501502
},
503+
"Up / Down / Enter / Ctrl+X": {
504+
group: "session",
505+
probe: ({ h, shell, chords }) => {
506+
// Queue items first via the local path — an exclusive onSubmit hook
507+
// owns enqueueing, so installing it earlier would swallow these.
508+
clearShellBridgeHooks(shell);
509+
setShellRunState(shell, "busy");
510+
shell.prompt.value = "a";
511+
submitPrompt(shell, "queue");
512+
shell.prompt.value = "b";
513+
submitPrompt(shell, "queue");
514+
expect(shell.pendingQueue).toBe(2);
515+
const pushed: string[] = [];
516+
setShellBridgeHooks(shell, {
517+
onSubmit: () => undefined,
518+
onInterrupt: () => undefined,
519+
onForceDeliver: (id) => {
520+
pushed.push(id);
521+
shell.session = {
522+
...shell.session,
523+
items: shell.session.items.filter((i) => i.id !== id),
524+
};
525+
},
526+
exclusive: true,
527+
});
528+
529+
// ↑ at the buffer's top edge selects the newest held item; ↑ walks up.
530+
press(h, chords[0]);
531+
expect(shellInternals(shell)?.pendingSelId).toBe(
532+
shell.session.items[1]?.id,
533+
);
534+
press(h, chords[0]);
535+
expect(shellInternals(shell)?.pendingSelId).toBe(
536+
shell.session.items[0]?.id,
537+
);
538+
// ↓ past the last row hands the key back to the prompt.
539+
press(h, chords[1]);
540+
press(h, chords[1]);
541+
expect(shellInternals(shell)?.pendingSelId).toBeNull();
542+
// ^X drops the selected item outright.
543+
press(h, chords[0]);
544+
press(h, chords[3]);
545+
expect(shell.session.items.map((i) => i.text)).toEqual(["a"]);
546+
// Enter force-pushes the selected item through the bridge.
547+
press(h, chords[0]);
548+
press(h, chords[2]);
549+
expect(pushed).toHaveLength(1);
550+
expect(shell.pendingQueue).toBe(0);
551+
552+
// Shared-shell convention: leave the queue and hooks as found.
553+
shell.session = { ...shell.session, items: [] };
554+
clearShellBridgeHooks(shell);
555+
setShellRunState(shell, "idle");
556+
},
557+
},
502558
"Ctrl+C": {
503559
group: "session",
504560
probe: ({ h, shell, chords }) => {
@@ -569,18 +625,18 @@ const PROBES: Readonly<
569625

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

578633
// The chord's whole job is what lands on screen, not the model alone —
579634
// assert on the rendered frame, not just streamLog.
580635
await h.renderOnce();
581636
const frame = h.captureCharFrame();
582-
expect(frame).toContain("[cancelled] drop me");
637+
expect(frame).toContain("drop me");
583638
expect(frame).toContain("keep");
639+
expect(frame).not.toContain("cancelled");
584640

585641
setShellRunState(shell, "idle");
586642
},

‎src/tui/keybindings.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,18 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
2323
{
2424
keys: "Enter",
2525
description:
26-
"soft-steer at the next tool boundary while busy (badge); send straight through when idle",
26+
"soft-steer at the next tool boundary while busy (held above the prompt); send straight through when idle",
2727
},
2828
{
2929
keys: "Alt+Enter",
3030
description:
3131
"queue a follow-up delivered only when the run goes idle; does nothing unless a run is busy",
3232
},
33+
{
34+
keys: "Up / Down / Enter / Ctrl+X",
35+
description:
36+
"on held items above the prompt: select, send now, drop (Esc backs out)",
37+
},
3338
{
3439
keys: "Ctrl+C",
3540
description:
@@ -38,7 +43,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
3843
{
3944
keys: "Ctrl+G",
4045
description:
41-
"cancel the most recently queued or steered message before it dispatches",
46+
"pop the most recently queued or steered message back into the prompt for editing",
4247
},
4348
{
4449
keys: "Alt+C",

0 commit comments

Comments
 (0)