@oh-my-pi/pi-tui 18.4.0 → 18.4.2

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 (65) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/chat/image-loading.d.ts +24 -0
  3. package/dist/types/chat/transcript-entry.d.ts +9 -0
  4. package/dist/types/chrome/transcript-container.d.ts +24 -3
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/rewind-selector.d.ts +4 -3
  8. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/composer-cache.d.ts +34 -16
  11. package/dist/types/prompt/composer.d.ts +15 -18
  12. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  13. package/dist/types/prompt/welcome.d.ts +5 -2
  14. package/dist/types/render/width-aware-text.d.ts +6 -0
  15. package/dist/types/status-line/component.d.ts +13 -4
  16. package/dist/types/status-line/metrics.d.ts +0 -1
  17. package/dist/types/status-line/startup.d.ts +38 -0
  18. package/dist/types/status-line/types.d.ts +0 -2
  19. package/dist/types/terminal-capabilities.d.ts +10 -6
  20. package/dist/types/terminal.d.ts +7 -0
  21. package/dist/types/theme/color.d.ts +1 -0
  22. package/dist/types/theme/session-color.d.ts +3 -3
  23. package/dist/types/theme/shimmer.d.ts +1 -1
  24. package/dist/types/theme/theme-class.d.ts +1 -1
  25. package/dist/types/tools/output-meta.d.ts +2 -1
  26. package/dist/types/tools/vibe.d.ts +4 -0
  27. package/dist/types/tui.d.ts +3 -2
  28. package/package.json +9 -9
  29. package/src/chat/assistant-message.ts +27 -21
  30. package/src/chat/image-loading.ts +81 -0
  31. package/src/chat/tool-execution.ts +53 -17
  32. package/src/chat/transcript-entry.ts +16 -0
  33. package/src/chrome/transcript-container.ts +282 -86
  34. package/src/components/editor.ts +28 -6
  35. package/src/components/image.ts +25 -9
  36. package/src/components/loader.ts +37 -17
  37. package/src/overlays/agent-hub.ts +16 -6
  38. package/src/overlays/agents-hub.ts +7 -18
  39. package/src/overlays/copy-selector.ts +8 -31
  40. package/src/overlays/model-browser.ts +106 -25
  41. package/src/overlays/model-hub.ts +49 -16
  42. package/src/overlays/rewind-selector.ts +60 -13
  43. package/src/overlays/usage-dashboard.ts +47 -6
  44. package/src/prompt/composer-attachments.ts +54 -7
  45. package/src/prompt/composer-cache.ts +217 -196
  46. package/src/prompt/composer.ts +45 -72
  47. package/src/prompt/custom-editor.ts +24 -6
  48. package/src/prompt/model-mention-autocomplete.ts +22 -10
  49. package/src/prompt/welcome.ts +31 -28
  50. package/src/render/width-aware-text.ts +9 -0
  51. package/src/status-line/component.ts +286 -182
  52. package/src/status-line/metrics.ts +3 -21
  53. package/src/status-line/segments.ts +68 -79
  54. package/src/status-line/startup.ts +181 -0
  55. package/src/status-line/types.ts +0 -2
  56. package/src/terminal-capabilities.ts +12 -6
  57. package/src/terminal.ts +75 -20
  58. package/src/theme/color.ts +15 -0
  59. package/src/theme/session-color.ts +26 -4
  60. package/src/theme/shimmer.ts +55 -76
  61. package/src/theme/theme-class.ts +29 -12
  62. package/src/tools/output-meta.ts +11 -3
  63. package/src/tools/vibe.ts +15 -2
  64. package/src/tui.ts +208 -154
  65. package/src/utils.ts +15 -0
@@ -21,8 +21,9 @@
21
21
  * active column when a strip is open), Left/Right slide between branch
22
22
  * variants at a fork and jump between user turns elsewhere, `f` opens a
23
23
  * filter (typing narrows the current path to matching items; Esc leaves the
24
- * filter with the selection kept), Enter rewinds to the outlined item, Esc
25
- * cancels.
24
+ * filter with the selection kept), Enter rewinds to the outlined item, A loads
25
+ * earlier turns without changing selection (stepping above the oldest replayed
26
+ * turn loads them too), Esc cancels.
26
27
  */
27
28
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
28
29
  import {
@@ -36,7 +37,7 @@ import {
36
37
  truncateToWidth,
37
38
  } from "../index";
38
39
  import type { MessageRenderer } from "../chat/extension-types";
39
- import type { TranscriptEntryLike as TranscriptEntry } from "../chat/transcript-entry";
40
+ import { recentTranscriptEntries, type TranscriptEntryLike as TranscriptEntry } from "../chat/transcript-entry";
40
41
  import { theme } from "../theme/theme";
41
42
  import { matchesAppToolsExpand, matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
42
43
  import { ChatTranscriptBuilder } from "../chat/chat-transcript-builder";
@@ -116,6 +117,10 @@ export class RewindSelectorComponent implements Component {
116
117
  #slide: { from: number; to: number; startedAt: number } | undefined;
117
118
  #slideTimer: NodeJS.Timeout | undefined;
118
119
 
120
+ /** Whole branch; the selector may currently replay only its tail. */
121
+ #entries: TranscriptEntry[];
122
+ /** True while older history is still unreplayed. */
123
+ #truncated = false;
119
124
  /** Filter query while the filter prompt is open; undefined shows the full transcript. */
120
125
  #filter: string | undefined;
121
126
  /** Main transcript rows from the last frame; the filter matches what is on screen. */
@@ -127,8 +132,10 @@ export class RewindSelectorComponent implements Component {
127
132
  entries: TranscriptEntry[],
128
133
  private readonly deps: RewindSelectorDeps,
129
134
  ) {
130
- this.#builder = this.#newBuilder();
131
- this.#targets = appendOutlineEntries(this.#builder, entries);
135
+ this.#entries = entries;
136
+ const tail = recentTranscriptEntries(entries);
137
+ this.#truncated = tail.length < entries.length;
138
+ this.#builder = this.#replay(tail);
132
139
  this.#selected = Math.max(0, this.#targets.length - 1);
133
140
  this.#browser = new TranscriptBrowser({
134
141
  getHeight: () => this.deps.ui.terminal?.rows || process.stdout.rows || 40,
@@ -142,7 +149,7 @@ export class RewindSelectorComponent implements Component {
142
149
  }
143
150
 
144
151
  #newBuilder(): ChatTranscriptBuilder {
145
- return new ChatTranscriptBuilder({
152
+ const builder = new ChatTranscriptBuilder({
146
153
  ui: this.deps.ui,
147
154
  getTool: this.deps.getTool,
148
155
  isBuiltInTool: this.deps.isBuiltInTool,
@@ -153,6 +160,30 @@ export class RewindSelectorComponent implements Component {
153
160
  linkTargets: this.deps.linkTargets,
154
161
  requestRender: this.deps.requestRender,
155
162
  });
163
+ builder.setExpanded(this.#expanded);
164
+ return builder;
165
+ }
166
+
167
+ /** Build a transcript for `entries` and adopt its targets. */
168
+ #replay(entries: TranscriptEntry[]): ChatTranscriptBuilder {
169
+ const builder = this.#newBuilder();
170
+ this.#targets = appendOutlineEntries(builder, entries);
171
+ return builder;
172
+ }
173
+
174
+ /** Replay the whole branch, keeping the main outline on the same turn. */
175
+ #loadFullHistory(): void {
176
+ if (!this.#truncated) return;
177
+ const selectedId = this.#targets[this.#selected]?.turnId;
178
+ const previous = this.#builder;
179
+ this.#builder = this.#replay(this.#entries);
180
+ previous.dispose();
181
+ this.#truncated = false;
182
+ this.#mainVisible = undefined;
183
+ this.#mainRows = [];
184
+ const restored = selectedId ? this.#targets.findIndex(target => target.turnId === selectedId) : -1;
185
+ this.#selected = restored >= 0 ? restored : Math.max(0, this.#targets.length - 1);
186
+ this.deps.requestRender();
156
187
  }
157
188
 
158
189
  invalidate(): void {
@@ -186,7 +217,6 @@ export class RewindSelectorComponent implements Component {
186
217
  for (const sibling of this.deps.siblingPaths(target.turnId)) {
187
218
  if (sibling.entries.length === 0) continue;
188
219
  const builder = this.#newBuilder();
189
- builder.setExpanded(this.#expanded);
190
220
  const targets = appendOutlineEntries(builder, sibling.entries);
191
221
  const firstUser = sibling.entries.find(isUserTurnEntry);
192
222
  const label = (firstUser && userTurnLabel(firstUser)) || sibling.rootId;
@@ -257,6 +287,8 @@ export class RewindSelectorComponent implements Component {
257
287
  return;
258
288
  }
259
289
  if (matchesKey(data, "f")) {
290
+ // The filter must search the whole branch, not just the startup tail.
291
+ this.#loadFullHistory();
260
292
  this.#filter = "";
261
293
  this.#activeVariant = 0;
262
294
  this.#siblingSelected = 0;
@@ -291,6 +323,10 @@ export class RewindSelectorComponent implements Component {
291
323
  }
292
324
  return;
293
325
  }
326
+ if (data === "a" || data === "A") {
327
+ this.#loadFullHistory();
328
+ return;
329
+ }
294
330
  if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
295
331
  const target = this.#outlinedTarget();
296
332
  if (target) this.deps.onSelect(target.entryId);
@@ -384,15 +420,19 @@ export class RewindSelectorComponent implements Component {
384
420
 
385
421
  /**
386
422
  * Visible main-path target indices whose rendered text contains every
387
- * whitespace-separated query word as a whole word (case-insensitive: `ls`
388
- * matches `ls -la` but not `tools`); all visible targets for an empty query.
423
+ * whitespace-separated Latin query word as a whole word (case-insensitive:
424
+ * `ls` matches `ls -la` but not `tools`). Other scripts match substrings so
425
+ * a Chinese query can find text inside a sentence without spaces.
426
+ * All visible targets match an empty query.
389
427
  * Matching the rendered rows keeps results honest: collapsed tool output
390
428
  * only matches once Ctrl+O expands it.
391
429
  */
392
430
  #filterMatches(): number[] {
393
431
  const words = (this.#filter ?? "").toLowerCase().split(/\s+/).filter(Boolean);
394
- const patterns = words.map(
395
- word => new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u"),
432
+ const patterns = words.map(word =>
433
+ /^[\p{Script=Latin}\p{N}_]+$/u.test(word)
434
+ ? new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u")
435
+ : new RegExp(RegExp.escape(word), "u"),
396
436
  );
397
437
  const matches: number[] = [];
398
438
  for (let index = 0; index < this.#targets.length; index++) {
@@ -432,7 +472,10 @@ export class RewindSelectorComponent implements Component {
432
472
  this.#move(delta, () => true);
433
473
  }
434
474
 
435
- /** Step the main selection by `delta` to the nearest visible target passing `accept`. */
475
+ /**
476
+ * Step the main selection by `delta` to the nearest visible target passing
477
+ * `accept`; stepping above the replayed tail loads the earlier history first.
478
+ */
436
479
  #move(delta: -1 | 1, accept: (target: OutlineTarget) => boolean): void {
437
480
  let index = this.#selected + delta;
438
481
  while (index >= 0 && index < this.#targets.length) {
@@ -446,6 +489,10 @@ export class RewindSelectorComponent implements Component {
446
489
  }
447
490
  index += delta;
448
491
  }
492
+ if (delta < 0 && this.#truncated) {
493
+ this.#loadFullHistory();
494
+ this.#move(delta, accept);
495
+ }
449
496
  }
450
497
 
451
498
  #isMainSelectable(index: number): boolean {
@@ -492,7 +539,7 @@ export class RewindSelectorComponent implements Component {
492
539
  const upDown = editorKeys("tui.select.up", "tui.select.down");
493
540
  const leftRight = formatKeyHints(["left", "right"]);
494
541
  const lateral = columns.length > 0 ? `${leftRight} branches` : `${leftRight} user turns`;
495
- const keys = `${upDown} step ${lateral} ${formatKeyHint("f")} filter ${formatKeyHint("enter")} rewind ${expandKeyHint()} expand ${editorKey("tui.select.cancel")} cancel`;
542
+ const keys = `${upDown} step ${lateral} ${formatKeyHint("f")} filter ${formatKeyHint("enter")} rewind ${this.#truncated ? `${formatKeyHint("a")} earlier turns ` : ""}${expandKeyHint()} expand ${editorKey("tui.select.cancel")} cancel`;
496
543
  return {
497
544
  header: [this.#header()],
498
545
  body: {
@@ -67,12 +67,19 @@ export interface CardWindowRow {
67
67
  usedText?: string;
68
68
  }
69
69
 
70
+ /** A connected account whose usage lookup produced no attributable report. */
71
+ export interface UnavailableUsageAccount {
72
+ provider: string;
73
+ label: string;
74
+ }
75
+
70
76
  /** Compact per-provider summary backing one card in the subscriptions grid. */
71
77
  export interface ProviderCard {
72
78
  provider: string;
73
79
  name: string;
74
- /** Number of accounts reporting for this provider. */
80
+ /** Number of represented accounts, including unavailable usage lookups. */
75
81
  accounts: number;
82
+ unavailableAccounts: string[];
76
83
  /** Window rows sorted most-pressing first. */
77
84
  windows: CardWindowRow[];
78
85
  /** True when every account reports no limits (e.g. enterprise plans). */
@@ -104,6 +111,15 @@ function aggregateStatus(limits: readonly { status?: UsageLimit["status"] }[]):
104
111
  return "unknown";
105
112
  }
106
113
 
114
+ /**
115
+ * Card status when some connected accounts reported no usage: the missing
116
+ * report raises the card to a warning but never hides an exhausted quota.
117
+ */
118
+ function statusWithUnavailableAccounts(windows: readonly { status?: UsageLimit["status"] }[]): UsageLimit["status"] {
119
+ if (windows.length === 0) return "unknown";
120
+ return aggregateStatus(windows) === "exhausted" ? "exhausted" : "warning";
121
+ }
122
+
107
123
  /** Fraction below which a window counts as untouched (renders as 100% free). */
108
124
  const IDLE_FRACTION = 0.005;
109
125
  /**
@@ -129,7 +145,11 @@ function compactWindowTag(window: NonNullable<UsageLimit["window"]>): string {
129
145
  * most-used account's reset countdown. Cards sort most-pressing first so
130
146
  * what's burning is on top-left; fully idle providers collapse into a tick.
131
147
  */
132
- export function buildProviderCards(reports: UsageReport[], nowMs: number): ProviderCard[] {
148
+ export function buildProviderCards(
149
+ reports: UsageReport[],
150
+ nowMs: number,
151
+ unavailableAccounts: readonly UnavailableUsageAccount[] = [],
152
+ ): ProviderCard[] {
133
153
  const displayReports = collapseSharedUsageReports(reports);
134
154
  const grouped = new Map<string, UsageReport[]>();
135
155
  for (const report of displayReports) {
@@ -137,9 +157,15 @@ export function buildProviderCards(reports: UsageReport[], nowMs: number): Provi
137
157
  list.push(report);
138
158
  grouped.set(report.provider, list);
139
159
  }
160
+ for (const account of unavailableAccounts) {
161
+ if (!grouped.has(account.provider)) grouped.set(account.provider, []);
162
+ }
140
163
 
141
164
  const cards: ProviderCard[] = [];
142
165
  for (const [provider, providerReports] of grouped) {
166
+ const unavailable = unavailableAccounts
167
+ .filter(account => account.provider === provider)
168
+ .map(account => account.label);
143
169
  const buckets = new Map<string, { label: string; limits: UsageLimit[] }>();
144
170
  for (const report of providerReports) {
145
171
  for (const limit of report.limits) {
@@ -220,10 +246,12 @@ export function buildProviderCards(reports: UsageReport[], nowMs: number): Provi
220
246
  cards.push({
221
247
  provider,
222
248
  name: formatProviderName(provider),
223
- accounts: providerReports.length,
249
+ accounts: providerReports.length + unavailable.length,
250
+ unavailableAccounts: unavailable,
224
251
  windows,
225
- unlimited: windows.length === 0,
252
+ unlimited: windows.length === 0 && unavailable.length === 0,
226
253
  idle:
254
+ unavailable.length === 0 &&
227
255
  !resetCredits &&
228
256
  daybreakAccounts.length === 0 &&
229
257
  windows.every(window => window.fraction !== undefined && window.fraction < IDLE_FRACTION),
@@ -328,6 +356,7 @@ export function buildHeatmapLayout(points: DailyActivityPoint[], weeks: number,
328
356
  /** Callbacks and data sources for {@link UsageDashboardComponent}. */
329
357
  export interface UsageDashboardOptions {
330
358
  reports: UsageReport[];
359
+ unavailableAccounts?: readonly UnavailableUsageAccount[];
331
360
  /**
332
361
  * Full classic `/usage` report for the expanded detail view; re-invoked per
333
362
  * terminal width.
@@ -395,7 +424,7 @@ export class UsageDashboardComponent implements Component {
395
424
  ensureThemeSync();
396
425
  this.#options = options;
397
426
  this.#nowMs = Date.now();
398
- this.#cards = buildProviderCards(options.reports, this.#nowMs);
427
+ this.#cards = buildProviderCards(options.reports, this.#nowMs, options.unavailableAccounts);
399
428
  this.#panel = new OverlayPanel("Usage");
400
429
  this.#header = new PanelRows();
401
430
  this.#header.setHeight(1);
@@ -464,7 +493,12 @@ export class UsageDashboardComponent implements Component {
464
493
 
465
494
  #renderCardLines(card: ProviderCard, width: number, labels: string[][], layout: CardRowLayout): string[] {
466
495
  const lines: string[] = [];
467
- const cardStatus = card.unlimited ? "ok" : aggregateStatus(card.windows);
496
+ const cardStatus =
497
+ card.unavailableAccounts.length > 0
498
+ ? statusWithUnavailableAccounts(card.windows)
499
+ : card.unlimited
500
+ ? "ok"
501
+ : aggregateStatus(card.windows);
468
502
  const accountsText = card.accounts > 1 ? theme.fg("dim", `${card.accounts} accts`) : "";
469
503
  const titleBudget = width - 2 - visibleWidth(accountsText) - (accountsText ? 1 : 0);
470
504
  const title = theme.bold(truncateToWidth(card.name, Math.max(4, titleBudget)));
@@ -495,6 +529,13 @@ export class UsageDashboardComponent implements Component {
495
529
  }
496
530
  }
497
531
 
532
+ for (const account of card.unavailableAccounts) {
533
+ const text = sanitizeDisplayLine(`${account} — usage unavailable`);
534
+ for (const line of wrapTextWithAnsi(text, Math.max(1, width - 2))) {
535
+ lines.push(` ${theme.fg("dim", line)}`);
536
+ }
537
+ }
538
+
498
539
  if (card.unlimited) {
499
540
  lines.push(` ${theme.fg("dim", "no limits")}`);
500
541
  return lines;
@@ -50,9 +50,12 @@ const SKILL_ICONS = [...new Set(Object.values(SYMBOL_PRESETS).map(m => m["icon.e
50
50
  /** Skill names as they appear in chips: word characters and dashes, dots only between segments. */
51
51
  const SKILL_NAME_SOURCE = "[\\w-]+(?:\\.[\\w-]+)*";
52
52
 
53
- const SKILL_CHIP_SOURCE = `(?:${SKILL_ICONS.map(icon =>
54
- /^[a-z]+$/i.test(icon) ? `(?<![A-Za-z])${RegExp.escape(icon)}` : RegExp.escape(icon),
55
- ).join("|")}) (${SKILL_NAME_SOURCE})(?![\\w-])`;
53
+ /** Regex source for a chip glyph; word glyphs (ASCII preset) must not continue a preceding word. */
54
+ function glyphSource(icon: string): string {
55
+ return /^[a-z]+$/i.test(icon) ? `(?<![A-Za-z])${RegExp.escape(icon)}` : RegExp.escape(icon);
56
+ }
57
+
58
+ const SKILL_CHIP_SOURCE = `(?:${SKILL_ICONS.map(glyphSource).join("|")}) (${SKILL_NAME_SOURCE})(?![\\w-])`;
56
59
 
57
60
  /**
58
61
  * Replaces `/skill:<name>` tokens for known skills with compact chip labels and
@@ -96,11 +99,16 @@ export function collapseModelMentions(
96
99
 
97
100
  const CHIP_ICON_KEY = { image: "chip.image", video: "chip.video", paste: "chip.paste" } as const;
98
101
 
102
+ /** Chip glyph the active theme renders for `kind`, including `symbols.overrides`. */
103
+ function activeChipIcon(kind: ChipKind): string {
104
+ return typeof theme === "undefined"
105
+ ? SYMBOL_PRESETS.unicode[CHIP_ICON_KEY[kind]]
106
+ : theme.symbol(CHIP_ICON_KEY[kind]);
107
+ }
108
+
99
109
  /** Compact atomic composer token for attachment `n` in the active symbol preset. */
100
110
  export function chipLabel(kind: ChipKind, n: number): string {
101
- const icon =
102
- typeof theme === "undefined" ? SYMBOL_PRESETS.unicode[CHIP_ICON_KEY[kind]] : theme.symbol(CHIP_ICON_KEY[kind]);
103
- return `${icon} #${n}`;
111
+ return `${activeChipIcon(kind)} #${n}`;
104
112
  }
105
113
 
106
114
  /** Every glyph a chip token may start with, across all symbol presets. */
@@ -111,7 +119,7 @@ const CHIP_ICONS: Record<ChipKind, readonly string[]> = {
111
119
  };
112
120
 
113
121
  const CHIP_TOKEN_SOURCE = `(?:${[...CHIP_ICONS.image, ...CHIP_ICONS.video, ...CHIP_ICONS.paste]
114
- .map(icon => (/^[a-z]+$/i.test(icon) ? `(?<![A-Za-z])${RegExp.escape(icon)}` : RegExp.escape(icon)))
122
+ .map(glyphSource)
115
123
  .join("|")}) #[1-9]\\d*`;
116
124
 
117
125
  /** Infers an attachment kind from a chip label emitted by any configured symbol preset. */
@@ -155,6 +163,45 @@ export const COMPOSER_TOKEN_REGEX = new RegExp(
155
163
  "gu",
156
164
  );
157
165
 
166
+ /**
167
+ * Attachment indices referenced by a composer buffer, per kind. Image/video count compact chips
168
+ * and expanded markers; paste counts compact chips only, since `[Paste #N]` markers number the
169
+ * base editor's separate paste buffer. Registered labels take precedence over theme glyphs so
170
+ * tokens created before a theme switch keep their kind; full numbers prevent `#1` matching `#10`.
171
+ */
172
+ export function referencedAttachments(
173
+ text: string,
174
+ recorded: ReadonlyMap<string, ChipKind>,
175
+ ): Record<ChipKind, Set<number>> {
176
+ const kinds: readonly ChipKind[] = ["image", "video", "paste"];
177
+ const kindByIcon = new Map<string, ChipKind>();
178
+ for (const kind of kinds) for (const icon of CHIP_ICONS[kind]) kindByIcon.set(icon, kind);
179
+ for (const kind of kinds) kindByIcon.set(activeChipIcon(kind), kind);
180
+ const icons = [...kindByIcon.keys()].sort((a, b) => b.length - a.length).map(glyphSource);
181
+ const labels = [...recorded.keys()].sort((a, b) => b.length - a.length).map(label => RegExp.escape(label));
182
+ const recordedSource = labels.length > 0 ? labels.join("|") : "(?!)";
183
+ const scanner = new RegExp(
184
+ `${PLACEHOLDER_REGEX.source}|(${recordedSource})(?!\\d)|(${icons.join("|")}) #([1-9]\\d*)`,
185
+ "gu",
186
+ );
187
+
188
+ const refs: Record<ChipKind, Set<number>> = { image: new Set(), video: new Set(), paste: new Set() };
189
+ for (const match of text.matchAll(scanner)) {
190
+ if (match[1] === "Image") refs.image.add(Number(match[2]));
191
+ else if (match[1] === "Video") refs.video.add(Number(match[2]));
192
+ const label = match[3];
193
+ if (label !== undefined) {
194
+ const kind = recorded.get(label);
195
+ if (kind !== undefined) refs[kind].add(Number(label.slice(label.lastIndexOf("#") + 1)));
196
+ } else {
197
+ const icon = match[4];
198
+ const kind = icon === undefined ? undefined : kindByIcon.get(icon);
199
+ if (kind !== undefined) refs[kind].add(Number(match[5]));
200
+ }
201
+ }
202
+ return refs;
203
+ }
204
+
158
205
  /** Add the registered model-chip labels to the composer placeholder matcher. */
159
206
  export function composerTokenRegex(mentionLabels: Iterable<string>): RegExp {
160
207
  const labels = [...new Set(mentionLabels)].sort((a, b) => b.length - a.length);