Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- 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.
- Keep steering rows editable until Pi's native turn boundary.
- Honour Pi's independent `one-at-a-time` and `all` modes at active-run delivery boundaries.
- Add `Option+Down` navigation and recency-first `Option+Up` selection.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

A visible steering and follow-up timeline for [Pi](https://github.com/earendil-works/pi-mono).

Queue instructions while the agent works. Steering rows stay above an ‘after this run’ boundary. Follow-ups stay below it. Both lanes remain independent first-in, first-out queues and keep Pi’s delivery timing.
Queue instructions while the agent works. Steering stays in a blue next-turn box. Follow-ups stay in a yellow after-this-run box beneath it. Both lanes remain independent first-in, first-out queues and keep Pi’s delivery timing.

Move into any row to edit it. The selected row becomes the live Pi editor, with its cursor, wrapping, paste handling, autocomplete and custom-editor behaviour intact.

Expand Down Expand Up @@ -59,7 +59,7 @@ The extension keeps Pi’s 2 delivery classes:

- steering reaches the current run at Pi’s next safe turn boundary
- follow-ups wait until the run finishes
- steering remains ahead of follow-ups in the visible timeline
- the blue steering box remains above the yellow follow-up box
- each lane keeps its own first-in, first-out order
- Pi’s `one-at-a-time` and `all` settings apply independently at active-run delivery boundaries

Expand Down
Binary file modified assets/pi-queue-steer-demo.mp4
Binary file not shown.
63 changes: 42 additions & 21 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,37 +91,60 @@ class QueueTimelineWidget implements Component {
const steering = this.items.filter((item) => item.lane === "steer");
const followUps = this.items.filter((item) => item.lane === "followUp");
if (width < 28) {
const summary = `queued S${steering.length} F${followUps.length}${this.paused ? " paused" : ""}`;
return [truncateToWidth(this.theme.fg("borderMuted", summary), width, "")];
const counts = [
this.theme.fg("accent", `S${steering.length}`),
this.theme.fg("warning", `F${followUps.length}`),
].join(" ");
const summary = `queued ${counts}${this.paused ? " paused" : ""}`;
return [truncateToWidth(summary, width, "")];
}

const border = (text: string) => this.theme.fg("borderMuted", text);
const counts = `${steering.length} steering · ${followUps.length} follow-up`;
const fullTitle = ` queued (${counts})${this.paused ? " · paused" : ""} `;
const shortTitle = ` queued S${steering.length} F${followUps.length}${this.paused ? " paused" : ""} `;
const lines: string[] = [];
if (steering.length > 0) this.renderLaneBox(lines, "steer", steering, width);
if (followUps.length > 0) this.renderLaneBox(lines, "followUp", followUps, width);
return lines;
}

private renderLaneBox(
lines: string[],
lane: QueueLane,
items: QueuedMessage<ImageContent>[],
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 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";
const fullTitle = ` ${name} (${items.length}) · ${state} `;
const shortTitle = ` ${name} (${items.length}) `;
const title = visibleWidth(fullTitle) + 2 <= width ? fullTitle : shortTitle;
const topFill = "─".repeat(Math.max(0, width - visibleWidth(title) - 2));
const lines = [border(`┌${title}${topFill}┐`)];
lines.push(border(`┌${title}${topFill}┐`));
const cellWidth = width - 4;

for (const item of steering) this.renderItem(lines, item, steering, cellWidth, border);
if (steering.length > 0 && followUps.length > 0) {
lines.push(`${border("│")} ${fitCell(this.theme.fg("dim", "─ after this run ─"), cellWidth)} ${border("│")}`);
}
for (const item of followUps) this.renderItem(lines, item, followUps, cellWidth, border);
for (const item of items) this.renderItem(lines, item, items, cellWidth, border);

const dequeue = keyText("app.message.dequeue");
const followUp = keyText("app.message.followUp");
const submit = keyText("tui.input.submit");
const interrupt = keyText("app.interrupt");
const selectedHere = items.some((item) => item.id === this.editingId);
const help = this.editingId
? `${dequeue}/${nextRowKeyText()} move · ${submit}/${followUp} save · ${interrupt} cancel`
? selectedHere
? `${dequeue}/${nextRowKeyText()} move · ${submit}/${followUp} save · ${interrupt} cancel`
: `${dequeue}/${nextRowKeyText()} move here · ${interrupt} cancel`
: this.paused
? `${submit} resume · ${dequeue} edit · ${interrupt} keep paused`
: `${submit} steer/send next · ${followUp} follow-up · ${dequeue} edit`;
: lane === "steer"
? `${submit} steer/send next · ${dequeue} edit`
: `${followUp} add follow-up · ${submit} send next · ${dequeue} edit`;
lines.push(`${border("│")} ${fitCell(this.theme.fg("dim", help), cellWidth)} ${border("│")}`);
lines.push(border(`└${"─".repeat(width - 2)}┘`));
return lines;
}

private renderItem(
Expand All @@ -136,8 +159,6 @@ class QueueTimelineWidget implements Component {
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 status = held ? " [held]" : this.paused && armed ? " [paused]" : "";
const label = `${laneLabel(item.lane)}${status}`;
const color = laneColor(item.lane);

if (!selected) {
Expand All @@ -148,13 +169,13 @@ class QueueTimelineWidget implements Component {
: armed
? "▶"
: "»";
const prefix = `${marker} ${label.padEnd(12)} `;
const raw = `${this.theme.fg(color, prefix)}${compactText(item)}`;
lines.push(`${border("│")} ${fitCell(raw, cellWidth)} ${border("│")}`);
const prefix = this.theme.fg(color, `${marker} `);
const body = this.theme.fg("muted", compactText(item));
lines.push(`${border("│")} ${fitCell(`${prefix}${body}`, cellWidth)} ${border("│")}`);
return;
}

const prefixText = `› ${label} `;
const prefixText = "› ";
const prefixWidth = visibleWidth(prefixText);
const editorWidth = Math.max(1, cellWidth - prefixWidth);
const editorLines = this.renderInlineEditor?.(editorWidth) ?? [item.text];
Expand Down
53 changes: 46 additions & 7 deletions test/queue-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,17 +228,55 @@ function renderWidget(harness: ReturnType<typeof createHarness>, width = 76): st
return component.render(width).join("\n");
}

test("renders one timeline with steering before the after-this-run boundary", async () => {
test("renders stacked lane boxes with steering above follow-ups", async () => {
const harness = createHarness();
await harness.emit("session_start");
await enqueue(harness, "followUp", "write the README");
await enqueue(harness, "steer", "check the API first");

const rendered = renderWidget(harness);
assert.ok(rendered.indexOf("check the API first") < rendered.indexOf("after this run"));
assert.ok(rendered.indexOf("after this run") < rendered.indexOf("write the README"));
assert.match(rendered, /steer/);
assert.match(rendered, /follow-up/);
const lines = rendered.split("\n");
assert.equal(lines.filter((line) => line.startsWith("┌")).length, 2);
assert.ok(rendered.indexOf("steering queue (1)") < rendered.indexOf("check the API first"));
assert.ok(rendered.indexOf("check the API first") < rendered.indexOf("follow-ups (1)"));
assert.ok(rendered.indexOf("follow-ups (1)") < rendered.indexOf("write the README"));
assert.match(rendered, /next turn/);
assert.match(rendered, /after this run/);
});

test("colors each lane's full box instead of only its row label", async () => {
const harness = createHarness();
await harness.emit("session_start");
await enqueue(harness, "steer", "blue row");
await enqueue(harness, "followUp", "yellow row");
const calls: Array<[string, string]> = [];
const widgetFactory = harness.widget as (tui: unknown, theme: any) => { render(width: number): string[] };
const component = widgetFactory({}, {
fg(color: string, text: string): string {
calls.push([color, text]);
return text;
},
});

component.render(76);
assert.ok(calls.some(([color, text]) => color === "accent" && text.startsWith("┌ steering queue")));
assert.ok(calls.some(([color, text]) => color === "warning" && text.startsWith("┌ follow-ups")));
assert.ok(calls.some(([color, text]) => color === "muted" && text === "blue row"));
assert.ok(calls.some(([color, text]) => color === "muted" && text === "yellow row"));
});

test("keeps queued text aligned when its row becomes the live editor", async () => {
const harness = createHarness();
await harness.emit("session_start");
await enqueue(harness, "followUp", "aligned message");

const queuedLine = renderWidget(harness).split("\n").find((line) => line.includes("aligned message"));
harness.editor.handleInput("alt-up");
const editingLine = renderWidget(harness).split("\n").find((line) => line.includes("aligned message"));

assert.ok(queuedLine);
assert.ok(editingLine);
assert.equal(queuedLine.indexOf("aligned message"), editingLine.indexOf("aligned message"));
});

test("uses compact queue chrome at narrow terminal widths", async () => {
Expand Down Expand Up @@ -319,7 +357,7 @@ test("Alt+Up enters at the most recently enqueued row across both lanes", async

harness.editor.handleInput("alt-up");
assert.equal(harness.editor.getText(), "newest overall");
assert.match(renderWidget(harness), /› follow-up newest overall/);
assert.match(renderWidget(harness), /› newest overall/);
});

test("Alt+Up and Alt+Down navigate spatially while retaining row drafts", async () => {
Expand Down Expand Up @@ -384,7 +422,8 @@ test("editing a steering head pins it while editing a later row does not", async
held.editor.handleInput("alt-up");
await held.emit("turn_end", { message: { role: "assistant", stopReason: "toolUse" } });
assert.equal(held.sent.length, 0);
assert.match(renderWidget(held), /steer \[held\] first/);
assert.match(renderWidget(held), /held while editing/);
assert.match(renderWidget(held), /› first/);

const later = createHarness();
await later.emit("session_start");
Expand Down