Skip to content

Commit c3ec48f

Browse files
committed
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.
1 parent 91fe7d4 commit c3ec48f

23 files changed

Lines changed: 1014 additions & 236 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) or via stdin when the product agent runs under `corbits exec`.
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. 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. 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. 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 still collect with `wait_agents`. 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: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -567,17 +567,33 @@ Two mid-run gestures, two delivery times (CL-6290):
567567
`Agent.deliver` into the live reactor, not a new `send`. A
568568
long parent `run_shell` or an awaiting `wait_agents` is parent-busy and holds
569569
steers. An in-flight TUI-primary `wait_agents` yields as a timeout when a
570-
steer is queued so occupancy can deliver it. The transcript row says
571-
`[will steer next]` while pending and
572-
`[steering]` once delivered (`submitPrompt`, `drainSteersAtBoundary` in
573-
`runtime-bridge.ts`).
570+
steer is queued so occupancy can deliver it.
574571
- **Alt+Enter, mid-run** — follow-up: enqueues kind `"queue"` and delivers
575572
only on **session-idle** (parent-idle and no live fleet lanes) as a `send`.
576-
Does not interrupt or reinject. The transcript row says `[will follow up]`
577-
while pending and `[following up]` once delivered. Idle, or with an empty
573+
Does not interrupt or reinject. Idle, or with an empty
578574
prompt, Alt+Enter does nothing — there is nothing to wait for. (Internal
579575
`"reinject"` remains in the submit API for tests; no product chord wires it.)
580576

577+
Held items never touch the transcript. Both kinds list in the **pending
578+
column** — a transient zone stacked directly on the prompt box, one row per
579+
item (`› steer …` / `› follow-up …`, `` on the selected row), oldest items
580+
folding into a leading `+N more` past four shown rows so the newest items —
581+
nearest the prompt, first selected — stay visible, and a guidance row naming
582+
its keys (`pending-column.ts`, painted by `syncPendingRows` in `chrome.ts`).
583+
The transcript only ever sees the item that actually delivers, as an ordinary
584+
user row — pending/delivery labels (`[will steer next]`, `[steering]`,
585+
`[following up]`) are gone on purpose.
586+
587+
While the column has items, `` at the prompt buffer's top edge selects the
588+
newest held item and ``/`` walk the rows; `` past the last row hands the
589+
key back to the prompt. On a selected row, **Enter** kills the item out of the
590+
queue and force-pushes it — `onForceDeliver` drops it on the same
591+
`port.deliver` hop a drain uses, so a steer still injects when the parent
592+
cycle is live and otherwise sends immediately. **Ctrl+X** drops the selected
593+
item outright. Esc or any other key ends the selection and falls through to
594+
normal handling. `Ctrl+G` stays the pop-to-edit chord: it returns the newest
595+
held item to an empty prompt for editing (dropping it mid-compose).
596+
581597
When `steer > 0` and a parent tool has been in flight ≥ `STEER_WAIT_NOTICE_MS`
582598
(3s), the notice row adds `waiting on <tool>` (e.g. `waiting on run_shell`).
583599
Follow-up-only does not; a sub-threshold in-flight tool does not. Delivery is
@@ -618,7 +634,9 @@ session exit still call `subAgentSessions.cancelAll` for an explicit
618634
session-wide cancel; that path is separate from interrupt and must stay off
619635
the soft-steer / follow-up gestures.
620636

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

728-
`Ctrl+G` (the Emacs/readline "abort" chord) cancels the most recently queued
729-
mid-run message. `Tab` toggles focus between the prompt and the transcript.
746+
`Ctrl+G` (the Emacs/readline "abort" chord) pops the most recently queued
747+
mid-run message back into an empty prompt for editing, or drops it mid-compose.
748+
`Tab` toggles focus between the prompt and the transcript.
730749
`Shift+Tab` cycles reasoning effort for the current model (wrapping the
731750
supported ladder) and flashes the new level; the prompt-border effort
732751
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: {
@@ -200,6 +218,9 @@ export const COLLAPSE_ORDER = [
200218
"progress",
201219
"progress_divider",
202220
"notice",
221+
// Pending items are the operator's own queued words: cut last of the
222+
// optionals, just ahead of prompt growth reclaim.
223+
"pending",
203224
// prompt growth reclaimed next (handled specially; never below PROMPT_BASE_ROWS)
204225
"prompt",
205226
] as const satisfies readonly ZoneId[];
@@ -220,6 +241,7 @@ export const PAINT_ORDER = [
220241
"progress",
221242
"progress_divider",
222243
"notice",
244+
"pending",
223245
"prompt",
224246
] as const satisfies readonly ZoneId[];
225247

src/tui/gutter-labels.test.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,10 @@ const OVERLAY_KIND_GUTTER = {
3535

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

38-
const STORED_META_LITERALS = [
39-
"thinking",
40-
"steer",
41-
"queue",
42-
"steering",
43-
"following-up",
44-
"reinject",
45-
"cancelled",
46-
];
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/cancelled are gone from the closed set on purpose.
41+
const STORED_META_LITERALS = ["thinking", "reinject"];
4742

4843
const FORBIDDEN = ["permission", "command", "overlay"];
4944

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",

src/tui/notice-line.test.ts

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import {
77
} from "./notice-line";
88

99
const state = (over: Partial<NoticeState> = {}): NoticeState => ({
10-
steer: 0,
11-
followUp: 0,
1210
waitingOn: null,
1311
interrupt: false,
1412
pinned: false,
@@ -23,51 +21,37 @@ describe("composeNoticeLine", () => {
2321
});
2422

2523
test("default state segments stay off the row", () => {
26-
const line = composeNoticeLine(
27-
state({ steer: 0, followUp: 0, pinned: false }),
28-
);
24+
const line = composeNoticeLine(state({ pinned: false }));
2925
expect(line).not.toContain("steer");
3026
expect(line).not.toContain("follow-up");
3127
expect(line).not.toContain("queue");
3228
expect(line).not.toContain("pinned");
3329
});
3430

35-
test("steer and follow-up are distinct segments", () => {
31+
test("pending counts are not segments — the column lists the items", () => {
3632
const line = composeNoticeLine(
37-
state({
38-
steer: 2,
39-
followUp: 1,
40-
pinned: true,
41-
interrupt: true,
42-
attachments: 1,
43-
}),
33+
state({ pinned: true, interrupt: true, attachments: 1 }),
4434
);
45-
expect(line).toContain("steer 2");
46-
expect(line).toContain("follow-up 1");
47-
expect(line).not.toContain("queue 2");
4835
expect(line).toContain("pinned");
4936
expect(line).not.toContain("interrupt");
5037
expect(line).toContain("1 image");
38+
expect(line).not.toContain("steer");
39+
expect(line).not.toContain("follow-up");
5140
});
5241

53-
test("waitingOn + steer names the in-flight command", () => {
54-
const line = composeNoticeLine(state({ steer: 1, waitingOn: "run_shell" }));
42+
test("waitingOn names the in-flight command", () => {
43+
const line = composeNoticeLine(state({ waitingOn: "run_shell" }));
5544
expect(line).toContain("waiting on run_shell");
5645
});
5746

58-
test("follow-up only does not wait on a tool", () => {
59-
const line = composeNoticeLine(state({ followUp: 1, waitingOn: null }));
60-
expect(line).not.toContain("waiting on");
61-
});
62-
6347
test("a flash is carried verbatim so paths keep their case", () => {
6448
expect(composeNoticeLine(state({ flash: "attached Screenshot.png" }))).toBe(
6549
"attached Screenshot.png",
6650
);
6751
});
6852

6953
test("no keys strip survives anywhere in the composition", () => {
70-
const line = composeNoticeLine(state({ followUp: 1, interrupt: true }));
54+
const line = composeNoticeLine(state({ attachments: 1, interrupt: true }));
7155
expect(line).not.toContain("commands");
7256
expect(line).not.toContain("files");
7357
expect(line).not.toContain("^C");

0 commit comments

Comments
 (0)