Skip to content

Commit 86f5db5

Browse files
committed
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 daf8321 commit 86f5db5

6 files changed

Lines changed: 132 additions & 28 deletions

File tree

docs/TUI.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -666,9 +666,12 @@ key back to the prompt. On a selected row, **Enter** kills the item out of the
666666
queue and force-pushes it — `onForceDeliver` drops it on the same
667667
`port.deliver` hop a drain uses, so a steer still injects when the parent
668668
cycle is live and otherwise sends immediately. **Ctrl+X** drops the selected
669-
item outright. Esc or any other key ends the selection and falls through to
670-
normal handling. `Ctrl+G` stays the pop-to-edit chord: it returns the newest
671-
held item to an empty prompt for editing (dropping it mid-compose).
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.
672675

673676
When `steer > 0` and a parent tool has been in flight ≥ `STEER_WAIT_NOTICE_MS`
674677
(3s), the notice row adds `waiting on <tool>` (e.g. `waiting on run_shell`).

src/tui/queued-delivery-hop.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,14 @@ describe("closed-target recovery", () => {
491491
expect(calls).toHaveLength(1);
492492

493493
expect(shell.prompt.value).toBe("");
494-
expect(drainedUserRow(shell).meta).toBe("steering");
494+
// The pending column carried the item until delivery, so the
495+
// transcript row is a plain operator message — no [steering]
496+
// label on purpose.
497+
const row = drainedUserRow(shell);
498+
expect(row.meta).toBeUndefined();
499+
expect(shell.streamLog.map((r) => r.text).join("\n")).toContain(
500+
"steer the ship",
501+
);
495502
expect(recoveryNotices(shell)).toEqual([]);
496503
} finally {
497504
bridge.dispose();

src/tui/runtime-bridge.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1717,7 +1717,6 @@ export function attachSessionBridge(
17171717
kind === "steer"
17181718
? enqueueSteer(shell.session, t, undefined, attachments)
17191719
: enqueue(shell.session, t, "queue", undefined, attachments);
1720-
const queued = shell.session.items[shell.session.items.length - 1];
17211720
bag.port.enqueue(t, kind);
17221721
if (kind === "steer") bag.waitYieldWake?.();
17231722
// No transcript echo while pending: the item lists in the column stacked

src/tui/shell.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,62 @@ describe("createAppShell", () => {
458458
{ width: 80, height: 24 },
459459
);
460460
});
461+
462+
test("Ctrl+G pops the selected row, not the newest", async () => {
463+
await withTestRenderer(
464+
async (h) => {
465+
const shell = createAppShell(h.renderer, {
466+
terminal: { columns: 80, rows: 24 },
467+
wireKeys: true,
468+
});
469+
try {
470+
setPendingQueue(shell, 2);
471+
await h.renderOnce();
472+
// ↑ selects the newest; ↑ again walks up to the older row.
473+
h.mockInput.pressKey("\x1b[A");
474+
h.mockInput.pressKey("\x1b[A");
475+
await h.renderOnce();
476+
expect(shellInternals(shell)?.pendingSelId).toBe(
477+
shell.session.items[0]?.id,
478+
);
479+
h.pressKey("g", { ctrl: true });
480+
await h.renderOnce();
481+
expect(shell.session.items.map((i) => i.text)).toEqual(["pad-2"]);
482+
expect(shell.prompt.value).toBe("pad-1");
483+
expect(shellInternals(shell)?.pendingSelId).toBeNull();
484+
} finally {
485+
shell.dispose();
486+
}
487+
},
488+
{ width: 80, height: 24 },
489+
);
490+
});
491+
492+
test("a prompt paste ends the selection instead of editing under it", async () => {
493+
await withTestRenderer(
494+
async (h) => {
495+
const shell = createAppShell(h.renderer, {
496+
terminal: { columns: 80, rows: 24 },
497+
wireKeys: true,
498+
});
499+
try {
500+
setPendingQueue(shell, 1);
501+
await h.renderOnce();
502+
h.mockInput.pressKey("\x1b[A");
503+
await h.renderOnce();
504+
expect(shellInternals(shell)?.pendingSelId).not.toBeNull();
505+
await h.mockInput.pasteBracketedText("pasted");
506+
await h.renderOnce();
507+
expect(shellInternals(shell)?.pendingSelId).toBeNull();
508+
expect(shell.prompt.value).toBe("pasted");
509+
expect(shell.session.items).toHaveLength(1);
510+
} finally {
511+
shell.dispose();
512+
}
513+
},
514+
{ width: 80, height: 24 },
515+
);
516+
});
461517
});
462518

463519
describe("product skin: stream + queue + overlay", () => {

src/tui/shell/keys.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
toggleTasksPanel,
3737
} from "./chrome.js";
3838
import {
39+
applyPendingCancelSelected,
3940
applyPendingDrop,
4041
applyPendingForcePush,
4142
applyPendingNav,
@@ -261,10 +262,18 @@ export function createShellKeyHandlers(
261262
const bag = shellInternals(shell);
262263
if (bag?.inputSuspended === true) return;
263264
sawBracketedPaste = true;
264-
if (shell.overlayList !== null && bag?.primaryBindings.onPaste) {
265-
event.preventDefault();
266-
bag.primaryBindings.onPaste(new TextDecoder().decode(event.bytes));
265+
if (shell.overlayList !== null) {
266+
if (bag?.primaryBindings.onPaste) {
267+
event.preventDefault();
268+
bag.primaryBindings.onPaste(new TextDecoder().decode(event.bytes));
269+
}
270+
return;
267271
}
272+
// A paste into the prompt is composer input like any other key: it ends
273+
// a pending-column selection instead of editing under it. The prompt
274+
// textarea still consumes the event itself, so this only drops the
275+
// selection and falls through.
276+
clearPendingSelection(shell);
268277
};
269278

270279
const onKey = (key: KeyEvent): void => {
@@ -493,9 +502,10 @@ export function createShellKeyHandlers(
493502
}
494503

495504
// A pending-column selection owns Enter (kill the held item and send it
496-
// now) and ^X (drop it) outright; every other key just ends the selection
497-
// and falls through to its normal handling. ↑/↓ are exempt — they stay
498-
// with the column and are claimed by the nav block below.
505+
// now), ^X (drop it) and ^G (pop it back for editing) outright; every
506+
// other key just ends the selection and falls through to its normal
507+
// handling. ↑/↓ are exempt — they stay with the column and are claimed
508+
// by the nav block below.
499509
if (pendingSelectionActive(shell)) {
500510
if (
501511
(keyName === "return" || keyName === "kpenter") &&
@@ -512,6 +522,11 @@ export function createShellKeyHandlers(
512522
applyPendingDrop(shell);
513523
return;
514524
}
525+
if (key.ctrl && !key.meta && !key.option && keyName === "g") {
526+
key.preventDefault();
527+
applyPendingCancelSelected(shell);
528+
return;
529+
}
515530
if (keyName !== "up" && keyName !== "down") {
516531
clearPendingSelection(shell);
517532
}

src/tui/shell/prompt.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -441,11 +441,39 @@ export function applyPendingNav(shell: AppShell, delta: -1 | 1): boolean {
441441
return true;
442442
}
443443

444+
/**
445+
* No-runtime path for leaving the queue through the selected row: kill the
446+
* selected item out of the queue. Same contract as applyShellCancelLast —
447+
* an empty prompt gets the item back for editing; mid-draft it's dropped
448+
* rather than merged, since merging would send two messages as one.
449+
*/
450+
function popSelectedToPrompt(shell: AppShell): void {
451+
const bag = shellInternals(shell);
452+
const sel = pendingSelIndex(shell);
453+
if (bag === undefined || sel < 0) return;
454+
const id = shell.session.items[sel]?.id;
455+
if (id === undefined) return;
456+
const { state, item: popped } = cancelItem(shell.session, id);
457+
if (popped === null) return;
458+
shell.session = state;
459+
bag.pendingSelId = null;
460+
if (shell.prompt.value.length === 0) {
461+
shell.prompt.value = popped.text;
462+
shell.prompt.cursorOffset = popped.text.length;
463+
shell.pendingAttachments = [
464+
...shell.pendingAttachments,
465+
...(popped.attachments ?? []),
466+
];
467+
}
468+
paintChrome(shell);
469+
}
470+
444471
/**
445472
* Enter on a selected pending item: kill it out of the queue and force-push —
446473
* deliver it now through the runtime, skipping its boundary/idle wait. With
447-
* no runtime attached there is nothing to deliver to, so it pops back into
448-
* the prompt for editing instead.
474+
* no runtime attached there is nothing to deliver to, so it falls back to
475+
* the cancel contract: back into an empty prompt for editing, dropped
476+
* mid-draft rather than merged.
449477
*/
450478
export function applyPendingForcePush(shell: AppShell): void {
451479
const bag = shellInternals(shell);
@@ -461,21 +489,17 @@ export function applyPendingForcePush(shell: AppShell): void {
461489
paintChrome(shell);
462490
return;
463491
}
464-
const { state, item: popped } = cancelItem(shell.session, item.id);
465-
if (popped === null) return;
466-
shell.session = state;
467-
bag.pendingSelId = null;
468-
// Same contract as applyShellCancelLast: an empty prompt gets the item
469-
// back for editing; mid-draft it's dropped rather than merged.
470-
if (shell.prompt.value.length === 0) {
471-
shell.prompt.value = popped.text;
472-
shell.prompt.cursorOffset = popped.text.length;
473-
shell.pendingAttachments = [
474-
...shell.pendingAttachments,
475-
...(popped.attachments ?? []),
476-
];
477-
}
478-
paintChrome(shell);
492+
popSelectedToPrompt(shell);
493+
}
494+
495+
/**
496+
* Ctrl+G on a selected pending item: cancel that row, not the newest — the
497+
* operator pointed at it. Without hooks this is the same pop-to-prompt as
498+
* the force-push fallback; with a runtime attached it still only cancels,
499+
* never delivers.
500+
*/
501+
export function applyPendingCancelSelected(shell: AppShell): void {
502+
popSelectedToPrompt(shell);
479503
}
480504

481505
/**

0 commit comments

Comments
 (0)