@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
@@ -3,6 +3,7 @@ import {
3
3
  getCellDimensions,
4
4
  getImageDimensions,
5
5
  type ImageDimensions,
6
+ ImageProtocol,
6
7
  imageFallback,
7
8
  renderImage,
8
9
  TERMINAL,
@@ -84,7 +85,7 @@ interface SurfaceSplit {
84
85
  * id so a partial pass reproduces the on-screen live/text split without a
85
86
  * full, correctly-ordered walk.
86
87
  */
87
- suppressedIds: Set<number>;
88
+ readonly suppressedIds: Set<number>;
88
89
  }
89
90
 
90
91
  function newSurfaceSplit(): SurfaceSplit {
@@ -96,7 +97,7 @@ function resetSurfaceSplit(split: SurfaceSplit): void {
96
97
  split.onTerminal = 0;
97
98
  split.planned = 0;
98
99
  split.lastTotal = 0;
99
- split.suppressedIds = new Set();
100
+ if (split.suppressedIds.size > 0) split.suppressedIds.clear();
100
101
  }
101
102
 
102
103
  let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
@@ -283,8 +284,8 @@ export class ImageBudget {
283
284
  */
284
285
  beginPass(stable = false, altScreen = false): void {
285
286
  this.#passIds.length = 0;
286
- this.#passSuppression.clear();
287
- this.#passIndex.clear();
287
+ if (this.#passSuppression.size > 0) this.#passSuppression.clear();
288
+ if (this.#passIndex.size > 0) this.#passIndex.clear();
288
289
  this.#stablePass = stable;
289
290
  this.#surface = altScreen ? "alt" : "screen";
290
291
  this.#split = altScreen ? this.#altSplit : this.#screenSplit;
@@ -295,7 +296,7 @@ export class ImageBudget {
295
296
  // first. Note that leaving alt mode is not the same as unstacking a
296
297
  // fullscreen overlay: the flush must exclude one that is still stacked
297
298
  // from the pass itself, which is that caller's job, not this line's.
298
- if (!altScreen) this.#liveIds.alt.clear();
299
+ if (!altScreen && this.#liveIds.alt.size > 0) this.#liveIds.alt.clear();
299
300
  this.#applyingReset = !stable && this.#cap > 0 && this.#split.planned > this.#split.onTerminal;
300
301
  }
301
302
 
@@ -348,7 +349,10 @@ export class ImageBudget {
348
349
  // [0, onTerminal) is what this surface currently shows as text. Partial
349
350
  // passes replay this per id (see #stablePass) instead of re-deriving it
350
351
  // from a reversed, tail-only walk.
351
- split.suppressedIds = new Set(this.#passIds.slice(0, split.onTerminal));
352
+ const suppressedIds = split.suppressedIds;
353
+ if (suppressedIds.size > 0) suppressedIds.clear();
354
+ const suppressedCount = Math.min(total, split.onTerminal);
355
+ for (let i = 0; i < suppressedCount; i++) suppressedIds.add(this.#passIds[i]);
352
356
  return retry;
353
357
  }
354
358
 
@@ -361,7 +365,12 @@ export class ImageBudget {
361
365
  * the next pass on the *other* surface knows what it may not destroy.
362
366
  */
363
367
  limitResidentImages(): void {
364
- this.#liveIds[this.#surface] = new Set(this.#passIds.filter(id => this.#passShowsLive(id)));
368
+ const liveIds = this.#liveIds[this.#surface];
369
+ if (liveIds.size > 0) liveIds.clear();
370
+ for (let i = 0; i < this.#passIds.length; i++) {
371
+ const id = this.#passIds[i];
372
+ if (this.#passShowsLive(id)) liveIds.add(id);
373
+ }
365
374
  const transmitted = this.#transmitted[this.#surface];
366
375
  if (this.#cap <= 0 || transmitted.size <= this.#cap) return;
367
376
  for (const id of transmitted) {
@@ -765,6 +774,13 @@ export class Image implements Component {
765
774
  // toward (and are demoted by) the budget; without a protocol every image is
766
775
  // already text.
767
776
  const suppressed = hasProtocol && this.#budget !== undefined ? this.#budget.observe(this.#imageId ?? 0) : false;
777
+ // Only Kitty images with a budget id transmit their data separately from
778
+ // the placement; a pending re-transmit (after a purge or history clear)
779
+ // must rebuild the lines. SIXEL and iTerm2 carry the image inside the line
780
+ // itself and never register a transmit, so gating their cache on it would
781
+ // re-encode the full image on every render pass.
782
+ const imageId = this.#imageId;
783
+ const transmitsSeparately = imageProtocol === ImageProtocol.Kitty && imageId != null;
768
784
 
769
785
  if (
770
786
  this.#cachedLines &&
@@ -774,7 +790,7 @@ export class Image implements Component {
774
790
  this.#cachedCellWidthPx === cellDimensions.widthPx &&
775
791
  this.#cachedCellHeightPx === cellDimensions.heightPx &&
776
792
  this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders &&
777
- (this.#imageId == null || this.#budget?.shouldTransmit(this.#imageId) !== true)
793
+ (!transmitsSeparately || this.#budget?.shouldTransmit(imageId) !== true)
778
794
  ) {
779
795
  return this.#cachedLines;
780
796
  }
@@ -787,7 +803,7 @@ export class Image implements Component {
787
803
  if (hasProtocol && !suppressed) {
788
804
  // Transmit the data once (keyed by id); thereafter renderImage returns
789
805
  // just the placement, so repaints never re-send the base64.
790
- const needsTransmit = this.#imageId != null && (this.#budget?.shouldTransmit(this.#imageId) ?? false);
806
+ const needsTransmit = transmitsSeparately && (this.#budget?.shouldTransmit(imageId) ?? false);
791
807
  const result = renderImage(this.#base64Data, this.#dimensions, {
792
808
  maxWidthCells: maxWidth,
793
809
  maxHeightCells: this.#options.maxHeightCells,
@@ -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 {
@@ -81,7 +81,7 @@ interface SidebarEntry extends HubSidebarEntry<"all" | "source" | "new" | "separ
81
81
  type ListRow = { kind: "agent"; agent: HubAgent } | { kind: "new" };
82
82
 
83
83
  /** The per-agent knob a strip or the model browser is editing. */
84
- type PropertyKind = "model" | "prewalk" | "advisor";
84
+ export type PropertyKind = "model" | "prewalk" | "advisor";
85
85
 
86
86
  type StripChip = HubStripChip<
87
87
  | { kind: "toggle" }
@@ -110,8 +110,10 @@ export interface AgentsHubDeps {
110
110
  resolvePatterns: (patterns: string[]) => string | undefined;
111
111
  effectivePrewalkPattern: (agent: HubAgent) => string | undefined;
112
112
  effectiveAdvisorPattern: (agent: HubAgent) => string | undefined;
113
- setDisabledAgents: (names: string[]) => void;
114
- setOverrides: (property: PropertyKind, overrides: Record<string, string>) => void;
113
+ /** Persist one agent's enabled state; other agents are untouched. */
114
+ setAgentDisabled: (name: string, options: { disabled: boolean }) => void;
115
+ /** Persist one agent's override for `property`; `undefined` clears it. Other agents are untouched. */
116
+ setAgentOverride: (property: PropertyKind, name: string, value: string | undefined) => void;
115
117
  generateAgent: (description: string, onText: (text: string) => void) => Promise<string>;
116
118
  saveAgent: (scope: "project" | "user", spec: GeneratedAgentSpec) => Promise<string>;
117
119
  }
@@ -341,24 +343,11 @@ export class AgentsHubComponent implements Component {
341
343
 
342
344
  #toggleAgent(agent: HubAgent): void {
343
345
  agent.disabled = !agent.disabled;
344
- const disabled = this.#allAgents
345
- .filter(entry => entry.disabled)
346
- .map(entry => entry.name)
347
- .sort((a, b) => a.localeCompare(b));
348
- this.#deps.setDisabledAgents(disabled);
346
+ this.#deps.setAgentDisabled(agent.name, { disabled: agent.disabled });
349
347
  this.#notice = `${agent.name} ${agent.disabled ? "disabled" : "enabled"}`;
350
348
  this.#tui.requestRender();
351
349
  }
352
350
 
353
- #persistRecord(property: PropertyKind): void {
354
- const overrides: Record<string, string> = {};
355
- for (const agent of this.#allAgents) {
356
- const value = this.#overrideFor(agent, property)?.trim();
357
- if (value) overrides[agent.name] = value;
358
- }
359
- this.#deps.setOverrides(property, overrides);
360
- }
361
-
362
351
  #overrideFor(agent: HubAgent, property: PropertyKind): string | undefined {
363
352
  switch (property) {
364
353
  case "model":
@@ -383,7 +372,7 @@ export class AgentsHubComponent implements Component {
383
372
  agent.advisorOverride = trimmed;
384
373
  break;
385
374
  }
386
- this.#persistRecord(property);
375
+ this.#deps.setAgentOverride(property, agent.name, trimmed);
387
376
  this.#notice = this.#describeProperty(agent, property);
388
377
  this.#tui.requestRender();
389
378
  }
@@ -18,12 +18,12 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
18
18
  import { type Component, matchesKey, routeSgrMouseInput, type TUI, truncateToWidth, visibleWidth } from "../index";
19
19
  import type { MessageRenderer } from "../chat/extension-types";
20
20
  import {
21
- isUserRequestEntry,
21
+ recentTranscriptEntries,
22
+ type SessionMessageEntryLike as SessionMessageEntry,
22
23
  type TranscriptEntryLike as TranscriptEntry,
23
24
  transcriptEntryMessage,
24
25
  userTurnDraft,
25
26
  } from "../chat/transcript-entry";
26
- import type { SessionMessageEntryLike as SessionMessageEntry } from "../chat/transcript-entry";
27
27
  import { expandKeyHint, replaceTabs } from "../render/render-utils";
28
28
  import { formatKeyHint } from "../app-keybindings";
29
29
  import { editorKey, editorKeys } from "../chrome/keybinding-hints";
@@ -92,13 +92,6 @@ export interface CopyBlock {
92
92
  const BLOCK_PREVIEW_LINES = 12;
93
93
  /** The copy picker's outline stroke — green, distinct from the rewind selector's accent. */
94
94
  const OUTLINE_COLOR: ThemeColor = "success";
95
- /**
96
- * Entries replayed when the picker opens. Replaying a long session's whole
97
- * branch costs seconds before the first frame (one component built and
98
- * rendered per entry), and the clipboard target is almost always recent, so
99
- * the picker starts at this tail and loads the rest on demand (`a`).
100
- */
101
- const INITIAL_ENTRIES = 600;
102
95
 
103
96
  /** A clickable control on a block caption, in composed-column columns. */
104
97
  interface ControlRegion {
@@ -132,7 +125,7 @@ export class CopySelectorComponent implements Component {
132
125
  private readonly deps: CopySelectorDeps,
133
126
  ) {
134
127
  this.#entries = entries;
135
- const tail = recentEntries(entries, INITIAL_ENTRIES);
128
+ const tail = recentTranscriptEntries(entries);
136
129
  this.#truncated = tail.length < entries.length;
137
130
  this.#builder = this.#replay(tail);
138
131
  this.#selected = Math.max(0, this.#targets.length - 1);
@@ -324,6 +317,11 @@ export class CopySelectorComponent implements Component {
324
317
  }
325
318
  index += delta;
326
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
+ }
327
325
  }
328
326
 
329
327
  // ========================================================================
@@ -478,27 +476,6 @@ export class CopySelectorComponent implements Component {
478
476
  }
479
477
  }
480
478
 
481
- /**
482
- * The trailing slice starting at the last turn initiator at or before
483
- * `entries.length - limit`: a user message, or a custom message that starts
484
- * a user-attributed turn (a directly invoked `/skill:` prompt, a collab peer's
485
- * prompt), the same boundary `ChatTranscriptBuilder` uses.
486
- *
487
- * The cut has to land on a turn boundary: the builder drops a tool result
488
- * whose initiating call was sliced away, so a tail beginning mid-turn renders
489
- * without its command — and a tail of nothing but orphaned results would
490
- * leave the picker with no target at all. Scanning backwards keeps the whole
491
- * final turn instead, and a branch whose last turn is itself longer than
492
- * `limit` replays in full.
493
- */
494
- function recentEntries(entries: TranscriptEntry[], limit: number): TranscriptEntry[] {
495
- if (entries.length <= limit) return entries;
496
- for (let index = entries.length - limit; index > 0; index--) {
497
- if (isUserRequestEntry(entries[index]!)) return entries.slice(index);
498
- }
499
- return entries;
500
- }
501
-
502
479
  /** Raw multi-line text of a user message (string or text blocks). */
503
480
  function rawUserText(message: Extract<SessionMessageEntry["message"], { role: "user" }>): string {
504
481
  if (typeof message.content === "string") return message.content;
@@ -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 {
@@ -984,7 +984,11 @@ export class ModelHubComponent implements Component {
984
984
  );
985
985
  }
986
986
 
987
- /** Persist `role → item`, preserving a still-supported thinking level, then open the thinking strip. */
987
+ /**
988
+ * Persist `role → item`, preserving a still-supported thinking level, then
989
+ * open the thinking strip — skipped for models with no reasoning surface,
990
+ * where every chip would be a no-op ({@link #thinkingOptionsFor}).
991
+ */
988
992
  #assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean, scope?: ModelRoleSelectionScope): void {
989
993
  if (this.#settings.modelRoleStorage === "project" && scope === undefined) {
990
994
  this.#openScopeStrip(item, role, returnToRoles);
@@ -1003,6 +1007,13 @@ export class ModelHubComponent implements Component {
1003
1007
  const result = this.#callbacks.onAssign(item.model, role, level, item.selector, scope);
1004
1008
  this.#finishAssignment(result, () => {
1005
1009
  this.#refreshAfterMutation();
1010
+ if (supported.length === 0) {
1011
+ if (returnToRoles) {
1012
+ this.#setActiveEntry("roles");
1013
+ this.#focus = "list";
1014
+ }
1015
+ return;
1016
+ }
1006
1017
  this.#openThinkingStrip(item, role, returnToRoles, scope, level);
1007
1018
  });
1008
1019
  }
@@ -1019,10 +1030,39 @@ export class ModelHubComponent implements Component {
1019
1030
  this.#refreshAfterMutation();
1020
1031
  }
1021
1032
 
1033
+ /**
1034
+ * Thinking levels a role assignment can actually apply. A model that does
1035
+ * not reason gets none: no request path sends reasoning for it, and
1036
+ * `applyAutoThinkingLevel` early-returns on `!model.reasoning`, so
1037
+ * `inherit`/`off`/`auto` would all be no-ops. Reasoners without an effort
1038
+ * dial (`thinking: undefined`, e.g. `xai/grok-code-fast-1`) keep the three
1039
+ * always-on levels — their thinking is real, only the ladder is absent.
1040
+ */
1022
1041
  #thinkingOptionsFor(model: Model): ConfiguredThinkingLevel[] {
1042
+ if (!model.reasoning) return [];
1023
1043
  return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
1024
1044
  }
1025
1045
 
1046
+ /**
1047
+ * Resolve what `t` on a role row would edit: the role's model in its
1048
+ * persisted scope. Undefined when the role is unassigned or its model has
1049
+ * no thinking levels to offer, which makes both `t` and its footer hint
1050
+ * inert — the same rule wildcard fallback rows follow.
1051
+ */
1052
+ #roleThinkingTarget(role: string): { item: ModelBrowserItem; scope?: ModelRoleSelectionScope } | undefined {
1053
+ const assignment = this.#roles[role];
1054
+ if (!assignment) return undefined;
1055
+ const source =
1056
+ this.#settings.modelRoleStorage === "project" ? this.#settings.getModelRoleSource(role) : "default";
1057
+ const scope = source === "project" || source === "global" ? source : undefined;
1058
+ const model = scope ? this.#roleForScope(role, scope).model : assignment.model;
1059
+ if (!model || this.#thinkingOptionsFor(model).length === 0) return undefined;
1060
+ return {
1061
+ item: { provider: model.provider, id: model.id, model, selector: `${model.provider}/${model.id}` },
1062
+ scope,
1063
+ };
1064
+ }
1065
+
1026
1066
  /** Offer only the roles this model can actually fill (chat roles for chat models, `web` for search runners, …). */
1027
1067
  #openRoleStrip(item: ModelBrowserItem): void {
1028
1068
  const chips: StripChip[] = [];
@@ -1821,20 +1861,9 @@ export class ModelHubComponent implements Component {
1821
1861
  return;
1822
1862
  }
1823
1863
  if (printable === "t") {
1824
- const assignment = role ? this.#roles[role] : undefined;
1825
- if (role && assignment) {
1826
- const source =
1827
- this.#settings.modelRoleStorage === "project" ? this.#settings.getModelRoleSource(role) : "default";
1828
- const scope = source === "project" || source === "global" ? source : undefined;
1829
- const scopedModel = scope ? this.#roleForScope(role, scope).model : assignment.model;
1830
- if (!scopedModel) return;
1831
- const item: ModelBrowserItem = {
1832
- provider: scopedModel.provider,
1833
- id: scopedModel.id,
1834
- model: scopedModel,
1835
- selector: `${scopedModel.provider}/${scopedModel.id}`,
1836
- };
1837
- this.#openThinkingStrip(item, role, true, scope);
1864
+ if (role) {
1865
+ const target = this.#roleThinkingTarget(role);
1866
+ if (target) this.#openThinkingStrip(target.item, role, true, target.scope);
1838
1867
  } else if (row?.kind === "fallback") {
1839
1868
  this.#openFallbackThinkingStrip(row);
1840
1869
  }
@@ -2338,7 +2367,11 @@ export class ModelHubComponent implements Component {
2338
2367
  if (row?.kind === "newFallback") {
2339
2368
  return `${upDown} rows · ${enter} new model/provider fallback chain · ${left} providers`;
2340
2369
  }
2341
- return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear · ${formatKeyHint("t")} thinking · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new`;
2370
+ // Same rule as fallback rows: advertise `t` only where a strip would
2371
+ // open — an assigned role whose model has thinking levels to offer.
2372
+ const editable = row?.kind === "role" && this.#roleThinkingTarget(row.role) !== undefined;
2373
+ const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
2374
+ return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new`;
2342
2375
  }
2343
2376
  if (entry.kind === "provider" && entry.locked) {
2344
2377
  return entry.oauth