@oh-my-pi/pi-coding-agent 16.4.5 → 16.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +3271 -3224
  3. package/dist/types/cli/bench-cli.d.ts +1 -7
  4. package/dist/types/cli/usage-cli.d.ts +1 -0
  5. package/dist/types/commands/usage.d.ts +7 -0
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  8. package/dist/types/modes/components/model-browser.d.ts +14 -1
  9. package/dist/types/modes/components/model-hub.d.ts +4 -3
  10. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  11. package/dist/types/modes/components/welcome.d.ts +4 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  13. package/dist/types/modes/interactive-mode.d.ts +2 -0
  14. package/dist/types/modes/queue-input.d.ts +8 -0
  15. package/dist/types/modes/types.d.ts +2 -0
  16. package/dist/types/session/agent-storage.d.ts +57 -0
  17. package/package.json +12 -12
  18. package/scripts/build-binary.ts +0 -1
  19. package/scripts/compile-binary.ts +4 -3
  20. package/src/cli/bench-cli.ts +7 -26
  21. package/src/cli/usage-cli.ts +11 -0
  22. package/src/commands/usage.ts +13 -2
  23. package/src/config/settings-schema.ts +1 -1
  24. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  25. package/src/modes/components/advisor-config.ts +3 -1
  26. package/src/modes/components/custom-editor.test.ts +58 -1
  27. package/src/modes/components/custom-editor.ts +42 -11
  28. package/src/modes/components/model-browser.ts +154 -60
  29. package/src/modes/components/model-hub.ts +475 -122
  30. package/src/modes/components/plan-review-overlay.ts +7 -0
  31. package/src/modes/components/tips.txt +2 -1
  32. package/src/modes/components/usage-row.ts +5 -6
  33. package/src/modes/components/welcome.ts +13 -14
  34. package/src/modes/controllers/input-controller.ts +140 -6
  35. package/src/modes/controllers/selector-controller.ts +20 -13
  36. package/src/modes/controllers/todo-command-controller.ts +1 -2
  37. package/src/modes/interactive-mode.ts +18 -0
  38. package/src/modes/queue-input.ts +132 -0
  39. package/src/modes/types.ts +2 -0
  40. package/src/modes/utils/ui-helpers.ts +19 -20
  41. package/src/session/agent-session.ts +184 -48
  42. package/src/session/agent-storage.ts +330 -3
  43. package/src/session/history-storage.ts +1 -34
  44. package/src/slash-commands/builtin-registry.ts +9 -0
  45. package/src/web/search/providers/perplexity.ts +18 -2
@@ -82,6 +82,8 @@ export interface PlanReviewOverlayCallbacks {
82
82
  onPick: (label: string) => void;
83
83
  /** Invoked on Esc / cancel. */
84
84
  onCancel: () => void;
85
+ /** Invoked with the current full plan text when the copy hotkey is pressed. */
86
+ onCopyPlan?: (content: string) => void | Promise<void>;
85
87
  /** Invoked when the external-editor key is pressed (overlay stays open). */
86
88
  onExternalEditor?: () => void;
87
89
  /** Invoked when the external-editor key edits the active annotation draft. */
@@ -302,6 +304,10 @@ export class PlanReviewOverlay implements Component {
302
304
  this.callbacks.onExternalEditor();
303
305
  return;
304
306
  }
307
+ if (this.callbacks.onCopyPlan && keyData === "c") {
308
+ void this.callbacks.onCopyPlan(joinPlanSections(this.#sections));
309
+ return;
310
+ }
305
311
  if (matchesKey(keyData, "tab") || keyData === "\t") {
306
312
  this.#cycleRegion(1);
307
313
  return;
@@ -677,6 +683,7 @@ export class PlanReviewOverlay implements Component {
677
683
  parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends");
678
684
  break;
679
685
  }
686
+ if (this.callbacks.onCopyPlan) parts.push("c copy");
680
687
  parts.push("tab regions");
681
688
  if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`);
682
689
  parts.push(this.#helpSuffix);
@@ -21,4 +21,5 @@ Pair up live: `/collab` shares your session through an end-to-end encrypted rela
21
21
  Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
22
22
  Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
23
23
  No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them!
24
- Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice [NEW]
24
+ Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice
25
+ Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y)
@@ -18,12 +18,11 @@ export function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?:
18
18
  parts.push(`${theme.icon.time} ${(ttftMs / 1000).toFixed(1)}s`);
19
19
  }
20
20
  if (durationMs && durationMs > MIN_DURATION_MS && usage.output > 0) {
21
- // Throughput excludes TTFT generation time is duration minus time-to-first-token.
22
- const genMs = durationMs - (ttftMs ?? 0);
23
- if (genMs > MIN_DURATION_MS) {
24
- const tokPerSec = (usage.output / genMs) * 1000;
25
- parts.push(`${theme.icon.throughput} ${tokPerSec.toFixed(1)}/s`);
26
- }
21
+ // TPS over the total request duration the post-TTFT window undercounts
22
+ // generation time when reasoning tokens are hidden before the first
23
+ // visible byte, inflating the rate.
24
+ const tokPerSec = (usage.output / durationMs) * 1000;
25
+ parts.push(`${theme.icon.throughput} ${tokPerSec.toFixed(1)}/s`);
27
26
  }
28
27
  const block = new Container();
29
28
  block.addChild(new Spacer(1));
@@ -44,20 +44,19 @@ const NEW_GLOW_PERIOD_MS = 1500;
44
44
  * affordance surfaces this many times as often. */
45
45
  const NEW_TIP_WEIGHT = 4;
46
46
 
47
- /** Per-tip selection weights, parallel to {@link TIPS}. */
48
- const TIP_WEIGHTS: readonly number[] = TIPS.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1));
49
- const TIP_WEIGHT_TOTAL = TIP_WEIGHTS.reduce((sum, weight) => sum + weight, 0);
50
-
51
- /** Pick a tip at random, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT}.
52
- * Returns "" when no tips are embedded. */
53
- function pickWeightedTip(): string {
54
- if (TIPS.length === 0) return "";
55
- let r = Math.random() * TIP_WEIGHT_TOTAL;
56
- for (let i = 0; i < TIPS.length; i++) {
57
- r -= TIP_WEIGHTS[i] ?? 1;
58
- if (r < 0) return TIPS[i] ?? "";
47
+ /** Pick a tip from `tips`, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT};
48
+ * `r` is a uniform sample in [0, 1). Returns "" when `tips` is empty.
49
+ * Exported for tests. */
50
+ export function pickWeightedTip(tips: readonly string[], r: number): string {
51
+ if (tips.length === 0) return "";
52
+ const weights = tips.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1));
53
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
54
+ let acc = r * total;
55
+ for (let i = 0; i < tips.length; i++) {
56
+ acc -= weights[i] ?? 1;
57
+ if (acc < 0) return tips[i] ?? "";
59
58
  }
60
- return TIPS[TIPS.length - 1] ?? "";
59
+ return tips[tips.length - 1] ?? "";
61
60
  }
62
61
 
63
62
  type ColorEncoding = "ansi-16m" | "ansi-256";
@@ -161,7 +160,7 @@ export class WelcomeComponent implements Component {
161
160
  if (theme.getSymbolPreset() === "unicode" && Math.random() < 0.1) {
162
161
  this.#selectedTip = "Please use nerdfont 😭.";
163
162
  } else {
164
- this.#selectedTip = pickWeightedTip();
163
+ this.#selectedTip = pickWeightedTip(TIPS, Math.random());
165
164
  }
166
165
  }
167
166
  return this.#selectedTip || undefined;
@@ -13,6 +13,7 @@ import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-
13
13
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
14
14
  import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references";
15
15
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
16
+ import { parseQueueShorthand, splitQueuedMessages } from "../../modes/queue-input";
16
17
  import { invokeSkillCommandFromText, isKnownSkillCommand } from "../../modes/skill-command";
17
18
  import type { InteractiveModeContext } from "../../modes/types";
18
19
  import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" };
@@ -266,6 +267,8 @@ export class InputController {
266
267
  });
267
268
  }
268
269
  this.ctx.editor.onEscape = () => {
270
+ // Side-channel panels are the topmost view. Esc dismisses them before
271
+ // touching loop mode, maintenance, or the underlying main turn.
269
272
  // Active context maintenance owns Esc: auto/manual compaction,
270
273
  // handoff generation, and auto-retry backoff all advertise
271
274
  // "(esc to cancel)". Dispatch on live session state instead of
@@ -281,6 +284,13 @@ export class InputController {
281
284
  // (see EventController). Main-session maintenance still owns Esc and
282
285
  // stays cancellable from the main view (focused submit gates /compact
283
286
  // and handoff, so manual maintenance is main-only anyway).
287
+ if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
288
+ return;
289
+ }
290
+ if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
291
+ return;
292
+ }
293
+
284
294
  if (!this.ctx.focusedAgentId) {
285
295
  const viewSession = this.ctx.viewSession;
286
296
  let aborted = false;
@@ -308,12 +318,6 @@ export class InputController {
308
318
  }
309
319
  return;
310
320
  }
311
- if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
312
- return;
313
- }
314
- if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
315
- return;
316
- }
317
321
  if (this.ctx.focusedAgentId) {
318
322
  // Esc never interrupts the focused agent's turn: clear typed text,
319
323
  // else return the view to the main session. Interrupt via empty
@@ -647,6 +651,16 @@ export class InputController {
647
651
 
648
652
  if (!text && !hasInputImages) return;
649
653
 
654
+ const queueBody = parseQueueShorthand(text);
655
+ if (queueBody !== undefined) {
656
+ await this.#queueForYield(queueBody, {
657
+ historyText: text,
658
+ images: inputImages,
659
+ imageLinks: inputImageLinks,
660
+ });
661
+ return;
662
+ }
663
+
650
664
  // Handle built-in slash commands
651
665
  if (text) {
652
666
  const slashResult = await executeBuiltinSlashCommand(text, {
@@ -1114,6 +1128,126 @@ export class InputController {
1114
1128
  }
1115
1129
  }
1116
1130
 
1131
+ /** Queue `/queue` input behind an active turn, or start it immediately when idle. */
1132
+ async handleQueueCommand(text: string): Promise<void> {
1133
+ const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
1134
+ const imageLinks =
1135
+ images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
1136
+ await this.#queueForYield(text, { images, imageLinks });
1137
+ }
1138
+
1139
+ async #queueForYield(
1140
+ text: string,
1141
+ options: {
1142
+ historyText?: string;
1143
+ images?: ImageContent[];
1144
+ imageLinks?: (string | undefined)[];
1145
+ },
1146
+ ): Promise<void> {
1147
+ const splitMessages = splitQueuedMessages(text);
1148
+ if (splitMessages.length === 0 && !options.images?.length) {
1149
+ this.ctx.editor.clearDraft();
1150
+ this.ctx.showWarning("Usage: /queue <message> (or start a prompt with -> / =>)");
1151
+ return;
1152
+ }
1153
+
1154
+ const messages = splitMessages.length > 0 ? splitMessages : [""];
1155
+ const originalDraft = this.ctx.editor.getText();
1156
+ const images = options.images?.length ? [...options.images] : undefined;
1157
+ const imageLinks = options.imageLinks
1158
+ ? [...options.imageLinks]
1159
+ : images
1160
+ ? images.map(() => undefined)
1161
+ : undefined;
1162
+ this.ctx.editor.clearDraft(options.historyText);
1163
+
1164
+ if (this.ctx.session.isCompacting) {
1165
+ for (let index = 0; index < messages.length; index++) {
1166
+ this.ctx.compactionQueuedMessages.push({
1167
+ text: messages[index] ?? "",
1168
+ mode: "followUp",
1169
+ images: index === 0 ? images : undefined,
1170
+ });
1171
+ }
1172
+ this.ctx.updatePendingMessagesDisplay();
1173
+ this.ctx.showStatus(
1174
+ messages.length === 1
1175
+ ? "Queued message for after compaction"
1176
+ : `Queued ${messages.length} messages for after compaction`,
1177
+ );
1178
+ this.ctx.ui.requestRender();
1179
+ return;
1180
+ }
1181
+
1182
+ const startImmediately = !this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount === 0;
1183
+ let queuedCount = 0;
1184
+ try {
1185
+ if (startImmediately && this.ctx.onInputCallback) {
1186
+ const first = messages[0] ?? "";
1187
+ const submission = this.ctx.startPendingSubmission({
1188
+ text: first,
1189
+ images,
1190
+ imageLinks,
1191
+ streamingBehavior: "followUp",
1192
+ });
1193
+ this.ctx.onInputCallback(submission);
1194
+ queuedCount = 1;
1195
+ }
1196
+ while (queuedCount < messages.length) {
1197
+ const message = messages[queuedCount] ?? "";
1198
+ const queuedImages = queuedCount === 0 ? images : undefined;
1199
+ await this.ctx.withLocalSubmission(
1200
+ message,
1201
+ async () => {
1202
+ if (startImmediately && queuedCount === 0) {
1203
+ await this.ctx.session.prompt(message, {
1204
+ images: queuedImages,
1205
+ streamingBehavior: "followUp",
1206
+ });
1207
+ } else {
1208
+ await this.ctx.session.followUp(message, queuedImages);
1209
+ }
1210
+ },
1211
+ { imageCount: queuedImages?.length ?? 0 },
1212
+ );
1213
+ queuedCount++;
1214
+ }
1215
+ } catch (error) {
1216
+ if (queuedCount === 0) {
1217
+ this.ctx.editor.setText(originalDraft);
1218
+ if (images) {
1219
+ this.ctx.editor.pendingImages = images;
1220
+ this.ctx.editor.pendingImageLinks = imageLinks ?? images.map(() => undefined);
1221
+ this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
1222
+ }
1223
+ } else {
1224
+ const remaining = messages.slice(queuedCount);
1225
+ const restored =
1226
+ remaining.length === 1
1227
+ ? `=> ${remaining[0]}`
1228
+ : `=>\n${remaining
1229
+ .map((message, index) => `${index + 1}. ${message.replaceAll("\n", "\n ")}`)
1230
+ .join("\n")}`;
1231
+ this.ctx.editor.setText(restored);
1232
+ }
1233
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
1234
+ }
1235
+
1236
+ this.ctx.updatePendingMessagesDisplay();
1237
+ if (queuedCount === messages.length) {
1238
+ this.ctx.showStatus(
1239
+ startImmediately
1240
+ ? queuedCount === 1
1241
+ ? "Sent queued message"
1242
+ : `Sent first message; queued ${queuedCount - 1} for later yields`
1243
+ : queuedCount === 1
1244
+ ? "Queued message for when the agent yields"
1245
+ : `Queued ${queuedCount} messages for when the agent yields`,
1246
+ );
1247
+ }
1248
+ this.ctx.ui.requestRender();
1249
+ }
1250
+
1117
1251
  /** Send editor text as a follow-up message (queued behind current stream). */
1118
1252
  async handleFollowUp(): Promise<void> {
1119
1253
  let text = this.ctx.editor.getExpandedText().trim();
@@ -616,7 +616,7 @@ export class SelectorController {
616
616
  this.ctx.session.modelRegistry,
617
617
  this.ctx.session.scopedModels,
618
618
  {
619
- onAssign: async (model, role, thinkingLevel, selector, action) => {
619
+ onAssign: async (model, role, thinkingLevel, selector) => {
620
620
  // `auto` is session-global: never baked into a per-role model value
621
621
  // (it can't round-trip through `model:<level>`). Apply it to the session
622
622
  // separately and persist via `defaultThinkingLevel`.
@@ -624,18 +624,6 @@ export class SelectorController {
624
624
  const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel;
625
625
  const selectorValue = selector ?? `${model.provider}/${model.id}`;
626
626
  try {
627
- if (action === "retryFallback") {
628
- const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking);
629
- const fallbackChains = this.ctx.settings.get("retry.fallbackChains");
630
- const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : [];
631
- this.ctx.settings.set("retry.fallbackChains", {
632
- ...fallbackChains,
633
- [role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)],
634
- });
635
- const roleInfo = getRoleInfo(role, settings);
636
- this.ctx.showStatus(`${roleInfo?.name ?? role} fallback model: ${fallbackSelector}`);
637
- return;
638
- }
639
627
  if (role === "default") {
640
628
  const { switched } = await this.ctx.session.setModel(model, role, {
641
629
  selector,
@@ -679,6 +667,25 @@ export class SelectorController {
679
667
  this.ctx.showError(error instanceof Error ? error.message : String(error));
680
668
  }
681
669
  },
670
+ onFallbackChainChange: (role, chain) => {
671
+ try {
672
+ const chains = { ...this.ctx.settings.get("retry.fallbackChains") };
673
+ if (chain.length === 0) {
674
+ delete chains[role];
675
+ } else {
676
+ chains[role] = chain;
677
+ }
678
+ this.ctx.settings.set("retry.fallbackChains", chains);
679
+ const roleInfo = getRoleInfo(role, settings);
680
+ this.ctx.showStatus(
681
+ chain.length > 0
682
+ ? `${roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}`
683
+ : `${roleInfo?.name ?? role} fallbacks cleared`,
684
+ );
685
+ } catch (error) {
686
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
687
+ }
688
+ },
682
689
  onPick: async (model, selector) => {
683
690
  try {
684
691
  // Session-only: update agent state but don't persist the model to settings.
@@ -133,8 +133,7 @@ export class TodoCommandController {
133
133
 
134
134
  /**
135
135
  * True latest todo state for the user-facing /todo verbs. Reads from session
136
- * entries so that completed/abandoned tasks remain visible after resume
137
- * (where `session.getTodoPhases()` would have stripped them).
136
+ * entries or falls back to the active session state.
138
137
  */
139
138
  #currentPhases(): TodoPhase[] {
140
139
  const fromEntries = getLatestTodoPhasesFromEntries(this.ctx.sessionManager.getBranch());
@@ -120,6 +120,7 @@ import { formatPhaseDisplayName, todoMatchesAnyDescription } from "../tools/todo
120
120
  import { ToolError } from "../tools/tool-errors";
121
121
  import { vocalizer } from "../tts/vocalizer";
122
122
  import { renderTreeList } from "../tui/tree-list";
123
+ import { copyToClipboard } from "../utils/clipboard";
123
124
  import type { EventBus } from "../utils/event-bus";
124
125
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
125
126
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
@@ -2517,6 +2518,7 @@ export class InteractiveMode implements InteractiveModeContext {
2517
2518
  {
2518
2519
  onPick: choice => finish(choice),
2519
2520
  onCancel: () => finish(undefined),
2521
+ onCopyPlan: content => void this.#copyPlanToClipboard(content),
2520
2522
  onExternalEditor: dialogOptions?.onExternalEditor,
2521
2523
  onAnnotationExternalEditor: (draft, commit) => void this.#openPlanAnnotationInExternalEditor(draft, commit),
2522
2524
  onPlanEdited: dialogOptions?.onPlanEdited,
@@ -2583,6 +2585,17 @@ export class InteractiveMode implements InteractiveModeContext {
2583
2585
  return contextUsage !== undefined && contextUsage.percent > PLAN_KEEP_CONTEXT_DISABLE_THRESHOLD_PERCENT;
2584
2586
  }
2585
2587
 
2588
+ async #copyPlanToClipboard(content: string): Promise<void> {
2589
+ try {
2590
+ await copyToClipboard(content);
2591
+ this.showStatus("Copied plan to clipboard");
2592
+ } catch (error) {
2593
+ this.showWarning(
2594
+ `Failed to copy plan to clipboard: ${error instanceof Error ? error.message : String(error)}`,
2595
+ );
2596
+ }
2597
+ }
2598
+
2586
2599
  async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
2587
2600
  const editorCmd = getEditorCommand();
2588
2601
  if (!editorCmd) {
@@ -4287,6 +4300,11 @@ export class InteractiveMode implements InteractiveModeContext {
4287
4300
  return this.#inputController.handleImagePaste();
4288
4301
  }
4289
4302
 
4303
+ /** Queue slash-command input behind the active turn. */
4304
+ handleQueueCommand(message: string): Promise<void> {
4305
+ return this.#inputController.handleQueueCommand(message);
4306
+ }
4307
+
4290
4308
  handleBtwCommand(question: string): Promise<void> {
4291
4309
  return this.#btwController.start(question);
4292
4310
  }
@@ -0,0 +1,132 @@
1
+ const QUEUE_PREFIXES: readonly string[] = ["->", "=>"];
2
+ /** Prefix matcher shared by queue-list parsing and editor highlighting. */
3
+ export const QUEUE_LIST_MARKER_RE = /^([\t ]*)(\d+|[A-Za-z]+)([.)])(?=[\t ]|$)/;
4
+ const CANONICAL_ROMAN_RE = /^(?=[MDCLXVI])M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})$/i;
5
+
6
+ interface EnumeratedItem {
7
+ line: number;
8
+ indent: string;
9
+ marker: string;
10
+ punctuation: string;
11
+ content: string;
12
+ }
13
+
14
+ interface EnumeratedList {
15
+ source: string;
16
+ lines: string[];
17
+ items: EnumeratedItem[];
18
+ }
19
+
20
+ /** Extract the message body from the `->` / `=>` yield-queue shorthand. */
21
+ export function parseQueueShorthand(text: string): string | undefined {
22
+ const prefix = QUEUE_PREFIXES.find(candidate => text.startsWith(candidate));
23
+ return prefix ? text.slice(prefix.length).trim() : undefined;
24
+ }
25
+
26
+ function parseEnumeratedItem(line: string, lineIndex: number): EnumeratedItem | undefined {
27
+ const match = QUEUE_LIST_MARKER_RE.exec(line);
28
+ if (!match) return undefined;
29
+ const [matched, indent, marker, punctuation] = match;
30
+ if (indent === undefined || marker === undefined || punctuation === undefined) return undefined;
31
+ return { line: lineIndex, indent, marker, punctuation, content: line.slice(matched.length).trimStart() };
32
+ }
33
+
34
+ function decodeDecimal(marker: string): number | undefined {
35
+ if (!/^\d+$/.test(marker)) return undefined;
36
+ const value = Number(marker);
37
+ return Number.isSafeInteger(value) ? value : undefined;
38
+ }
39
+
40
+ function decodeRoman(marker: string): number | undefined {
41
+ if (!CANONICAL_ROMAN_RE.test(marker)) return undefined;
42
+ const values: Readonly<Record<string, number>> = {
43
+ I: 1,
44
+ V: 5,
45
+ X: 10,
46
+ L: 50,
47
+ C: 100,
48
+ D: 500,
49
+ M: 1000,
50
+ };
51
+ const upper = marker.toUpperCase();
52
+ let value = 0;
53
+ for (let index = 0; index < upper.length; index++) {
54
+ const current = values[upper[index] ?? ""];
55
+ if (current === undefined) return undefined;
56
+ const next = values[upper[index + 1] ?? ""] ?? 0;
57
+ value += current < next ? -current : current;
58
+ }
59
+ return value;
60
+ }
61
+
62
+ function decodeAlpha(marker: string): number | undefined {
63
+ if (!/^[A-Za-z]+$/.test(marker)) return undefined;
64
+ let value = 0;
65
+ for (const char of marker.toUpperCase()) {
66
+ value = value * 26 + char.charCodeAt(0) - 64;
67
+ if (!Number.isSafeInteger(value)) return undefined;
68
+ }
69
+ return value;
70
+ }
71
+
72
+ function isSequential(markers: readonly string[], decode: (marker: string) => number | undefined): boolean {
73
+ let previous = decode(markers[0] ?? "");
74
+ if (previous === undefined) return false;
75
+ for (let index = 1; index < markers.length; index++) {
76
+ const current = decode(markers[index] ?? "");
77
+ if (current === undefined || current !== previous + 1) return false;
78
+ previous = current;
79
+ }
80
+ return true;
81
+ }
82
+
83
+ function isEnumeratedSequence(items: readonly EnumeratedItem[]): boolean {
84
+ const markers = items.map(item => item.marker);
85
+ if (markers.every(marker => /^\d+$/.test(marker))) return isSequential(markers, decodeDecimal);
86
+ if (
87
+ !markers.every(marker => marker === marker.toUpperCase()) &&
88
+ !markers.every(marker => marker === marker.toLowerCase())
89
+ ) {
90
+ return false;
91
+ }
92
+ return isSequential(markers, decodeRoman) || isSequential(markers, decodeAlpha);
93
+ }
94
+
95
+ function parseEnumeratedList(text: string): EnumeratedList | undefined {
96
+ const source = text.trim();
97
+ if (!source) return undefined;
98
+ const lines = source.split(/\r?\n/);
99
+ const first = parseEnumeratedItem(lines[0] ?? "", 0);
100
+ if (!first) return undefined;
101
+
102
+ const items = [first];
103
+ for (let lineIndex = 1; lineIndex < lines.length; lineIndex++) {
104
+ const item = parseEnumeratedItem(lines[lineIndex] ?? "", lineIndex);
105
+ if (item?.indent === first.indent) items.push(item);
106
+ }
107
+ if (items.length < 2 || items.some(item => item.punctuation !== first.punctuation) || !isEnumeratedSequence(items)) {
108
+ return undefined;
109
+ }
110
+ return { source, lines, items };
111
+ }
112
+
113
+ /** Whether text currently forms a sequential queue list, including an unfinished trailing item. */
114
+ export function isQueuedMessageList(text: string): boolean {
115
+ return parseEnumeratedList(text) !== undefined;
116
+ }
117
+
118
+ /** Split a sequential numeric, Roman-numeral, or alphabetic list into queue entries. */
119
+ export function splitQueuedMessages(text: string): string[] {
120
+ const list = parseEnumeratedList(text);
121
+ if (!list) {
122
+ const source = text.trim();
123
+ return source ? [source] : [];
124
+ }
125
+
126
+ const messages = list.items.map((item, index) => {
127
+ const nextLine = list.items[index + 1]?.line ?? list.lines.length;
128
+ return [item.content, ...list.lines.slice(item.line + 1, nextLine)].join("\n").trim();
129
+ });
130
+ while (messages.at(-1) === "") messages.pop();
131
+ return messages.length > 0 && messages.every(Boolean) ? messages : [list.source];
132
+ }
@@ -377,6 +377,8 @@ export interface InteractiveModeContext {
377
377
  handleCtrlZ(): void;
378
378
  handleDequeue(): void;
379
379
  handleImagePaste(): Promise<boolean>;
380
+ /** Queue a message for delivery only after the active agent turn would stop. */
381
+ handleQueueCommand(message: string): Promise<void>;
380
382
  handleBtwCommand(question: string): Promise<void>;
381
383
  handleTanCommand(work: string): Promise<void>;
382
384
  hasActiveBtw(): boolean;
@@ -46,6 +46,7 @@ import {
46
46
  type SkillPromptDetails,
47
47
  } from "../../session/messages";
48
48
  import type { SessionContext } from "../../session/session-context";
49
+ import { replaceTabs } from "../../tools/render-utils";
49
50
  import { buildSkillCommandPrompt, invokeSkillCommandFromText, isKnownSkillCommand } from "../skill-command";
50
51
  import { createAssistantMessageComponent } from "./interactive-context-helpers";
51
52
  import {
@@ -677,35 +678,33 @@ export class UiHelpers {
677
678
  this.ctx.pendingMessagesContainer.disposeChildren();
678
679
  const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages;
679
680
 
680
- const steeringMessages: Array<{ message: string; label: string }> = [];
681
- for (const message of queuedMessages.steering) {
682
- steeringMessages.push({ message, label: "Steer" });
683
- }
681
+ const steeringMessages = [...queuedMessages.steering];
684
682
  for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) {
685
- if (entry.mode === "steer") {
686
- steeringMessages.push({ message: entry.text, label: "Steer" });
687
- }
683
+ if (entry.mode === "steer") steeringMessages.push(entry.text);
688
684
  }
689
685
 
690
- const followUpMessages: Array<{ message: string; label: string }> = [];
691
- for (const message of queuedMessages.followUp) {
692
- followUpMessages.push({ message, label: "Follow-up" });
693
- }
686
+ const followUpMessages = [...queuedMessages.followUp];
694
687
  for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) {
695
- if (entry.mode === "followUp") {
696
- followUpMessages.push({ message: entry.text, label: "Follow-up" });
697
- }
688
+ if (entry.mode === "followUp") followUpMessages.push(entry.text);
698
689
  }
699
690
 
700
- const allMessages = [...steeringMessages, ...followUpMessages];
701
- if (allMessages.length > 0) {
691
+ const groups = [
692
+ { label: "Steering", messages: steeringMessages },
693
+ { label: "After yield", messages: followUpMessages },
694
+ ].filter(group => group.messages.length > 0);
695
+ if (groups.length > 0) {
702
696
  this.ctx.pendingMessagesContainer.addChild(new Spacer(1));
703
- for (const entry of allMessages) {
704
- const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
705
- this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
697
+ for (const group of groups) {
698
+ const heading = theme.fg("muted", `${group.label}${theme.sep.dot}${group.messages.length}`);
699
+ this.ctx.pendingMessagesContainer.addChild(new TruncatedText(heading, 1, 0));
700
+ for (let index = 0; index < group.messages.length; index++) {
701
+ const message = replaceTabs(group.messages[index] ?? "").replace(/\r?\n/g, " ↵ ");
702
+ const queuedText = theme.fg("dim", ` ${index + 1}. ${message}`);
703
+ this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
704
+ }
706
705
  }
707
706
  const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
708
- const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
707
+ const hintText = theme.fg("dim", ` ${theme.tree.hook} ${dequeueKey} to edit`);
709
708
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
710
709
  }
711
710
  }