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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
131 changes: 97 additions & 34 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
Expand Down Expand Up @@ -60,27 +62,31 @@ interface QueueModes {
followUp: QueueMode;
}

/** A queue row with session drafts applied for display and navigation. */
interface TimelineItem extends QueuedMessage<ImageContent> {
removed: boolean;
movedLane: boolean;
held: boolean;
}

class QueueTimelineWidget implements Component {
private readonly items: QueuedMessage<ImageContent>[];
private readonly items: TimelineItem[];
private readonly editingId: string | undefined;
private readonly touchedIds: ReadonlySet<string>;
private readonly renderInlineEditor: InlineEditorRenderer | undefined;
private readonly paused: boolean;
private readonly modes: QueueModes;
private readonly theme: Theme;

constructor(options: {
items: QueuedMessage<ImageContent>[];
items: TimelineItem[];
editingId: string | undefined;
touchedIds: ReadonlySet<string>;
renderInlineEditor: InlineEditorRenderer | undefined;
paused: boolean;
modes: QueueModes;
theme: Theme;
}) {
this.items = options.items;
this.editingId = options.editingId;
this.touchedIds = options.touchedIds;
this.renderInlineEditor = options.renderInlineEditor;
this.paused = options.paused;
this.modes = options.modes;
Expand Down Expand Up @@ -108,15 +114,12 @@ class QueueTimelineWidget implements Component {
private renderLaneBox(
lines: string[],
lane: QueueLane,
items: QueuedMessage<ImageContent>[],
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";
Expand All @@ -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`
Expand All @@ -149,29 +152,34 @@ class QueueTimelineWidget implements Component {

private renderItem(
lines: string[],
item: QueuedMessage<ImageContent>,
laneItems: QueuedMessage<ImageContent>[],
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"
? "○"
: armed
? "▶"
: "»";
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;
}

Expand All @@ -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("│")}`);
}
}

Expand Down Expand Up @@ -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<QueueLane, boolean> = {
steer: laneIsHeld("steer"),
followUp: laneIsHeld("followUp"),
};
const heads: Record<QueueLane, string | undefined> = {
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;
Expand All @@ -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(),
Expand Down Expand Up @@ -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);

Expand All @@ -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);
};
Expand Down Expand Up @@ -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;
Expand Down
Loading