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

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 (42) hide show
  1. package/CHANGELOG.md +28 -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 +9 -2
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/rewind-selector.d.ts +3 -3
  7. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  8. package/dist/types/prompt/composer-cache.d.ts +34 -16
  9. package/dist/types/prompt/composer.d.ts +15 -18
  10. package/dist/types/prompt/welcome.d.ts +5 -2
  11. package/dist/types/status-line/component.d.ts +13 -4
  12. package/dist/types/status-line/metrics.d.ts +0 -1
  13. package/dist/types/status-line/startup.d.ts +38 -0
  14. package/dist/types/status-line/types.d.ts +0 -2
  15. package/dist/types/terminal-capabilities.d.ts +10 -6
  16. package/dist/types/tools/output-meta.d.ts +2 -1
  17. package/dist/types/tools/vibe.d.ts +4 -0
  18. package/package.json +9 -9
  19. package/src/chat/assistant-message.ts +27 -21
  20. package/src/chat/image-loading.ts +81 -0
  21. package/src/chat/tool-execution.ts +29 -13
  22. package/src/chat/transcript-entry.ts +16 -0
  23. package/src/chrome/transcript-container.ts +208 -74
  24. package/src/components/image.ts +10 -2
  25. package/src/overlays/agents-hub.ts +7 -18
  26. package/src/overlays/copy-selector.ts +3 -31
  27. package/src/overlays/model-hub.ts +49 -16
  28. package/src/overlays/rewind-selector.ts +51 -12
  29. package/src/overlays/usage-dashboard.ts +47 -6
  30. package/src/prompt/composer-cache.ts +217 -196
  31. package/src/prompt/composer.ts +40 -70
  32. package/src/prompt/welcome.ts +31 -28
  33. package/src/status-line/component.ts +73 -50
  34. package/src/status-line/metrics.ts +3 -21
  35. package/src/status-line/segments.ts +34 -50
  36. package/src/status-line/startup.ts +181 -0
  37. package/src/status-line/types.ts +0 -2
  38. package/src/terminal-capabilities.ts +12 -6
  39. package/src/terminal.ts +38 -9
  40. package/src/tools/output-meta.ts +11 -3
  41. package/src/tools/vibe.ts +15 -2
  42. package/src/tui.ts +66 -42
@@ -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,
@@ -765,6 +766,13 @@ export class Image implements Component {
765
766
  // toward (and are demoted by) the budget; without a protocol every image is
766
767
  // already text.
767
768
  const suppressed = hasProtocol && this.#budget !== undefined ? this.#budget.observe(this.#imageId ?? 0) : false;
769
+ // Only Kitty images with a budget id transmit their data separately from
770
+ // the placement; a pending re-transmit (after a purge or history clear)
771
+ // must rebuild the lines. SIXEL and iTerm2 carry the image inside the line
772
+ // itself and never register a transmit, so gating their cache on it would
773
+ // re-encode the full image on every render pass.
774
+ const imageId = this.#imageId;
775
+ const transmitsSeparately = imageProtocol === ImageProtocol.Kitty && imageId != null;
768
776
 
769
777
  if (
770
778
  this.#cachedLines &&
@@ -774,7 +782,7 @@ export class Image implements Component {
774
782
  this.#cachedCellWidthPx === cellDimensions.widthPx &&
775
783
  this.#cachedCellHeightPx === cellDimensions.heightPx &&
776
784
  this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders &&
777
- (this.#imageId == null || this.#budget?.shouldTransmit(this.#imageId) !== true)
785
+ (!transmitsSeparately || this.#budget?.shouldTransmit(imageId) !== true)
778
786
  ) {
779
787
  return this.#cachedLines;
780
788
  }
@@ -787,7 +795,7 @@ export class Image implements Component {
787
795
  if (hasProtocol && !suppressed) {
788
796
  // Transmit the data once (keyed by id); thereafter renderImage returns
789
797
  // just the placement, so repaints never re-send the base64.
790
- const needsTransmit = this.#imageId != null && (this.#budget?.shouldTransmit(this.#imageId) ?? false);
798
+ const needsTransmit = transmitsSeparately && (this.#budget?.shouldTransmit(imageId) ?? false);
791
799
  const result = renderImage(this.#base64Data, this.#dimensions, {
792
800
  maxWidthCells: maxWidth,
793
801
  maxHeightCells: this.#options.maxHeightCells,
@@ -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);
@@ -478,27 +471,6 @@ export class CopySelectorComponent implements Component {
478
471
  }
479
472
  }
480
473
 
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
474
  /** Raw multi-line text of a user message (string or text blocks). */
503
475
  function rawUserText(message: Extract<SessionMessageEntry["message"], { role: "user" }>): string {
504
476
  if (typeof message.content === "string") return message.content;
@@ -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
@@ -21,8 +21,8 @@
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, Esc cancels.
26
26
  */
27
27
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
28
28
  import {
@@ -36,7 +36,7 @@ import {
36
36
  truncateToWidth,
37
37
  } from "../index";
38
38
  import type { MessageRenderer } from "../chat/extension-types";
39
- import type { TranscriptEntryLike as TranscriptEntry } from "../chat/transcript-entry";
39
+ import { recentTranscriptEntries, type TranscriptEntryLike as TranscriptEntry } from "../chat/transcript-entry";
40
40
  import { theme } from "../theme/theme";
41
41
  import { matchesAppToolsExpand, matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
42
42
  import { ChatTranscriptBuilder } from "../chat/chat-transcript-builder";
@@ -116,6 +116,10 @@ export class RewindSelectorComponent implements Component {
116
116
  #slide: { from: number; to: number; startedAt: number } | undefined;
117
117
  #slideTimer: NodeJS.Timeout | undefined;
118
118
 
119
+ /** Whole branch; the selector may currently replay only its tail. */
120
+ #entries: TranscriptEntry[];
121
+ /** True while older history is still unreplayed. */
122
+ #truncated = false;
119
123
  /** Filter query while the filter prompt is open; undefined shows the full transcript. */
120
124
  #filter: string | undefined;
121
125
  /** Main transcript rows from the last frame; the filter matches what is on screen. */
@@ -127,8 +131,10 @@ export class RewindSelectorComponent implements Component {
127
131
  entries: TranscriptEntry[],
128
132
  private readonly deps: RewindSelectorDeps,
129
133
  ) {
130
- this.#builder = this.#newBuilder();
131
- this.#targets = appendOutlineEntries(this.#builder, entries);
134
+ this.#entries = entries;
135
+ const tail = recentTranscriptEntries(entries);
136
+ this.#truncated = tail.length < entries.length;
137
+ this.#builder = this.#replay(tail);
132
138
  this.#selected = Math.max(0, this.#targets.length - 1);
133
139
  this.#browser = new TranscriptBrowser({
134
140
  getHeight: () => this.deps.ui.terminal?.rows || process.stdout.rows || 40,
@@ -142,7 +148,7 @@ export class RewindSelectorComponent implements Component {
142
148
  }
143
149
 
144
150
  #newBuilder(): ChatTranscriptBuilder {
145
- return new ChatTranscriptBuilder({
151
+ const builder = new ChatTranscriptBuilder({
146
152
  ui: this.deps.ui,
147
153
  getTool: this.deps.getTool,
148
154
  isBuiltInTool: this.deps.isBuiltInTool,
@@ -153,6 +159,30 @@ export class RewindSelectorComponent implements Component {
153
159
  linkTargets: this.deps.linkTargets,
154
160
  requestRender: this.deps.requestRender,
155
161
  });
162
+ builder.setExpanded(this.#expanded);
163
+ return builder;
164
+ }
165
+
166
+ /** Build a transcript for `entries` and adopt its targets. */
167
+ #replay(entries: TranscriptEntry[]): ChatTranscriptBuilder {
168
+ const builder = this.#newBuilder();
169
+ this.#targets = appendOutlineEntries(builder, entries);
170
+ return builder;
171
+ }
172
+
173
+ /** Replay the whole branch, keeping the main outline on the same turn. */
174
+ #loadFullHistory(): void {
175
+ if (!this.#truncated) return;
176
+ const selectedId = this.#targets[this.#selected]?.turnId;
177
+ const previous = this.#builder;
178
+ this.#builder = this.#replay(this.#entries);
179
+ previous.dispose();
180
+ this.#truncated = false;
181
+ this.#mainVisible = undefined;
182
+ this.#mainRows = [];
183
+ const restored = selectedId ? this.#targets.findIndex(target => target.turnId === selectedId) : -1;
184
+ this.#selected = restored >= 0 ? restored : Math.max(0, this.#targets.length - 1);
185
+ this.deps.requestRender();
156
186
  }
157
187
 
158
188
  invalidate(): void {
@@ -186,7 +216,6 @@ export class RewindSelectorComponent implements Component {
186
216
  for (const sibling of this.deps.siblingPaths(target.turnId)) {
187
217
  if (sibling.entries.length === 0) continue;
188
218
  const builder = this.#newBuilder();
189
- builder.setExpanded(this.#expanded);
190
219
  const targets = appendOutlineEntries(builder, sibling.entries);
191
220
  const firstUser = sibling.entries.find(isUserTurnEntry);
192
221
  const label = (firstUser && userTurnLabel(firstUser)) || sibling.rootId;
@@ -257,6 +286,8 @@ export class RewindSelectorComponent implements Component {
257
286
  return;
258
287
  }
259
288
  if (matchesKey(data, "f")) {
289
+ // The filter must search the whole branch, not just the startup tail.
290
+ this.#loadFullHistory();
260
291
  this.#filter = "";
261
292
  this.#activeVariant = 0;
262
293
  this.#siblingSelected = 0;
@@ -291,6 +322,10 @@ export class RewindSelectorComponent implements Component {
291
322
  }
292
323
  return;
293
324
  }
325
+ if (data === "a" || data === "A") {
326
+ this.#loadFullHistory();
327
+ return;
328
+ }
294
329
  if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
295
330
  const target = this.#outlinedTarget();
296
331
  if (target) this.deps.onSelect(target.entryId);
@@ -384,15 +419,19 @@ export class RewindSelectorComponent implements Component {
384
419
 
385
420
  /**
386
421
  * 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.
422
+ * whitespace-separated Latin query word as a whole word (case-insensitive:
423
+ * `ls` matches `ls -la` but not `tools`). Other scripts match substrings so
424
+ * a Chinese query can find text inside a sentence without spaces.
425
+ * All visible targets match an empty query.
389
426
  * Matching the rendered rows keeps results honest: collapsed tool output
390
427
  * only matches once Ctrl+O expands it.
391
428
  */
392
429
  #filterMatches(): number[] {
393
430
  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"),
431
+ const patterns = words.map(word =>
432
+ /^[\p{Script=Latin}\p{N}_]+$/u.test(word)
433
+ ? new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u")
434
+ : new RegExp(RegExp.escape(word), "u"),
396
435
  );
397
436
  const matches: number[] = [];
398
437
  for (let index = 0; index < this.#targets.length; index++) {
@@ -492,7 +531,7 @@ export class RewindSelectorComponent implements Component {
492
531
  const upDown = editorKeys("tui.select.up", "tui.select.down");
493
532
  const leftRight = formatKeyHints(["left", "right"]);
494
533
  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`;
534
+ 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
535
  return {
497
536
  header: [this.#header()],
498
537
  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;