From 2ac5d2880cb4d3acef02011663d923817124a760 Mon Sep 17 00:00:00 2001 From: Thomas Mustier <6326440+tmustier@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:34:04 +0100 Subject: [PATCH] Add row removal and lane toggle to the edit session Two new fixed shortcuts while a queue row is selected: - alt+x marks the row for removal; save deletes it (including image-only rows), Escape or a second press restores it - alt+t toggles the row between steering and follow-up as a session draft, previewing at the destination lane tail before save commits it Both verbs live inside the snapshot edit session, so Escape still rolls back the entire session and touched heads stay pinned at delivery boundaries. Row selection now navigates the visual timeline so lane previews and option+up/option+down movement stay aligned. Saves still never change a row's lane implicitly. --- AGENTS.md | 3 +- CHANGELOG.md | 4 ++ README.md | 14 ++-- index.ts | 131 ++++++++++++++++++++++++++--------- queue-state.ts | 59 ++++++++++++++-- test/queue-state.test.ts | 146 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 310 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 62aa613..6fc5889 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,8 @@ - Preserve FIFO order, stable item IDs, image attachments, and failed-dispatch restoration. - Preserve configured Pi keybindings by matching action IDs rather than hard-coded escape sequences. - Compose with previously installed custom editors and retain their input behavior. -- Treat row edits as snapshots: save in place; Escape rolls back the entire editing session. +- Treat row edits as snapshots: save in place; Escape rolls back the entire editing session, including removal marks and lane toggles. +- Row saves never change delivery lanes implicitly; only the explicit lane toggle re-lanes a row, to the destination tail, on save. - Dispatch pauses only when the oldest row has an unsaved edit. Keep tests close to these invariants and visually verify TUI changes in a real Pi session. diff --git a/CHANGELOG.md b/CHANGELOG.md index be39195..ebeffa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Add `Option+X` to mark the selected row for removal — deleted on save, restored by `Escape` or a second press, and finally covering image-only rows. +- Add `Option+T` to re-lane the selected row between steering and follow-up, previewing at its destination tail before the save commits it. +- Navigate row selection through the visual timeline so lane previews and `Option+Up`/`Option+Down` movement stay aligned. + - Show steering and follow-ups as separate lanes in one delivery-ordered timeline. - Group the lanes into stacked blue and yellow boxes with aligned inline editing. - Add a compact looping demo in the original GitHub Dark terminal treatment, starting on a populated screen. diff --git a/README.md b/README.md index 13c069a..0860f29 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,15 @@ The extension follows your configured Pi action bindings. These are the default | Editing a row | `Option+Up` | Keep the current draft and move to the previous visual row | | Editing a row | `Option+Down` | Keep the current draft and move to the next visual row | | Editing a row | Type normally | Edit directly inside the selected row | +| Editing a row | `Option+X` | Mark the selected row for removal; save deletes it, a second press restores it | +| Editing a row | `Option+T` | Move the selected row to the other lane when saved | | Editing a row | `Enter` or `Option+Enter` | Save all row edits without changing their lanes | | Editing a row | `Escape` | Cancel the session and roll back all unsaved row edits | | Empty composer, follow-up queued | `Enter` | Promote the oldest follow-up to steering now | | Queue paused after an abort | `Enter` | Resume from the next steering row, or the next follow-up | | Agent working, queue visible | `Escape` | Abort the run and pause both visible lanes | -`Option+Down` is the only new fixed shortcut. The other controls use Pi’s configured action bindings. Terminals outside macOS may label `Option` as `Alt`. +`Option+Down`, `Option+X` and `Option+T` are the only new fixed shortcuts. The other controls use Pi’s configured action bindings. Terminals outside macOS may label `Option` as `Alt`. ## Delivery semantics @@ -71,12 +73,14 @@ The extension hands messages back to Pi’s native queues only when their delive - `Option+Up` starts at the row you queued most recently - `Option+Up` and `Option+Down` then move through the visible timeline -- editing never changes a row’s position or delivery class +- saving never changes a row’s lane implicitly; `Option+T` re-lanes the selected row explicitly, and it joins the tail of its new lane on save +- a re-laned row previews inside its destination box before the save commits it +- `Option+X` marks the selected row for removal; save deletes it, and `Escape` or a second `Option+X` restores it - a selected row becomes the real editor without a nested composer frame - one editing session can hold drafts for several rows -- `Escape` restores every row from the session snapshot +- `Escape` restores every row from the session snapshot, including removal marks and lane toggles - saving an empty text-only row removes it -- image-only rows remain queued +- image-only rows survive text clearing; `Option+X` removes them - an unrelated composer draft is stashed and restored when editing ends A touched head row is pinned until you save or cancel. In `one-at-a-time` mode, later rows do not block the head. In `all` mode, editing any row holds that whole lane at active-run delivery boundaries. @@ -109,7 +113,7 @@ npm run ci pi -e ./index.ts ``` -The automated suite covers both lanes, queue modes, delivery boundaries, stable edits, rollback, abort recovery, image preservation, failed handoffs, editor-frame extraction and editor composition. Check TUI changes in a real interactive Pi session as well. +The automated suite covers both lanes, queue modes, delivery boundaries, stable edits, rollback, removal marks, lane toggles, abort recovery, image preservation, failed handoffs, editor-frame extraction and editor composition. Check TUI changes in a real interactive Pi session as well. Tested with Pi 0.80.9. diff --git a/index.ts b/index.ts index fb518e6..e9c0bc3 100644 --- a/index.ts +++ b/index.ts @@ -21,6 +21,8 @@ const WIDGET_ID = "queue-steer.timeline"; const EDITOR_FEATURES = Symbol.for("@tmustier/pi-editor-features"); const QUEUE_STEER_FEATURE = "queue-steer"; const NEXT_ROW_KEY = "alt+down"; +const REMOVE_ROW_KEY = "alt+x"; +const TOGGLE_LANE_KEY = "alt+t"; type QueueMode = "all" | "one-at-a-time"; type EditorFactory = NonNullable>; @@ -60,19 +62,24 @@ interface QueueModes { followUp: QueueMode; } +/** A queue row with session drafts applied for display and navigation. */ +interface TimelineItem extends QueuedMessage { + removed: boolean; + movedLane: boolean; + held: boolean; +} + class QueueTimelineWidget implements Component { - private readonly items: QueuedMessage[]; + private readonly items: TimelineItem[]; private readonly editingId: string | undefined; - private readonly touchedIds: ReadonlySet; private readonly renderInlineEditor: InlineEditorRenderer | undefined; private readonly paused: boolean; private readonly modes: QueueModes; private readonly theme: Theme; constructor(options: { - items: QueuedMessage[]; + items: TimelineItem[]; editingId: string | undefined; - touchedIds: ReadonlySet; renderInlineEditor: InlineEditorRenderer | undefined; paused: boolean; modes: QueueModes; @@ -80,7 +87,6 @@ class QueueTimelineWidget implements Component { }) { this.items = options.items; this.editingId = options.editingId; - this.touchedIds = options.touchedIds; this.renderInlineEditor = options.renderInlineEditor; this.paused = options.paused; this.modes = options.modes; @@ -108,15 +114,12 @@ class QueueTimelineWidget implements Component { private renderLaneBox( lines: string[], lane: QueueLane, - items: QueuedMessage[], + items: TimelineItem[], width: number, ): void { const color = laneColor(lane); const border = (text: string) => this.theme.fg(color, text); - const laneTouched = items.some((item) => this.touchedIds.has(item.id)); - const laneHeld = this.modes[lane] === "all" - ? laneTouched - : !!items[0] && this.touchedIds.has(items[0].id); + const laneHeld = items.some((item) => item.held); const stage = lane === "steer" ? "next turn" : "after this run"; const state = this.paused ? "paused" : laneHeld ? "held while editing" : stage; const name = lane === "steer" ? "steering queue" : "follow-ups"; @@ -136,7 +139,7 @@ class QueueTimelineWidget implements Component { const selectedHere = items.some((item) => item.id === this.editingId); const help = this.editingId ? selectedHere - ? `${dequeue}/${nextRowKeyText()} move · ${submit}/${followUp} save · ${interrupt} cancel` + ? `${dequeue}/${nextRowKeyText()} move · ${REMOVE_ROW_KEY} remove · ${TOGGLE_LANE_KEY} lane · ${submit} save · ${interrupt} cancel` : `${dequeue}/${nextRowKeyText()} move here · ${interrupt} cancel` : this.paused ? `${submit} resume · ${dequeue} edit · ${interrupt} keep paused` @@ -149,20 +152,24 @@ class QueueTimelineWidget implements Component { private renderItem( lines: string[], - item: QueuedMessage, - laneItems: QueuedMessage[], + item: TimelineItem, + laneItems: TimelineItem[], cellWidth: number, border: (text: string) => string, ): void { const selected = item.id === this.editingId; const head = laneItems[0]?.id === item.id; - const laneTouched = laneItems.some((candidate) => this.touchedIds.has(candidate.id)); - const held = this.modes[item.lane] === "all" ? laneTouched : head && this.touchedIds.has(item.id); const armed = this.modes[item.lane] === "all" || head; const color = laneColor(item.lane); if (!selected) { - const marker = held || (this.paused && armed) + if (item.removed) { + const prefix = this.theme.fg("error", "✕ "); + const body = this.theme.fg("dim", `${compactText(item)} · removed on save`); + lines.push(`${border("│")} ${fitCell(`${prefix}${body}`, cellWidth)} ${border("│")}`); + return; + } + const marker = item.held || (this.paused && armed) ? "⏸" : item.lane === "followUp" ? "○" @@ -170,8 +177,9 @@ class QueueTimelineWidget implements Component { ? "▶" : "»"; const prefix = this.theme.fg(color, `${marker} `); + const moved = item.movedLane ? this.theme.fg("dim", " · moves here on save") : ""; const body = this.theme.fg("muted", compactText(item)); - lines.push(`${border("│")} ${fitCell(`${prefix}${body}`, cellWidth)} ${border("│")}`); + lines.push(`${border("│")} ${fitCell(`${prefix}${body}${moved}`, cellWidth)} ${border("│")}`); return; } @@ -183,9 +191,14 @@ class QueueTimelineWidget implements Component { const prefix = index === 0 ? this.theme.fg(color, prefixText) : " ".repeat(prefixWidth); lines.push(`${border("│")} ${fitCell(`${prefix}${editorLine}`, cellWidth)} ${border("│")}`); } + const notes: string[] = []; + if (item.removed) notes.push(`removed on save · ${REMOVE_ROW_KEY} undoes`); + else if (item.movedLane) notes.push(`moves here on save · ${TOGGLE_LANE_KEY} undoes`); if (item.images.length > 0) { - const imageNote = `${item.images.length} image${item.images.length === 1 ? "" : "s"} preserved`; - lines.push(`${border("│")} ${fitCell(this.theme.fg("dim", `${" ".repeat(prefixWidth)}↳ ${imageNote}`), cellWidth)} ${border("│")}`); + notes.push(`${item.images.length} image${item.images.length === 1 ? "" : "s"} preserved`); + } + for (const note of notes) { + lines.push(`${border("│")} ${fitCell(this.theme.fg("dim", `${" ".repeat(prefixWidth)}↳ ${note}`), cellWidth)} ${border("│")}`); } } @@ -220,6 +233,44 @@ export default function queueSteerExtension(pi: ExtensionAPI) { return !!head && editSession.touches(head.id); }; + /** + * Queue rows with session drafts applied, in visual timeline order. + * + * Rows keep their FIFO position; rows re-laned in the current session + * preview at their destination lane's tail, matching where commit puts + * them. Held flags follow dispatch truth: they reflect each row's + * *committed* lane, so an uncommitted lane draft never changes delivery. + */ + const timelineItems = (): TimelineItem[] => { + const modes = queueModes(); + const heldLane: Record = { + steer: laneIsHeld("steer"), + followUp: laneIsHeld("followUp"), + }; + const heads: Record = { + steer: queue.peek("steer")?.id, + followUp: queue.peek("followUp")?.id, + }; + const decorated = queue.snapshot().map((item): TimelineItem => { + const lane = editSession?.laneFor(item.id) ?? item.lane; + return { + ...item, + text: editSession?.textFor(item.id) ?? item.text, + images: editSession?.imagesFor(item.id) ?? item.images, + lane, + removed: editSession?.isRemoved(item.id) ?? false, + movedLane: lane !== item.lane, + held: heldLane[item.lane] && (modes[item.lane] === "all" || heads[item.lane] === item.id), + }; + }); + return [ + ...decorated.filter((item) => item.lane === "steer" && !item.movedLane), + ...decorated.filter((item) => item.lane === "steer" && item.movedLane), + ...decorated.filter((item) => item.lane === "followUp" && !item.movedLane), + ...decorated.filter((item) => item.lane === "followUp" && item.movedLane), + ]; + }; + const renderQueue = (ctx: ExtensionContext): void => { activeContext = ctx; if (queue.length === 0) paused = false; @@ -228,22 +279,12 @@ export default function queueSteerExtension(pi: ExtensionAPI) { return; } - const items = queue.snapshot().map((item) => { - const draftText = editSession?.textFor(item.id); - const draftImages = editSession?.imagesFor(item.id); - return { - ...item, - text: draftText ?? item.text, - images: draftImages ?? item.images, - }; - }); - const touchedIds = new Set(items.filter((item) => editSession?.touches(item.id)).map((item) => item.id)); + const items = timelineItems(); ctx.ui.setWidget( WIDGET_ID, (_tui, theme) => new QueueTimelineWidget({ items, editingId: editSession?.selectedId, - touchedIds, renderInlineEditor, paused, modes: queueModes(), @@ -361,7 +402,10 @@ export default function queueSteerExtension(pi: ExtensionAPI) { editSession = undefined; ctx.ui.setEditorText(session.composerDraft); if (result?.removed) { - ctx.ui.notify(`Removed ${result.removed} empty queued message${result.removed === 1 ? "" : "s"}`, "info"); + ctx.ui.notify(`Removed ${result.removed} queued message${result.removed === 1 ? "" : "s"}`, "info"); + } + if (result?.moved) { + ctx.ui.notify(`Moved ${result.moved} queued message${result.moved === 1 ? "" : "s"} to the other lane`, "info"); } renderQueue(ctx); @@ -387,13 +431,22 @@ export default function queueSteerExtension(pi: ExtensionAPI) { return; } + // Navigate the visual timeline so movement matches what is on screen + // even while a lane draft previews a row inside the other box. + const session = editSession; + const ordered = timelineItems(); const currentText = ctx.ui.getEditorText(); + const index = ordered.findIndex((item) => item.id === session.selectedId); const selectedId = direction === "previous" - ? queue.previousId(editSession.selectedId) - : queue.nextId(editSession.selectedId); + ? index <= 0 + ? ordered.at(-1)?.id + : ordered[index - 1]?.id + : index === -1 || index === ordered.length - 1 + ? ordered[0]?.id + : ordered[index + 1]?.id; const selected = selectedId ? queue.get(selectedId) : undefined; if (!selected) return; - const selectedText = editSession.select(selected, currentText); + const selectedText = session.select(selected, currentText); ctx.ui.setEditorText(selectedText); renderQueue(ctx); }; @@ -440,6 +493,16 @@ export default function queueSteerExtension(pi: ExtensionAPI) { selectQueueItem(ctx, "next"); return; } + if (matchesKey(data, REMOVE_ROW_KEY)) { + editSession.toggleRemoved(editSession.selectedId); + renderQueue(ctx); + return; + } + if (matchesKey(data, TOGGLE_LANE_KEY)) { + editSession.toggleLane(editSession.selectedId); + renderQueue(ctx); + return; + } if (keybindings.matches(data, "app.interrupt") && !isShowingAutocomplete()) { finishEditing(ctx, false); return; diff --git a/queue-state.ts b/queue-state.ts index 237d933..1f712a5 100644 --- a/queue-state.ts +++ b/queue-state.ts @@ -54,6 +54,17 @@ export class DeliveryQueue { return true; } + /** Reclassify a row into the other lane, joining that lane's tail. */ + moveToLaneTail(id: string, lane: QueueLane): boolean { + const index = this.items.findIndex((item) => item.id === id); + if (index === -1) return false; + const [item] = this.items.splice(index, 1); + if (!item) return false; + item.lane = lane; + this.items.push(item); + return true; + } + remove(id: string): QueuedMessage | undefined { const index = this.items.findIndex((item) => item.id === id); if (index === -1) return undefined; @@ -139,11 +150,14 @@ interface QueuedMessageDraft { id: string; text: string; images: TImage[]; + lane: QueueLane; + removed: boolean; } export interface EditCommitResult { updated: number; removed: number; + moved: number; } /** Rollback-safe drafts spanning rows from either delivery lane. */ @@ -155,7 +169,11 @@ export class QueueEditSession { constructor(item: QueuedMessage, composerDraft: string) { this.currentId = item.id; this.composerDraft = composerDraft; - this.drafts.set(item.id, { id: item.id, text: item.text, images: [...item.images] }); + this.drafts.set(item.id, this.newDraft(item)); + } + + private newDraft(item: QueuedMessage): QueuedMessageDraft { + return { id: item.id, text: item.text, images: [...item.images], lane: item.lane, removed: false }; } get selectedId(): string { @@ -176,12 +194,36 @@ export class QueueEditSession { select(item: QueuedMessage, currentText: string, images?: readonly TImage[]): string { this.capture(currentText, images); if (!this.drafts.has(item.id)) { - this.drafts.set(item.id, { id: item.id, text: item.text, images: [...item.images] }); + this.drafts.set(item.id, this.newDraft(item)); } this.currentId = item.id; return this.selectedText; } + /** Toggle whether the row is deleted on save. Returns the new mark. */ + toggleRemoved(id: string): boolean | undefined { + const draft = this.drafts.get(id); + if (!draft) return undefined; + draft.removed = !draft.removed; + return draft.removed; + } + + /** Toggle the row's draft delivery lane. Returns the new lane. */ + toggleLane(id: string): QueueLane | undefined { + const draft = this.drafts.get(id); + if (!draft) return undefined; + draft.lane = draft.lane === "steer" ? "followUp" : "steer"; + return draft.lane; + } + + laneFor(id: string): QueueLane | undefined { + return this.drafts.get(id)?.lane; + } + + isRemoved(id: string): boolean { + return this.drafts.get(id)?.removed ?? false; + } + touches(id: string): boolean { return this.drafts.has(id); } @@ -207,13 +249,22 @@ export class QueueEditSession { this.capture(currentText, images); let updated = 0; let removed = 0; + let moved = 0; for (const draft of this.drafts.values()) { - if (!draft.text.trim() && draft.images.length === 0) { + if (draft.removed || (!draft.text.trim() && draft.images.length === 0)) { if (queue.remove(draft.id)) removed += 1; continue; } if (queue.update(draft.id, draft.text, draft.images)) updated += 1; } - return { updated, removed }; + // Apply lane moves in queue order so multi-row moves land at the + // destination tail in the same order the timeline previewed them. + for (const item of queue.snapshot()) { + const draft = this.drafts.get(item.id); + if (draft && !draft.removed && draft.lane !== item.lane) { + if (queue.moveToLaneTail(item.id, draft.lane)) moved += 1; + } + } + return { updated, removed, moved }; } } diff --git a/test/queue-state.test.ts b/test/queue-state.test.ts index 17bd50b..51e4ca9 100644 --- a/test/queue-state.test.ts +++ b/test/queue-state.test.ts @@ -81,12 +81,57 @@ test("empty drafts remove text-only rows but preserve image-only rows", () => { const imageOnly = queue.enqueue("followUp", "", ["image.png"]); const deleteEdit = new QueueEditSession(textOnly, ""); - assert.deepEqual(deleteEdit.commit(queue, ""), { updated: 0, removed: 1 }); + assert.deepEqual(deleteEdit.commit(queue, ""), { updated: 0, removed: 1, moved: 0 }); const imageEdit = new QueueEditSession(imageOnly, ""); - assert.deepEqual(imageEdit.commit(queue, ""), { updated: 1, removed: 0 }); + assert.deepEqual(imageEdit.commit(queue, ""), { updated: 1, removed: 0, moved: 0 }); assert.deepEqual(queue.get(imageOnly.id)?.images, ["image.png"]); }); +test("removal marks delete any row on commit, including image-only rows", () => { + const queue = new DeliveryQueue(); + const imageOnly = queue.enqueue("followUp", "", ["image.png"]); + queue.enqueue("followUp", "keep me"); + + const edit = new QueueEditSession(imageOnly, ""); + assert.equal(edit.toggleRemoved(imageOnly.id), true); + assert.equal(edit.toggleRemoved(imageOnly.id), false); + assert.equal(edit.toggleRemoved(imageOnly.id), true); + assert.deepEqual(edit.commit(queue, ""), { updated: 0, removed: 1, moved: 0 }); + assert.deepEqual(queue.laneSnapshot("followUp").map((item) => item.text), ["keep me"]); +}); + +test("lane toggles re-lane rows to the destination tail on commit only", () => { + const queue = new DeliveryQueue(); + const promote = queue.enqueue("followUp", "promote me"); + queue.enqueue("steer", "steer one"); + queue.enqueue("steer", "steer two"); + + const edit = new QueueEditSession(promote, ""); + assert.equal(edit.toggleLane(promote.id), "steer"); + assert.equal(edit.laneFor(promote.id), "steer"); + assert.equal(queue.get(promote.id)?.lane, "followUp"); + + assert.deepEqual(edit.commit(queue, "promote me"), { updated: 1, removed: 0, moved: 1 }); + assert.deepEqual( + queue.laneSnapshot("steer").map((item) => item.text), + ["steer one", "steer two", "promote me"], + ); + assert.equal(queue.get(promote.id)?.id, promote.id); + assert.equal(queue.laneLength("followUp"), 0); +}); + +test("toggling a lane twice leaves the row untouched at commit", () => { + const queue = new DeliveryQueue(); + const first = queue.enqueue("steer", "first"); + queue.enqueue("steer", "second"); + + const edit = new QueueEditSession(first, ""); + edit.toggleLane(first.id); + edit.toggleLane(first.id); + assert.deepEqual(edit.commit(queue, "first"), { updated: 1, removed: 0, moved: 0 }); + assert.deepEqual(queue.laneSnapshot("steer").map((item) => item.text), ["first", "second"]); +}); + class MockEditor { private text = ""; onSubmit?: (text: string) => void; @@ -484,7 +529,102 @@ test("clearing a selected text-only row deletes it on save", async () => { harness.editor.setText(""); harness.editor.handleInput("enter"); assert.equal(harness.widget, undefined); - assert.match(harness.notifications[0]?.message ?? "", /Removed 1 empty queued message/); + assert.match(harness.notifications[0]?.message ?? "", /Removed 1 queued message/); +}); + +test("Alt+X marks the selected row and save removes it", async () => { + const harness = createHarness(); + await harness.emit("session_start"); + await enqueue(harness, "steer", "keep me"); + await enqueue(harness, "steer", "cancel me"); + + harness.editor.handleInput("alt-up"); + harness.editor.handleInput("\x1bx"); + assert.match(renderWidget(harness), /removed on save/); + + harness.editor.handleInput("enter"); + assert.match(harness.notifications[0]?.message ?? "", /Removed 1 queued message/); + await harness.emit("turn_end", { message: { role: "assistant", stopReason: "toolUse" } }); + assert.deepEqual(harness.sent[0], { content: "keep me", options: { deliverAs: "steer" } }); + assert.equal(harness.widget, undefined); +}); + +test("Escape rolls back a removal mark with the rest of the session", async () => { + const harness = createHarness(); + await harness.emit("session_start"); + await enqueue(harness, "steer", "nearly gone"); + + harness.editor.handleInput("alt-up"); + harness.editor.handleInput("\x1bx"); + harness.editor.handleInput("escape"); + await harness.emit("turn_end", { message: { role: "assistant", stopReason: "toolUse" } }); + assert.deepEqual(harness.sent[0], { content: "nearly gone", options: { deliverAs: "steer" } }); +}); + +test("a removal-marked head stays pinned at delivery boundaries", async () => { + const harness = createHarness(); + await harness.emit("session_start"); + await enqueue(harness, "steer", "marked head"); + + harness.editor.handleInput("alt-up"); + harness.editor.handleInput("\x1bx"); + await harness.emit("turn_end", { message: { role: "assistant", stopReason: "toolUse" } }); + assert.equal(harness.sent.length, 0); + assert.match(renderWidget(harness), /held while editing/); +}); + +test("Alt+T previews a follow-up in the steering box and re-lanes on save", async () => { + const harness = createHarness(); + await harness.emit("session_start"); + await enqueue(harness, "steer", "steer one"); + await enqueue(harness, "followUp", "promote me"); + + harness.editor.handleInput("alt-up"); + harness.editor.handleInput("\x1bt"); + const preview = renderWidget(harness); + assert.match(preview, /steering queue \(2\)/); + assert.match(preview, /moves here on save/); + assert.ok(preview.indexOf("steer one") < preview.indexOf("promote me")); + + harness.editor.handleInput("enter"); + assert.match(harness.notifications[0]?.message ?? "", /Moved 1 queued message to the other lane/); + await harness.emit("turn_end", { message: { role: "assistant", stopReason: "toolUse" } }); + await harness.emit("turn_end", { message: { role: "assistant", stopReason: "toolUse" } }); + assert.deepEqual(harness.sent.map((item) => [item.content, item.options]), [ + ["steer one", { deliverAs: "steer" }], + ["promote me", { deliverAs: "steer" }], + ]); +}); + +test("Escape rolls back a lane toggle with the rest of the session", async () => { + const harness = createHarness(); + await harness.emit("session_start"); + await enqueue(harness, "followUp", "stay a follow-up"); + + harness.editor.handleInput("alt-up"); + harness.editor.handleInput("\x1bt"); + harness.editor.handleInput("escape"); + await harness.emit("turn_end", { message: { role: "assistant", stopReason: "toolUse" } }); + assert.equal(harness.sent.length, 0); + await harness.emit("agent_end"); + assert.deepEqual(harness.sent[0], { content: "stay a follow-up", options: { deliverAs: "followUp" } }); +}); + +test("navigation follows the visual timeline while a lane draft is active", async () => { + const harness = createHarness(); + await harness.emit("session_start"); + await enqueue(harness, "steer", "steer one"); + await enqueue(harness, "followUp", "later one"); + await enqueue(harness, "followUp", "later two"); + + harness.editor.handleInput("alt-up"); + assert.equal(harness.editor.getText(), "later two"); + harness.editor.handleInput("\x1bt"); + // Now previewed at the steering tail: previous is the native steer row. + harness.editor.handleInput("alt-up"); + assert.equal(harness.editor.getText(), "steer one"); + harness.editor.handleInput("\x1b[1;3B"); + assert.equal(harness.editor.getText(), "later two"); }); test("recomposes after another extension installs editor chrome on a later tick", async () => {