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 () => {