@oh-my-pi/pi-tui 18.4.1 → 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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/chrome/transcript-container.d.ts +15 -1
  3. package/dist/types/overlays/model-browser.d.ts +15 -0
  4. package/dist/types/overlays/rewind-selector.d.ts +2 -1
  5. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  6. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  7. package/dist/types/render/width-aware-text.d.ts +6 -0
  8. package/dist/types/terminal.d.ts +7 -0
  9. package/dist/types/theme/color.d.ts +1 -0
  10. package/dist/types/theme/session-color.d.ts +3 -3
  11. package/dist/types/theme/shimmer.d.ts +1 -1
  12. package/dist/types/theme/theme-class.d.ts +1 -1
  13. package/dist/types/tui.d.ts +3 -2
  14. package/package.json +9 -9
  15. package/src/chat/tool-execution.ts +24 -4
  16. package/src/chrome/transcript-container.ts +76 -14
  17. package/src/components/editor.ts +28 -6
  18. package/src/components/image.ts +15 -7
  19. package/src/components/loader.ts +37 -17
  20. package/src/overlays/agent-hub.ts +16 -6
  21. package/src/overlays/copy-selector.ts +5 -0
  22. package/src/overlays/model-browser.ts +106 -25
  23. package/src/overlays/rewind-selector.ts +10 -2
  24. package/src/prompt/composer-attachments.ts +54 -7
  25. package/src/prompt/composer.ts +5 -2
  26. package/src/prompt/custom-editor.ts +24 -6
  27. package/src/prompt/model-mention-autocomplete.ts +22 -10
  28. package/src/render/width-aware-text.ts +9 -0
  29. package/src/status-line/component.ts +213 -132
  30. package/src/status-line/segments.ts +34 -29
  31. package/src/terminal.ts +37 -11
  32. package/src/theme/color.ts +15 -0
  33. package/src/theme/session-color.ts +26 -4
  34. package/src/theme/shimmer.ts +55 -76
  35. package/src/theme/theme-class.ts +29 -12
  36. package/src/tui.ts +142 -112
  37. package/src/utils.ts +15 -0
@@ -26,7 +26,14 @@ export class Loader extends Text {
26
26
  #ui: TUI | null = null;
27
27
  #lastSpinnerTick = 0;
28
28
  #layoutSource?: readonly string[];
29
- #layout?: readonly { leading: string; content: string; trailing: string }[];
29
+ #layout?: readonly {
30
+ leading: string;
31
+ message: string;
32
+ trailing: string;
33
+ spinner: boolean;
34
+ separator: string;
35
+ bodyWidth?: number;
36
+ }[];
30
37
  #layoutFrames: readonly string[];
31
38
  #layoutFrame: string;
32
39
  #trailer?: () => string | undefined;
@@ -72,14 +79,28 @@ export class Loader extends Text {
72
79
  if (source !== this.#layoutSource) {
73
80
  const paddingX = getPaddingX(1);
74
81
  this.#layoutSource = source;
75
- this.#layout = source.map(line => {
82
+ this.#layout = source.map((line, i) => {
76
83
  const clamped = visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line;
77
84
  const body = clamped.slice(paddingX);
78
85
  const content = body.trimEnd();
86
+ const leading = clamped.slice(0, paddingX);
87
+ const spinner = i === 0 && content.startsWith(this.#layoutFrame);
88
+ const remainder = spinner ? content.slice(this.#layoutFrame.length) : content;
89
+ const separator = spinner && remainder.startsWith(" ") ? " " : "";
90
+ const message = remainder.slice(separator.length);
91
+ const plainMessage = i === 0 ? Bun.stripANSI(message) : "";
92
+ // A visible non-whitespace ending makes trimEnd independent of
93
+ // colorizer ANSI placement. The separator isolates spinner width
94
+ // from message graphemes; other cases retain whole-body measuring.
95
+ const stableBodyWidth =
96
+ plainMessage.length > 0 && plainMessage.trimEnd() === plainMessage && (!spinner || separator === " ");
79
97
  return {
80
- leading: clamped.slice(0, paddingX),
81
- content,
98
+ leading,
99
+ message,
82
100
  trailing: body.slice(content.length),
101
+ spinner,
102
+ separator,
103
+ bodyWidth: stableBodyWidth ? visibleWidth(leading + separator + message) : undefined,
83
104
  };
84
105
  });
85
106
  }
@@ -88,28 +109,27 @@ export class Loader extends Text {
88
109
  // The wrapped text carries one stable representative per frame width.
89
110
  // Same-width frames swap only the visible glyph here; crossing widths
90
111
  // rewraps against the representative selected by #syncText.
91
- const sentinel = this.#layoutFrame;
92
112
  const lines = [""];
93
113
  const layout = this.#layout ?? [];
94
- for (let i = 0; i < layout.length; i++) {
95
- const { leading, content, trailing } = layout[i];
96
- if (i === 0 && content.startsWith(sentinel)) {
97
- const remainder = content.slice(sentinel.length);
98
- const separator = remainder.startsWith(" ") ? " " : "";
99
- const message = remainder.slice(separator.length);
100
- lines.push(
101
- `${leading}${this.spinnerColorFn(frame)}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
102
- );
103
- } else {
104
- lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
114
+ let coloredSpinner = "";
115
+ for (const { leading, message, trailing, spinner, separator } of layout) {
116
+ if (spinner) {
117
+ coloredSpinner = this.spinnerColorFn(frame);
105
118
  }
119
+ lines.push(
120
+ `${leading}${spinner ? coloredSpinner : ""}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
121
+ );
106
122
  }
107
123
  if (this.#trailer && lines.length > 1) {
108
124
  const trailer = this.#trailer();
109
125
  if (trailer) {
110
126
  // Text pads rows to full width; drop that pad before docking right.
111
127
  const body = lines[1].trimEnd();
112
- const gap = width - visibleWidth(body) - visibleWidth(trailer);
128
+ const bodyWidth =
129
+ layout[0].bodyWidth === undefined
130
+ ? visibleWidth(body)
131
+ : layout[0].bodyWidth + visibleWidth(coloredSpinner);
132
+ const gap = width - bodyWidth - visibleWidth(trailer);
113
133
  if (gap >= 2) lines[1] = body + padding(gap) + trailer;
114
134
  }
115
135
  }
@@ -102,13 +102,23 @@ function activityGlyph(row: AgentActivityRow): string {
102
102
  }
103
103
  }
104
104
 
105
+ const ACTIVITY_CLOCK_FORMAT = new Intl.DateTimeFormat(undefined, {
106
+ hour: "2-digit",
107
+ minute: "2-digit",
108
+ second: "2-digit",
109
+ hour12: false,
110
+ });
111
+ const ACTIVITY_CLOCK_CACHE_LIMIT = 512;
112
+ const activityClockCache = new Map<number, string>();
113
+
105
114
  function activityClock(timestamp: number): string {
106
- return new Date(timestamp).toLocaleTimeString(undefined, {
107
- hour: "2-digit",
108
- minute: "2-digit",
109
- second: "2-digit",
110
- hour12: false,
111
- });
115
+ let text = activityClockCache.get(timestamp);
116
+ if (text === undefined) {
117
+ text = ACTIVITY_CLOCK_FORMAT.format(timestamp);
118
+ if (activityClockCache.size >= ACTIVITY_CLOCK_CACHE_LIMIT) activityClockCache.clear();
119
+ activityClockCache.set(timestamp, text);
120
+ }
121
+ return text;
112
122
  }
113
123
  /** Result of one host-backed transcript read for the Agent Hub viewer. */
114
124
  export interface AgentHubRemoteTranscript {
@@ -317,6 +317,11 @@ export class CopySelectorComponent implements Component {
317
317
  }
318
318
  index += delta;
319
319
  }
320
+ // Stepping above the replayed tail continues into the earlier history.
321
+ if (delta < 0 && this.#truncated) {
322
+ this.#loadFullHistory();
323
+ this.#moveVertical(delta);
324
+ }
320
325
  }
321
326
 
322
327
  // ========================================================================
@@ -113,6 +113,11 @@ export interface ResolvedModelRoleValue {
113
113
 
114
114
  /** Host-provided preferences and model-role resolution for the browser. */
115
115
  export interface ModelBrowserSource extends ModelRoleLookup {
116
+ /**
117
+ * Changes whenever any preference this source reads or resolves against changes,
118
+ * except the storage-backed `mruOrder` and `modelPerf`. Keys derived-scope caches.
119
+ */
120
+ readonly revision: number;
116
121
  readonly defaultThinkingLevel: string;
117
122
  readonly modelProviderOrder: readonly string[];
118
123
  readonly knownRoleIds: readonly string[];
@@ -175,16 +180,32 @@ export function resolveRoleAssignments(
175
180
  return ThinkingLevel.Inherit;
176
181
  };
177
182
 
183
+ // Roles sharing an `accepts` predicate share one filtered array, so the
184
+ // resolver's array-keyed indexes are built once per pool, not once per role.
185
+ const eligible = (
186
+ pool: ReadonlyArray<Model>,
187
+ byAccepts: Map<ModelBrowserRoleInfo["accepts"], Model[]>,
188
+ role: string,
189
+ ): Model[] => {
190
+ const accepts = settings.getRoleInfo(role).accepts;
191
+ let models = byAccepts.get(accepts);
192
+ if (!models) {
193
+ models = pool.filter(accepts);
194
+ byAccepts.set(accepts, models);
195
+ }
196
+ return models;
197
+ };
198
+
178
199
  const roles: RoleAssignments = {};
179
200
  const knownRoles = settings.knownRoleIds;
180
201
  const configuredRoles = new Set<string>();
181
- const catalog = [...allModels];
202
+ const catalogByAccepts = new Map<ModelBrowserRoleInfo["accepts"], Model[]>();
182
203
 
183
204
  for (const role of knownRoles) {
184
205
  const roleValue = settings.getModelRole(role);
185
206
  if (!roleValue) continue;
186
207
  configuredRoles.add(role);
187
- const resolved = settings.resolveRoleValue(roleValue, catalog.filter(settings.getRoleInfo(role).accepts));
208
+ const resolved = settings.resolveRoleValue(roleValue, eligible(allModels, catalogByAccepts, role));
188
209
  if (resolved.model) {
189
210
  roles[role] = {
190
211
  model: resolved.model,
@@ -195,13 +216,10 @@ export function resolveRoleAssignments(
195
216
  }
196
217
 
197
218
  if (autoCandidates.length > 0) {
198
- const candidates = [...autoCandidates];
219
+ const candidatesByAccepts = new Map<ModelBrowserRoleInfo["accepts"], Model[]>();
199
220
  for (const role of knownRoles) {
200
221
  if (configuredRoles.has(role)) continue;
201
- const resolved = settings.resolveRoleValue(
202
- `pi/${role}`,
203
- candidates.filter(settings.getRoleInfo(role).accepts),
204
- );
222
+ const resolved = settings.resolveRoleValue(`pi/${role}`, eligible(autoCandidates, candidatesByAccepts, role));
205
223
  if (!resolved.model) continue;
206
224
  roles[role] = {
207
225
  model: resolved.model,
@@ -332,32 +350,95 @@ export interface SessionModelScope {
332
350
  error: string | undefined;
333
351
  }
334
352
 
353
+ /** Catalog inputs a {@link SessionModelScope} is derived from. */
354
+ interface SessionModelScopeInputs {
355
+ models: ReadonlyArray<Model>;
356
+ allModels: ReadonlyArray<Model>;
357
+ error: string | undefined;
358
+ }
359
+
360
+ function readSessionModelScopeInputs(
361
+ registry: ModelBrowserRegistry,
362
+ scopedModels: ReadonlyArray<Model>,
363
+ ): SessionModelScopeInputs {
364
+ if (scopedModels.length > 0) return { models: scopedModels, allModels: scopedModels, error: undefined };
365
+ const loadError = registry.getError();
366
+ let error = loadError ? String(loadError) : undefined;
367
+ let models: ReadonlyArray<Model>;
368
+ try {
369
+ models = registry.getAvailable();
370
+ } catch (cause) {
371
+ error = cause instanceof Error ? cause.message : String(cause);
372
+ models = [];
373
+ }
374
+ return { models, allModels: registry.getAll("all"), error };
375
+ }
376
+
377
+ function scopeFromInputs(settings: ModelBrowserSource, inputs: SessionModelScopeInputs): SessionModelScope {
378
+ const roles = resolveRoleAssignments(settings, inputs.allModels, inputs.models);
379
+ const mruOrder = settings.mruOrder;
380
+ const items = buildBrowserItems(inputs.models);
381
+ sortModelItems(items, { roles, mruOrder });
382
+ return { items, roles, mruOrder, error: inputs.error };
383
+ }
384
+
335
385
  /** Build the session picker's current scope without creating an interactive browser. */
336
386
  export function buildSessionModelScope(
337
387
  settings: ModelBrowserSource,
338
388
  registry: ModelBrowserRegistry,
339
389
  scopedModels: ReadonlyArray<Model>,
340
390
  ): SessionModelScope {
341
- let models: ReadonlyArray<Model>;
342
- let error: string | undefined;
343
- if (scopedModels.length > 0) {
344
- models = scopedModels;
345
- } else {
346
- const loadError = registry.getError();
347
- error = loadError ? String(loadError) : undefined;
348
- try {
349
- models = registry.getAvailable();
350
- } catch (cause) {
351
- error = cause instanceof Error ? cause.message : String(cause);
352
- models = [];
391
+ return scopeFromInputs(settings, readSessionModelScopeInputs(registry, scopedModels));
392
+ }
393
+
394
+ function sameEntries<T>(a: ReadonlyArray<T>, b: ReadonlyArray<T>): boolean {
395
+ if (a === b) return true;
396
+ if (a.length !== b.length) return false;
397
+ for (let i = 0; i < a.length; i++) {
398
+ if (a[i] !== b[i]) return false;
399
+ }
400
+ return true;
401
+ }
402
+
403
+ /**
404
+ * {@link buildSessionModelScope} for per-keystroke callers: returns the same
405
+ * scope until the source revision, MRU order, scoped models, or the registry's
406
+ * available models, catalog, or load error change.
407
+ */
408
+ export class SessionModelScopeCache {
409
+ #settings: ModelBrowserSource;
410
+ #registry: ModelBrowserRegistry;
411
+ #revision = 0;
412
+ #inputs: SessionModelScopeInputs | undefined;
413
+ #scope: SessionModelScope | undefined;
414
+
415
+ constructor(settings: ModelBrowserSource, registry: ModelBrowserRegistry) {
416
+ this.#settings = settings;
417
+ this.#registry = registry;
418
+ }
419
+
420
+ get(scopedModels: ReadonlyArray<Model>): SessionModelScope {
421
+ const revision = this.#settings.revision;
422
+ const inputs = readSessionModelScopeInputs(this.#registry, scopedModels);
423
+ const cachedInputs = this.#inputs;
424
+ const cached = this.#scope;
425
+ if (
426
+ cached &&
427
+ cachedInputs &&
428
+ revision === this.#revision &&
429
+ inputs.error === cachedInputs.error &&
430
+ sameEntries(inputs.models, cachedInputs.models) &&
431
+ sameEntries(inputs.allModels, cachedInputs.allModels) &&
432
+ sameEntries(this.#settings.mruOrder, cached.mruOrder)
433
+ ) {
434
+ return cached;
353
435
  }
436
+ const scope = scopeFromInputs(this.#settings, inputs);
437
+ this.#revision = revision;
438
+ this.#inputs = inputs;
439
+ this.#scope = scope;
440
+ return scope;
354
441
  }
355
- const allModels = scopedModels.length > 0 ? models : registry.getAll("all");
356
- const roles = resolveRoleAssignments(settings, allModels, models);
357
- const mruOrder = settings.mruOrder;
358
- const items = buildBrowserItems(models);
359
- sortModelItems(items, { roles, mruOrder });
360
- return { items, roles, mruOrder, error };
361
442
  }
362
443
 
363
444
  interface RoleProviderStats {
@@ -22,7 +22,8 @@
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
24
  * filter with the selection kept), Enter rewinds to the outlined item, A loads
25
- * earlier turns without changing selection, Esc cancels.
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 {
@@ -471,7 +472,10 @@ export class RewindSelectorComponent implements Component {
471
472
  this.#move(delta, () => true);
472
473
  }
473
474
 
474
- /** 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
+ */
475
479
  #move(delta: -1 | 1, accept: (target: OutlineTarget) => boolean): void {
476
480
  let index = this.#selected + delta;
477
481
  while (index >= 0 && index < this.#targets.length) {
@@ -485,6 +489,10 @@ export class RewindSelectorComponent implements Component {
485
489
  }
486
490
  index += delta;
487
491
  }
492
+ if (delta < 0 && this.#truncated) {
493
+ this.#loadFullHistory();
494
+ this.#move(delta, accept);
495
+ }
488
496
  }
489
497
 
490
498
  #isMainSelectable(index: number): boolean {
@@ -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);
@@ -369,12 +369,15 @@ export class Composer implements TerminalFrameProvider {
369
369
  // editor drifts up above a band of blank rows (#11007).
370
370
  this.#transientChromeFloor = Math.min(this.#transientChromeFloor ?? transientRows, transientRows);
371
371
  const belowFloor = after.length - transientRows + this.#transientChromeFloor;
372
+ const now = performance.now();
373
+ const frame: AnimationFrame = { now, tick: Math.floor(now / 80) };
374
+ // Retirement measures the same live blocks the viewport lays out below;
375
+ // one open frame renders each of them once for both.
376
+ transcript.beginFrame(frame);
372
377
  const history = this.#offerHistory(transcript, width, rows, preRoots.length + belowFloor);
373
378
  const headerVisible = !this.#headerRetired && this.#offeredHistory?.source !== "header";
374
379
  const headerRows = headerVisible ? this.#header.render(width) : [];
375
380
  const before = [...headerRows, ...preRoots];
376
- const now = performance.now();
377
- const frame: AnimationFrame = { now, tick: Math.floor(now / 80) };
378
381
  // The live tail is laid out against the same baseline retirement is
379
382
  // billed against, so its compaction allocator (one row per block, no
380
383
  // inter-block blanks) engages only when a block genuinely cannot retire.
@@ -14,6 +14,7 @@ import { imageAttachmentSource } from "./image-source";
14
14
  import { isVideoPath } from "./video";
15
15
  import {
16
16
  attachmentSgr,
17
+ type ChipKind,
17
18
  COMPOSER_TOKEN_REGEX,
18
19
  chipLabel,
19
20
  collapseImageMarkers,
@@ -21,6 +22,8 @@ import {
21
22
  collapseSkillTokens,
22
23
  composerTokenRegex,
23
24
  modelChipStyle,
25
+ PLACEHOLDER_REGEX,
26
+ referencedAttachments,
24
27
  renderPlaceholders,
25
28
  skillChipLabel,
26
29
  skillChipStyle,
@@ -662,14 +665,29 @@ export class CustomEditor extends Editor {
662
665
  ) {
663
666
  return cached.chips;
664
667
  }
665
- const text = this.getText();
668
+ const recorded = new Map<string, ChipKind>();
669
+ if (this.pendingImages.length > 0) {
670
+ for (const [label, expansion] of this.atoms) {
671
+ const kind = expansion.startsWith("[Image #")
672
+ ? "image"
673
+ : expansion.startsWith("[Video #")
674
+ ? "video"
675
+ : undefined;
676
+ if (kind !== undefined && expansion.match(PLACEHOLDER_REGEX)?.[0] === expansion) {
677
+ recorded.set(label, kind);
678
+ }
679
+ }
680
+ }
681
+ for (const entry of this.pendingTexts) {
682
+ // A reused label belongs to the atom currently expanding it, not a deleted paste.
683
+ if (!recorded.has(entry.label)) recorded.set(entry.label, "paste");
684
+ }
685
+ const refs = referencedAttachments(this.getText(), recorded);
666
686
  const chips: ComposerChipDescriptor[] = [];
667
687
  for (let i = 0; i < this.pendingImages.length; i++) {
668
688
  const n = i + 1;
669
- const video =
670
- text.includes(chipLabel("video", n)) || text.includes(`[Video #${n}]`) || text.includes(`[Video #${n},`);
671
- const image =
672
- text.includes(chipLabel("image", n)) || text.includes(`[Image #${n}]`) || text.includes(`[Image #${n},`);
689
+ const video = refs.video.has(n);
690
+ const image = refs.image.has(n);
673
691
  if (!video && !image) continue;
674
692
  chips.push({
675
693
  kind: video ? "video" : "image",
@@ -679,7 +697,7 @@ export class CustomEditor extends Editor {
679
697
  });
680
698
  }
681
699
  for (const entry of this.pendingTexts) {
682
- if (!text.includes(entry.label)) continue;
700
+ if (!refs.paste.has(entry.n)) continue;
683
701
  chips.push({ kind: "paste", n: entry.n, text: entry });
684
702
  }
685
703
  this.#composerChipsCache = {
@@ -4,9 +4,11 @@ import type { ModelBrowserRegistry, ModelBrowserSource } from "../overlays/model
4
4
  import { modelMentionDisplayName } from "./model-mention-syntax";
5
5
  import type {
6
6
  buildSearchAffinity as BuildSearchAffinity,
7
- buildSessionModelScope as BuildSessionModelScope,
8
7
  ModelBrowserItem,
9
8
  rankModelItems as RankModelItems,
9
+ SearchAffinity,
10
+ SessionModelScope,
11
+ SessionModelScopeCache as SessionModelScopeCacheClass,
10
12
  } from "../overlays/model-browser";
11
13
  import { theme } from "../theme/theme";
12
14
 
@@ -17,7 +19,7 @@ const MAX_MODEL_MENTION_SUGGESTIONS = 20;
17
19
  export type ModelMentionCandidateSource = (query: string) => ReadonlyArray<ModelBrowserItem>;
18
20
 
19
21
  interface ModelBrowserModules {
20
- buildSessionModelScope: typeof BuildSessionModelScope;
22
+ SessionModelScopeCache: typeof SessionModelScopeCacheClass;
21
23
  buildSearchAffinity: typeof BuildSearchAffinity;
22
24
  rankModelItems: typeof RankModelItems;
23
25
  }
@@ -75,20 +77,30 @@ export function applyModelMentionCompletion(
75
77
  };
76
78
  }
77
79
 
78
- /** Create a fresh session-scoped model candidate lookup using picker ordering. */
80
+ /**
81
+ * Create a session-scoped model candidate lookup using picker ordering. The
82
+ * scope and search affinity are reused across queries until their inputs change.
83
+ */
79
84
  export function createModelMentionSource(host: {
80
85
  source: ModelBrowserSource;
81
86
  registry: ModelBrowserRegistry;
82
87
  scopedModels: () => ReadonlyArray<Model>;
83
88
  }): ModelMentionCandidateSource {
89
+ let scopeCache: SessionModelScopeCacheClass | undefined;
90
+ let affinityScope: SessionModelScope | undefined;
91
+ let affinityProviderOrder: readonly string[] | undefined;
92
+ let affinity: SearchAffinity | undefined;
84
93
  return query => {
85
- const { buildSearchAffinity, buildSessionModelScope, rankModelItems } = loadModelBrowser();
86
- const scope = buildSessionModelScope(host.source, host.registry, host.scopedModels());
94
+ const { SessionModelScopeCache, buildSearchAffinity, rankModelItems } = loadModelBrowser();
95
+ scopeCache ??= new SessionModelScopeCache(host.source, host.registry);
96
+ const scope = scopeCache.get(host.scopedModels());
87
97
  if (!query.trim()) return scope.items;
88
- return rankModelItems(query, scope.items, {
89
- roles: scope.roles,
90
- mruOrder: scope.mruOrder,
91
- affinity: buildSearchAffinity(host.source.modelProviderOrder, scope.roles, scope.mruOrder),
92
- });
98
+ const providerOrder = host.source.modelProviderOrder;
99
+ if (!affinity || affinityScope !== scope || affinityProviderOrder !== providerOrder) {
100
+ affinity = buildSearchAffinity(providerOrder, scope.roles, scope.mruOrder);
101
+ affinityScope = scope;
102
+ affinityProviderOrder = providerOrder;
103
+ }
104
+ return rankModelItems(query, scope.items, { roles: scope.roles, mruOrder: scope.mruOrder, affinity });
93
105
  };
94
106
  }
@@ -47,6 +47,15 @@ export class WidthAwareText implements Component {
47
47
  this.#inner.invalidate();
48
48
  }
49
49
 
50
+ /**
51
+ * Re-run the formatter on the next render because its inputs changed.
52
+ * Unlike {@link invalidate}, the inner `Text` keeps its wrap cache, so a
53
+ * reformat that yields the same string skips re-wrapping and re-tinting.
54
+ */
55
+ reformat(): void {
56
+ this.#cachedText = undefined;
57
+ }
58
+
50
59
  render(width: number): readonly string[] {
51
60
  const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
52
61
  const contentWidth = Math.max(1, width - paddingX * 2);