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

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 (38) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli.js +3052 -3005
  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 +3 -0
  9. package/dist/types/modes/components/model-hub.d.ts +4 -3
  10. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  11. package/dist/types/modes/interactive-mode.d.ts +2 -0
  12. package/dist/types/modes/queue-input.d.ts +8 -0
  13. package/dist/types/modes/types.d.ts +2 -0
  14. package/dist/types/session/agent-storage.d.ts +57 -0
  15. package/package.json +12 -12
  16. package/scripts/build-binary.ts +0 -1
  17. package/scripts/compile-binary.ts +4 -3
  18. package/src/cli/bench-cli.ts +7 -26
  19. package/src/cli/usage-cli.ts +11 -0
  20. package/src/commands/usage.ts +13 -2
  21. package/src/config/settings-schema.ts +1 -1
  22. package/src/modes/components/advisor-config.ts +3 -1
  23. package/src/modes/components/custom-editor.test.ts +58 -1
  24. package/src/modes/components/custom-editor.ts +42 -11
  25. package/src/modes/components/model-browser.ts +114 -33
  26. package/src/modes/components/model-hub.ts +455 -108
  27. package/src/modes/components/usage-row.ts +5 -6
  28. package/src/modes/controllers/input-controller.ts +140 -6
  29. package/src/modes/controllers/selector-controller.ts +20 -13
  30. package/src/modes/controllers/todo-command-controller.ts +1 -2
  31. package/src/modes/interactive-mode.ts +5 -0
  32. package/src/modes/queue-input.ts +132 -0
  33. package/src/modes/types.ts +2 -0
  34. package/src/modes/utils/ui-helpers.ts +19 -20
  35. package/src/session/agent-session.ts +184 -48
  36. package/src/session/agent-storage.ts +330 -3
  37. package/src/session/history-storage.ts +1 -34
  38. package/src/slash-commands/builtin-registry.ts +9 -0
@@ -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));
@@ -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());
@@ -4287,6 +4287,11 @@ export class InteractiveMode implements InteractiveModeContext {
4287
4287
  return this.#inputController.handleImagePaste();
4288
4288
  }
4289
4289
 
4290
+ /** Queue slash-command input behind the active turn. */
4291
+ handleQueueCommand(message: string): Promise<void> {
4292
+ return this.#inputController.handleQueueCommand(message);
4293
+ }
4294
+
4290
4295
  handleBtwCommand(question: string): Promise<void> {
4291
4296
  return this.#btwController.start(question);
4292
4297
  }
@@ -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
  }