@oh-my-pi/pi-tui 18.8.7 → 18.8.8

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 (40) hide show
  1. package/CHANGELOG.md +28 -64
  2. package/dist/types/chat/execution-shared.d.ts +6 -5
  3. package/dist/types/keybinding-matchers.d.ts +3 -0
  4. package/dist/types/overlays/annotation-overlay.d.ts +12 -0
  5. package/dist/types/overlays/annotation-types.d.ts +2 -0
  6. package/dist/types/overlays/hook-selector.d.ts +9 -4
  7. package/dist/types/overlays/model-browser.d.ts +2 -2
  8. package/dist/types/overlays/model-hub.d.ts +21 -4
  9. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  10. package/dist/types/overlays/usage-dashboard.d.ts +3 -1
  11. package/dist/types/status-line/types.d.ts +1 -1
  12. package/dist/types/theme/symbols.d.ts +1 -1
  13. package/dist/types/tools/eval.d.ts +1 -1
  14. package/dist/types/tools/native-view.d.ts +47 -0
  15. package/dist/types/tools/renderer.d.ts +15 -2
  16. package/dist/types/tui.d.ts +20 -0
  17. package/dist/types/vim.d.ts +19 -5
  18. package/package.json +9 -9
  19. package/src/chat/execution-shared.ts +42 -25
  20. package/src/chat/tool-execution.ts +9 -2
  21. package/src/components/editor.ts +182 -5
  22. package/src/keybinding-matchers.ts +6 -2
  23. package/src/overlays/agent-hub.ts +7 -3
  24. package/src/overlays/annotation-overlay.ts +87 -6
  25. package/src/overlays/annotation-types.ts +2 -0
  26. package/src/overlays/hook-selector.ts +12 -5
  27. package/src/overlays/model-browser.ts +3 -3
  28. package/src/overlays/model-hub.ts +107 -27
  29. package/src/overlays/session-observer-registry.ts +10 -2
  30. package/src/overlays/usage-dashboard.ts +70 -23
  31. package/src/prompt/composer.ts +1 -0
  32. package/src/status-line/segments.ts +3 -0
  33. package/src/status-line/types.ts +1 -1
  34. package/src/theme/symbols.ts +5 -0
  35. package/src/tools/bash.ts +82 -31
  36. package/src/tools/eval.ts +149 -123
  37. package/src/tools/native-view.ts +102 -1
  38. package/src/tools/renderer.ts +15 -2
  39. package/src/tui.ts +68 -0
  40. package/src/vim.ts +333 -18
@@ -126,8 +126,8 @@ export interface ResolvedModelRoleValue {
126
126
  export interface ModelCompactionPoint {
127
127
  /** Context tokens that trigger auto-compaction; undefined when auto-compaction is off or the window is unknown. */
128
128
  tokens: number | undefined;
129
- /** The configured percentage of the window, when the trigger is percent-based. */
130
- percent?: number;
129
+ /** Why it triggers there, in a few words: `fixed`, `85% of 400K base`, `80% of window`. */
130
+ basis?: string;
131
131
  /** What sets it: the matching `compaction.modelThresholds` key, `global`, or `default`. */
132
132
  source: string;
133
133
  /** The model's own exact entry as editable text (`90k`, `80%`); absent when it has none. */
@@ -2174,7 +2174,7 @@ export class ModelBrowser implements Component {
2174
2174
  const value =
2175
2175
  compaction.tokens === undefined
2176
2176
  ? "off"
2177
- : `${compaction.tokens.toLocaleString("en-US")}${compaction.percent !== undefined ? ` · ${compaction.percent}%` : ""}`;
2177
+ : `${compaction.tokens.toLocaleString("en-US")}${compaction.basis !== undefined ? ` · ${compaction.basis}` : ""}`;
2178
2178
  facts.push({
2179
2179
  k: [span("Compacts at", "muted")],
2180
2180
  v: [span(value, "mono"), span(` · ${compaction.source}`, "dim")],
@@ -48,7 +48,7 @@ import type {
48
48
  ResolvedModelRoleValue,
49
49
  } from "./model-browser";
50
50
  import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../thinking";
51
- import { thinkingLevelGlyph } from "../render/render-utils";
51
+ import { sanitizeDisplayWarning, thinkingLevelGlyph } from "../render/render-utils";
52
52
  import { theme } from "../theme/theme";
53
53
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
54
54
  import {
@@ -171,14 +171,30 @@ export interface ModelHubCallbacks {
171
171
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
172
172
  onCycleOrderChange?: (order: string[]) => void;
173
173
  /**
174
- * Persist typed text as `model`'s own compaction point: a token count (`90000`,
175
- * `90k`, `1M`), a percentage (`80%`), or empty to reset. Returns an error
176
- * message for input it rejects, which keeps the field open.
174
+ * Persist typed text as `model`'s own compaction limit: a token base the
175
+ * policy scales (`400k`), a fixed trigger (`f400k`), a percentage (`80%`), or
176
+ * empty to reset. Rejected input returns `{ kind: "error" }`; input that
177
+ * needs acknowledgement (one that opens the extended window) returns
178
+ * `{ kind: "confirm" }` until called again with the same text and
179
+ * `confirmed`. Either keeps the field open.
177
180
  */
178
- onCompactionPointChange?: (model: Model, input: string) => string | undefined;
181
+ onCompactionPointChange?: (
182
+ model: Model,
183
+ input: string,
184
+ confirmed: boolean,
185
+ ) => CompactionPointChangeResult | undefined;
186
+ /**
187
+ * One short line on where `model` would compact with the typed limit
188
+ * (`compacts at 340K · 85% of 400K base`), shown beside the field while
189
+ * typing; undefined for input that does not parse or fit.
190
+ */
191
+ previewCompactionPoint?: (model: Model, input: string) => string | undefined;
179
192
  onCancel: () => void;
180
193
  }
181
194
 
195
+ /** Why {@link ModelHubCallbacks.onCompactionPointChange} kept the field open. */
196
+ export type CompactionPointChangeResult = { kind: "error"; message: string } | { kind: "confirm"; message: string };
197
+
182
198
  export interface ModelHubOptions {
183
199
  /** Preselect this provider's sidebar entry (e.g. when reopening after /login). */
184
200
  initialProviderId?: string;
@@ -232,12 +248,18 @@ type StripState =
232
248
  input: Input;
233
249
  }
234
250
  | {
235
- /** Footer text input setting `model`'s compaction point; `error` is the last rejection. */
251
+ /**
252
+ * Footer text input setting `model`'s compaction limit; `error` is the last
253
+ * rejection, `confirm` the warning a second Enter on the same `value`
254
+ * accepts, `preview` the host's live line for the typed `value`.
255
+ */
236
256
  kind: "name";
237
257
  purpose: "compaction";
238
258
  model: Model;
239
259
  input: Input;
240
260
  error?: string;
261
+ confirm?: { value: string; message: string };
262
+ preview?: { value: string; text: string | undefined };
241
263
  };
242
264
 
243
265
  /** A Roles-view command; keys and the picker's action bar both run {@link ModelHubComponent}'s `#runRolesAction`. */
@@ -302,8 +324,41 @@ function providerInitials(providerId: string): string {
302
324
 
303
325
  const PROVIDER_REFRESH_DEBOUNCE_MS = 120;
304
326
  const RECENT_LIMIT = 15;
305
- /** Accepted compaction point input, shown beside the field. */
306
- const COMPACTION_INPUT_HINT = "90000 · 90k · 1m · 80% · empty resets";
327
+ /** Accepted compaction limit input, shown beside the field: a scaled base, a fixed trigger, or a percentage. */
328
+ const COMPACTION_INPUT_HINT = "400k base · f400k fixed · 80% · empty resets";
329
+
330
+ type CompactionStrip = Extract<StripState, { purpose: "compaction" }>;
331
+
332
+ /** Whether the compaction field still holds the text its pending warning was raised for. */
333
+ function compactionConfirmPending(strip: CompactionStrip): boolean {
334
+ return strip.confirm !== undefined && strip.confirm.value === strip.input.getValue();
335
+ }
336
+
337
+ /**
338
+ * The note beside the compaction field: the last error, a pending warning, the
339
+ * host's live preview of the typed limit, or (empty or unparsed input) the
340
+ * input syntax. Host text embeds model ids and config text, so it is
341
+ * sanitized for every render path (terminal footer, Tern picker and strip).
342
+ */
343
+ function compactionNotice(
344
+ strip: CompactionStrip,
345
+ preview: ((model: Model, input: string) => string | undefined) | undefined,
346
+ ): { text: string; style: "error" | "warning" | "dim" } {
347
+ if (strip.error) return { text: sanitizeDisplayWarning(strip.error), style: "error" };
348
+ // The footer hint and the primary action already say Enter accepts; the
349
+ // notice stays short so it fits beside the field.
350
+ if (strip.confirm && compactionConfirmPending(strip)) {
351
+ return { text: sanitizeDisplayWarning(strip.confirm.message), style: "warning" };
352
+ }
353
+ const value = strip.input.getValue();
354
+ if (preview && value.trim().length > 0) {
355
+ // Renders repeat per frame; ask the host once per typed value.
356
+ if (strip.preview?.value !== value) strip.preview = { value, text: preview(strip.model, value) };
357
+ if (strip.preview.text) return { text: sanitizeDisplayWarning(strip.preview.text), style: "dim" };
358
+ }
359
+ return { text: COMPACTION_INPUT_HINT, style: "dim" };
360
+ }
361
+
307
362
  const MODEL_KIND_TABS: ReadonlyArray<"all" | ModelKind> = ["all", ...MODEL_KINDS];
308
363
  const ROLE_TABS = ["all", "chat", "kind"] as const;
309
364
  type RoleTab = (typeof ROLE_TABS)[number];
@@ -1800,9 +1855,16 @@ export class ModelHubComponent implements Component {
1800
1855
  const strip = this.#strip;
1801
1856
  if (strip?.kind !== "name") return;
1802
1857
  if (strip.purpose === "compaction") {
1803
- const error = this.#callbacks.onCompactionPointChange?.(strip.model, strip.input.getValue());
1804
- if (error !== undefined) {
1805
- strip.error = error;
1858
+ const value = strip.input.getValue();
1859
+ const result = this.#callbacks.onCompactionPointChange?.(strip.model, value, compactionConfirmPending(strip));
1860
+ if (result?.kind === "error") {
1861
+ strip.error = result.message;
1862
+ strip.confirm = undefined;
1863
+ return;
1864
+ }
1865
+ if (result?.kind === "confirm") {
1866
+ strip.error = undefined;
1867
+ strip.confirm = { value, message: result.message };
1806
1868
  return;
1807
1869
  }
1808
1870
  this.#strip = null;
@@ -2721,7 +2783,9 @@ export class ModelHubComponent implements Component {
2721
2783
  const strip = this.#strip;
2722
2784
  if (strip) {
2723
2785
  if (strip.kind === "name") {
2724
- if (strip.purpose === "compaction") return `${enter} set compaction point · ${cancel} cancel`;
2786
+ if (strip.purpose === "compaction") {
2787
+ return `${enter} ${compactionConfirmPending(strip) ? "accept" : "set"} compaction point · ${cancel} cancel`;
2788
+ }
2725
2789
  if (strip.purpose === "preset") {
2726
2790
  return `${enter} save preset · ${cancel} cancel`;
2727
2791
  }
@@ -2808,19 +2872,23 @@ export class ModelHubComponent implements Component {
2808
2872
  if (strip.kind === "name") {
2809
2873
  const labelText =
2810
2874
  strip.purpose === "compaction"
2811
- ? `Compact ${strip.model.id} at:`
2875
+ ? `${strip.model.id} limit:`
2812
2876
  : strip.purpose === "preset"
2813
2877
  ? "Preset name:"
2814
2878
  : "New role name:";
2815
2879
  const label = theme.fg("accent", labelText);
2816
- const inputWidth = Math.max(8, Math.min(32, width - visibleWidth(labelText) - 24));
2880
+ // A compaction point is at most a few characters (`1500k`, `12.5%`); a
2881
+ // narrow field leaves the row for its notice.
2882
+ const maxInputWidth = strip.purpose === "compaction" ? 10 : 32;
2883
+ const inputWidth = Math.max(8, Math.min(maxInputWidth, width - visibleWidth(labelText) - 24));
2817
2884
  const inputLine = strip.input.render(inputWidth)[0] ?? "";
2818
- const hint =
2819
- strip.purpose !== "compaction"
2820
- ? theme.fg("dim", "(letters, digits, - and _)")
2821
- : strip.error
2822
- ? theme.fg("error", strip.error)
2823
- : theme.fg("dim", COMPACTION_INPUT_HINT);
2885
+ let hint: string;
2886
+ if (strip.purpose === "compaction") {
2887
+ const notice = compactionNotice(strip, this.#callbacks.previewCompactionPoint);
2888
+ hint = theme.fg(notice.style, notice.text);
2889
+ } else {
2890
+ hint = theme.fg("dim", "(letters, digits, - and _)");
2891
+ }
2824
2892
  return truncateToWidth(`${label} ${inputLine} ${hint}`, width);
2825
2893
  }
2826
2894
 
@@ -3475,7 +3543,12 @@ export class ModelHubComponent implements Component {
3475
3543
  const apply =
3476
3544
  strip.kind === "name"
3477
3545
  ? strip.purpose === "compaction"
3478
- ? pickerAction("compactionPoint", "Set compaction point", "enter", { primary: true })
3546
+ ? pickerAction(
3547
+ "compactionPoint",
3548
+ compactionConfirmPending(strip) ? "Accept compaction point" : "Set compaction point",
3549
+ "enter",
3550
+ { primary: true },
3551
+ )
3479
3552
  : pickerAction(
3480
3553
  strip.purpose === "preset" ? "presetName" : "roleName",
3481
3554
  strip.purpose === "preset" ? "Save preset" : "Create role",
@@ -3579,14 +3652,14 @@ export class ModelHubComponent implements Component {
3579
3652
  #pickerStrip(strip: StripState): NonNullable<TspPickerProps["strip"]> {
3580
3653
  if (strip.kind === "name") {
3581
3654
  if (strip.purpose === "compaction") {
3655
+ const notice = compactionNotice(strip, this.#callbacks.previewCompactionPoint);
3582
3656
  return {
3583
3657
  label: [
3584
- span("Compact ", "muted"),
3585
3658
  span(strip.model.id, "mono"),
3586
- span(" at ", "muted"),
3659
+ span(" limit ", "muted"),
3587
3660
  span(strip.input.getValue(), "mono"),
3588
3661
  span("▏", "accent"),
3589
- span(` ${strip.error ?? COMPACTION_INPUT_HINT}`, strip.error ? "error" : "dim"),
3662
+ span(` ${notice.text}`, notice.style),
3590
3663
  ],
3591
3664
  items: [],
3592
3665
  };
@@ -3965,13 +4038,14 @@ export class ModelHubComponent implements Component {
3965
4038
  if (!strip) return undefined;
3966
4039
  if (strip.kind === "name") {
3967
4040
  if (strip.purpose === "compaction") {
4041
+ const notice = compactionNotice(strip, this.#callbacks.previewCompactionPoint);
3968
4042
  return node(
3969
4043
  "row",
3970
4044
  { gap: "sm", align: "center" },
3971
4045
  [
3972
- text([span(`Compact ${strip.model.id} at:`, "accent")]),
4046
+ text([span(`${strip.model.id} limit:`, "accent")]),
3973
4047
  col([strip.input], { grow: 1 }),
3974
- text([span(strip.error ?? COMPACTION_INPUT_HINT, strip.error ? "error" : "dim")]),
4048
+ text([span(notice.text, notice.style)], { wrap: "word" }),
3975
4049
  ],
3976
4050
  "compactionPoint",
3977
4051
  );
@@ -4030,7 +4104,13 @@ export class ModelHubComponent implements Component {
4030
4104
  switch (strip.kind) {
4031
4105
  case "name":
4032
4106
  return strip.purpose === "compaction"
4033
- ? [keys("set compaction point", "enter"), cancel("cancel")]
4107
+ ? [
4108
+ keys(
4109
+ compactionConfirmPending(strip) ? "accept compaction point" : "set compaction point",
4110
+ "enter",
4111
+ ),
4112
+ cancel("cancel"),
4113
+ ]
4034
4114
  : strip.purpose === "preset"
4035
4115
  ? [keys("save preset", "enter"), cancel("cancel")]
4036
4116
  : [keys("create + pick model", "enter"), cancel("cancel")];
@@ -64,6 +64,8 @@ export interface ObservableSession {
64
64
  lastUpdate: number;
65
65
  /** Latest progress snapshot from the subagent executor */
66
66
  progress?: AgentProgress;
67
+ /** `Date.now()` when {@link progress} arrived; its `durationMs` was current then. */
68
+ progressAt?: number;
67
69
  }
68
70
 
69
71
  /** Coarse source of an observer change; callers use it to separate lifecycle work from high-frequency progress. */
@@ -225,6 +227,9 @@ export class SessionObserverRegistry {
225
227
  existing.detached = payload.detached ?? existing.detached;
226
228
  if (payload.description) existing.description = payload.description;
227
229
  if (payload.sessionFile) existing.sessionFile = payload.sessionFile;
230
+ // A kept-alive or parked agent's next turn restarts its clock; the prior turn's
231
+ // snapshot must not be aged across the idle gap before the new turn's first frame.
232
+ if (payload.status === "started") existing.progressAt = undefined;
228
233
  } else {
229
234
  this.#sessions.set(payload.id, {
230
235
  id: payload.id,
@@ -256,12 +261,14 @@ export class SessionObserverRegistry {
256
261
 
257
262
  const sortOrder = this.#ensureSortOrder(id);
258
263
  this.#ensureParentSortOrder(payload.parentToolCallId, sortOrder);
264
+ const now = Date.now();
259
265
  if (existing) {
260
- existing.lastUpdate = Date.now();
266
+ existing.lastUpdate = now;
261
267
  existing.index = payload.index;
262
268
  existing.parentToolCallId = payload.parentToolCallId ?? existing.parentToolCallId;
263
269
  existing.detached = payload.detached ?? existing.detached;
264
270
  existing.progress = progress;
271
+ existing.progressAt = now;
265
272
  if (progress.description) existing.description = progress.description;
266
273
  if (payload.sessionFile) existing.sessionFile = payload.sessionFile;
267
274
  } else {
@@ -276,8 +283,9 @@ export class SessionObserverRegistry {
276
283
  parentToolCallId: payload.parentToolCallId,
277
284
  detached: payload.detached,
278
285
  index: payload.index,
279
- lastUpdate: Date.now(),
286
+ lastUpdate: now,
280
287
  progress,
288
+ progressAt: now,
281
289
  });
282
290
  }
283
291
  this.#notifyListeners("progress");
@@ -86,8 +86,10 @@ export interface ProviderCard {
86
86
  unavailableAccounts: string[];
87
87
  /** Window rows in provider-declared order (e.g. 5h → weekly → monthly); the fullest CARD_MAX_WINDOWS lead. */
88
88
  windows: CardWindowRow[];
89
- /** True when every account reports no limits (e.g. enterprise plans). */
89
+ /** True when every account reports no limits (e.g. enterprise plans, providers without a quota API). */
90
90
  unlimited: boolean;
91
+ /** Distinct report notes across accounts (e.g. why a provider exposes no quota windows). */
92
+ notes: string[];
91
93
  /** True when nothing is used anywhere (or there are no limits): collapses to a tick. */
92
94
  idle: boolean;
93
95
  resetCredits?: {
@@ -262,6 +264,7 @@ export function buildProviderCards(
262
264
  unavailableAccounts: unavailable,
263
265
  windows,
264
266
  unlimited: windows.length === 0 && unavailable.length === 0,
267
+ notes: [...new Set(providerReports.flatMap(report => report.notes ?? []))],
265
268
  idle:
266
269
  unavailable.length === 0 &&
267
270
  !resetCredits &&
@@ -451,6 +454,25 @@ function mutedText(content: string, wrap = false): NativeNode {
451
454
  return text([span(content, "muted")], wrap ? { wrap: "word" } : { truncate: "end" });
452
455
  }
453
456
 
457
+ /**
458
+ * A quiet line under the provider grid naming the providers that get no
459
+ * frame (`Untouched: Kimi Code, Zai`); `title` says why on hover.
460
+ */
461
+ function footnote(key: string, label: string, cards: readonly ProviderCard[], title?: string): NativeNode {
462
+ const names = cards.map(card => card.name).join(", ");
463
+ return node(
464
+ "text",
465
+ {
466
+ spans: [span(`${label}: `, "dim"), span(names)],
467
+ wrap: "word",
468
+ role: "omp.usage.footnote",
469
+ ...(title ? { title: sanitizeDisplayLine(title) } : {}),
470
+ },
471
+ undefined,
472
+ key,
473
+ );
474
+ }
475
+
454
476
  /** Stable, human account name for a report in the detail view. */
455
477
  function reportAccountLabel(report: UsageReport, limit: UsageLimit | undefined, index: number): string {
456
478
  const metadata = report.metadata;
@@ -1020,36 +1042,59 @@ export class UsageDashboardComponent implements Component {
1020
1042
  const children: NativeChild[] = [];
1021
1043
  if (this.#cards.length === 0) {
1022
1044
  children.push(
1023
- node("text", { spans: [span("No usage data available.")], role: "omp.usage.untouched" }, undefined, "none"),
1045
+ node("text", { spans: [span("No usage data available.")], role: "omp.usage.footnote" }, undefined, "none"),
1024
1046
  );
1025
1047
  } else {
1026
- // Unlimited providers keep a frame reading "No limits"; only untouched ones collapse.
1027
- const active = this.#cards.filter(entry => !entry.idle || entry.unlimited);
1028
- const idle = this.#cards.filter(entry => entry.idle && !entry.unlimited);
1029
- if (active.length > 0) {
1048
+ // Only providers with something to show get a frame: the rest are
1049
+ // named in footnotes, whether untouched, unreadable or quota-less.
1050
+ const framed: ProviderCard[] = [];
1051
+ const untouched: ProviderCard[] = [];
1052
+ const unreported: ProviderCard[] = [];
1053
+ const unmetered: ProviderCard[] = [];
1054
+ for (const entry of this.#cards) {
1055
+ const shown =
1056
+ entry.windows.length > 0 || entry.resetCredits !== undefined || entry.daybreakAccounts !== undefined;
1057
+ if (!shown) (entry.unavailableAccounts.length > 0 ? unreported : unmetered).push(entry);
1058
+ else if (entry.idle) untouched.push(entry);
1059
+ else framed.push(entry);
1060
+ }
1061
+ if (framed.length > 0) {
1030
1062
  children.push(
1031
1063
  node(
1032
1064
  "row",
1033
1065
  { wrap: true, gap: "md", role: "omp.usage.grid" },
1034
- active.map(entry => this.#describeCard(entry, meter)),
1066
+ framed.map(entry => this.#describeCard(entry, meter)),
1035
1067
  "providers",
1036
1068
  ),
1037
1069
  );
1038
1070
  }
1039
- if (idle.length > 0) {
1040
- children.push(
1041
- node(
1042
- "text",
1043
- {
1044
- spans: [span(`Untouched: ${idle.map(entry => entry.name).join(", ")}`)],
1045
- wrap: "word",
1046
- role: "omp.usage.untouched",
1047
- },
1048
- undefined,
1049
- "idle",
1071
+ const notes: NativeNode[] = [];
1072
+ if (untouched.length > 0) notes.push(footnote("idle", "Untouched", untouched));
1073
+ if (unreported.length > 0) {
1074
+ const accounts = unreported.flatMap(entry =>
1075
+ entry.unavailableAccounts.map(account => `${entry.name} (${account})`),
1076
+ );
1077
+ notes.push(
1078
+ footnote(
1079
+ "unreported",
1080
+ "No usage data",
1081
+ unreported,
1082
+ `No usage report came back for ${accounts.join(", ")}: the sign-in expired, the request failed, or the plan has no quotas`,
1050
1083
  ),
1051
1084
  );
1052
1085
  }
1086
+ if (unmetered.length > 0) {
1087
+ const why = unmetered.flatMap(entry => entry.notes.map(note => `${entry.name}: ${note}`));
1088
+ notes.push(
1089
+ footnote(
1090
+ "unmetered",
1091
+ "No quotas reported",
1092
+ unmetered,
1093
+ why.length > 0 ? why.join(" • ") : "These providers report no quota windows to track",
1094
+ ),
1095
+ );
1096
+ }
1097
+ if (notes.length > 0) children.push(node("col", { gap: "xs" }, notes, "footnotes"));
1053
1098
  }
1054
1099
  children.push(this.#describeActivity(chart));
1055
1100
  return children;
@@ -1100,10 +1145,10 @@ export class UsageDashboardComponent implements Component {
1100
1145
  );
1101
1146
  }
1102
1147
  for (const account of entry.unavailableAccounts) {
1103
- children.push(mutedText(`${sanitizeDisplayLine(account)}: usage unavailable`, true));
1148
+ children.push(mutedText(`${sanitizeDisplayLine(account)}: no usage data`, true));
1104
1149
  }
1105
1150
  if (entry.unlimited) {
1106
- children.push(mutedText("No limits"));
1151
+ children.push(mutedText("No quotas reported"));
1107
1152
  } else {
1108
1153
  for (const [index, window] of entry.windows.slice(0, CARD_MAX_WINDOWS).entries()) {
1109
1154
  const label: TspSpan[] = [span(sanitizeDisplayLine(window.label))];
@@ -1112,7 +1157,9 @@ export class UsageDashboardComponent implements Component {
1112
1157
  text(label, { role: "omp.usage.label", truncate: "middle", title: sanitizeDisplayLine(window.label) }),
1113
1158
  ];
1114
1159
  if (window.fraction === undefined) {
1115
- cells.push(text([span(window.usedText ?? "No data", "muted")], { truncate: "end" }));
1160
+ cells.push(
1161
+ text([span(window.usedText ?? "No data", "muted")], { role: "omp.usage.amount", truncate: "end" }),
1162
+ );
1116
1163
  } else {
1117
1164
  const token =
1118
1165
  window.status === "exhausted" ? "error" : window.status === "warning" ? "warning" : undefined;
@@ -1229,7 +1276,7 @@ export class UsageDashboardComponent implements Component {
1229
1276
  const children: NativeChild[] = [];
1230
1277
  for (const account of unavailable) {
1231
1278
  if (account.provider !== entry.provider) continue;
1232
- children.push(mutedText(`${sanitizeDisplayLine(account.label)}: usage unavailable`, true));
1279
+ children.push(mutedText(`${sanitizeDisplayLine(account.label)}: no usage data`, true));
1233
1280
  }
1234
1281
 
1235
1282
  const facts: { k: TspText; v: TspText }[] = [];
@@ -1321,7 +1368,7 @@ export class UsageDashboardComponent implements Component {
1321
1368
  children.push(node("table", { cols, rows }, undefined, "limits"));
1322
1369
  for (const note of new Set(limitNotes)) children.push(mutedText(note, true));
1323
1370
  } else if (unavailable.every(account => account.provider !== entry.provider)) {
1324
- children.push(mutedText("No limits"));
1371
+ children.push(mutedText("No quotas reported"));
1325
1372
  }
1326
1373
  sections.push(
1327
1374
  node(
@@ -899,6 +899,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
899
899
 
900
900
  /** Update the canonical editor reference after InteractiveMode remounts a custom editor. */
901
901
  setEditor(editor: CustomEditor): void {
902
+ this.ui.replaceHeldFocus(this.#editor, editor);
902
903
  this.#editor = editor;
903
904
  }
904
905
 
@@ -1085,6 +1085,7 @@ const VIM_MODE_LABELS: Record<NonNullable<SegmentContext["vim"]>["mode"], string
1085
1085
  normal: "NORMAL",
1086
1086
  visual: "VISUAL",
1087
1087
  "visual-line": "V-LINE",
1088
+ replace: "REPLACE",
1088
1089
  };
1089
1090
 
1090
1091
  /**
@@ -1097,6 +1098,7 @@ const VIM_MODE_ICON_KEYS: Record<NonNullable<SegmentContext["vim"]>["mode"], Sym
1097
1098
  normal: "icon.vimNormal",
1098
1099
  visual: "icon.vimVisual",
1099
1100
  "visual-line": "icon.vimVisualLine",
1101
+ replace: "icon.vimReplace",
1100
1102
  };
1101
1103
 
1102
1104
  const VIM_MODE_COLORS: Record<NonNullable<SegmentContext["vim"]>["mode"], ThemeColor> = {
@@ -1104,6 +1106,7 @@ const VIM_MODE_COLORS: Record<NonNullable<SegmentContext["vim"]>["mode"], ThemeC
1104
1106
  normal: "accent",
1105
1107
  visual: "warning",
1106
1108
  "visual-line": "warning",
1109
+ replace: "accent",
1107
1110
  };
1108
1111
 
1109
1112
  const vimSegment: StatusLineSegment = {
@@ -114,7 +114,7 @@ export interface SegmentContext {
114
114
  } | null;
115
115
  /** Modal editing state, or null when `tui.vimMode` is off. */
116
116
  vim: {
117
- mode: "insert" | "normal" | "visual" | "visual-line";
117
+ mode: "insert" | "normal" | "visual" | "visual-line" | "replace";
118
118
  /** Half-typed operator/count (`"2d"`), empty when nothing is pending. */
119
119
  pending: string;
120
120
  /** Lines spanned by the active Visual selection; 0 outside Visual modes. */
@@ -129,6 +129,7 @@ export type SymbolKey =
129
129
  | "icon.vimInsert"
130
130
  | "icon.vimVisual"
131
131
  | "icon.vimVisualLine"
132
+ | "icon.vimReplace"
132
133
  // Slash-command type indicators (autocomplete); names without an existing
133
134
  // icon.* equivalent — see SlashCommandIconName for the full vocabulary.
134
135
  | "cmd.action"
@@ -511,6 +512,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
511
512
  "icon.vimInsert": "▎",
512
513
  "icon.vimVisual": "◉",
513
514
  "icon.vimVisualLine": "≡",
515
+ "icon.vimReplace": "▁",
514
516
  // Slash-command type indicators
515
517
  "cmd.action": "❯",
516
518
  "cmd.prompt": "✎",
@@ -921,6 +923,8 @@ const NERD_SYMBOLS: SymbolMap = {
921
923
  "icon.vimVisual": "\uf06e",
922
924
  // pick:  (nf-fa-bars) | alt:  (nf-fa-align_left)
923
925
  "icon.vimVisualLine": "\uf0c9",
926
+ // pick:  (nf-fa-exchange) | alt:  (nf-fa-repeat)
927
+ "icon.vimReplace": "\uf0ec",
924
928
  // Slash-command type indicators
925
929
  // pick: (nf-cod-terminal) | alt: (nf-fa-terminal)
926
930
  "cmd.action": "\uea85",
@@ -1289,6 +1293,7 @@ const ASCII_SYMBOLS: SymbolMap = {
1289
1293
  "icon.vimInsert": "I",
1290
1294
  "icon.vimVisual": "V",
1291
1295
  "icon.vimVisualLine": "L",
1296
+ "icon.vimReplace": "R",
1292
1297
  // Slash-command type indicators — unused; the icon column is disabled in ASCII mode
1293
1298
  "cmd.action": "",
1294
1299
  "cmd.prompt": "",