@oh-my-pi/pi-tui 18.14.0 → 18.14.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 (58) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +6 -0
  3. package/dist/types/charts/table-data.d.ts +4 -2
  4. package/dist/types/chat/figure-markdown.d.ts +2 -0
  5. package/dist/types/chat/svg-figure.d.ts +5 -0
  6. package/dist/types/chat/table-chart.d.ts +2 -0
  7. package/dist/types/chat/transcript-render-helpers.d.ts +3 -3
  8. package/dist/types/components/image.d.ts +6 -0
  9. package/dist/types/native/describe.d.ts +4 -2
  10. package/dist/types/native/overlay.d.ts +42 -2
  11. package/dist/types/native/state.d.ts +4 -0
  12. package/dist/types/overlays/btw-history-panel.d.ts +2 -0
  13. package/dist/types/prompt/composer.d.ts +13 -5
  14. package/dist/types/render/render-utils.d.ts +2 -0
  15. package/dist/types/theme/theme.d.ts +1 -1
  16. package/dist/types/theme/tui-adapters.d.ts +2 -0
  17. package/dist/types/thinking.d.ts +8 -6
  18. package/dist/types/tools/ast-grep.d.ts +2 -0
  19. package/dist/types/tools/eval.d.ts +2 -0
  20. package/dist/types/tools/find.d.ts +1 -1
  21. package/dist/types/tools/grouped-file-output.d.ts +24 -11
  22. package/dist/types/tools/read.d.ts +4 -4
  23. package/dist/types/tools/task.d.ts +2 -2
  24. package/package.json +9 -9
  25. package/src/apps/git/git-tui.ts +10 -17
  26. package/src/apps/git/sidebar.ts +21 -35
  27. package/src/apps/ps-top.ts +4 -12
  28. package/src/charts/chart-plan.ts +0 -29
  29. package/src/charts/table-data.ts +55 -6
  30. package/src/chat/figure-markdown.ts +6 -4
  31. package/src/chat/svg-figure.ts +55 -16
  32. package/src/chat/table-chart.ts +19 -2
  33. package/src/chat/transcript-render-helpers.ts +18 -16
  34. package/src/components/image.ts +13 -0
  35. package/src/native/backend.ts +7 -3
  36. package/src/native/describe.ts +9 -4
  37. package/src/native/overlay.ts +124 -16
  38. package/src/native/state.ts +25 -5
  39. package/src/overlays/btw-history-panel.ts +8 -0
  40. package/src/overlays/extensions/extension-dashboard.ts +7 -8
  41. package/src/overlays/jobs-panel.ts +38 -4
  42. package/src/overlays/login-dialog.ts +8 -26
  43. package/src/overlays/tree-selector.ts +81 -27
  44. package/src/overlays/usage-dashboard.ts +2 -4
  45. package/src/prompt/composer.ts +17 -5
  46. package/src/render/render-utils.ts +2 -0
  47. package/src/theme/theme-class.ts +12 -3
  48. package/src/theme/theme.ts +1 -0
  49. package/src/theme/tui-adapters.ts +8 -3
  50. package/src/thinking.ts +17 -11
  51. package/src/tools/ast-grep.ts +10 -2
  52. package/src/tools/eval.ts +2 -0
  53. package/src/tools/find.ts +7 -2
  54. package/src/tools/grep.ts +8 -2
  55. package/src/tools/grouped-file-output.ts +172 -47
  56. package/src/tools/lsp.ts +158 -8
  57. package/src/tools/read.ts +6 -5
  58. package/src/tools/task.ts +2 -2
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Overlay chrome for `describe()`: root cards, key-hint footers, selectable
3
- * lists and annotation callouts.
2
+ * Overlay chrome for `describe()`: root cards, key-hint footers, buttons and
3
+ * segmented controls, selectable lists and annotation callouts.
4
4
  *
5
5
  * The overlay frame itself (glass, ring, placement) is the terminal's: the
6
6
  * engine wraps every overlay-stack component in an `overlay` node. An overlay
@@ -9,8 +9,9 @@
9
9
  import type { TspProps, TspSpan, TspText } from "@oh-my-pi/pi-wire";
10
10
  import { formatTooltipKey, type KeyName } from "../key-hint-format";
11
11
  import { getKeybindings, type Keybinding } from "../keybindings";
12
- import { card, kbd, list, node, row, span, text } from "./describe";
12
+ import { card, kbd, keyNames, list, node, row, span, text } from "./describe";
13
13
  import type { NativeChild, NativeNode } from "./node";
14
+ import { nativeButtons } from "./state";
14
15
 
15
16
  /** Below this many columns a centered sheet fills the pane instead of floating with margins. */
16
17
  export const NARROW_SHEET_COLS = 100;
@@ -96,29 +97,68 @@ export interface ActionButtonOptions {
96
97
  readonly title?: string;
97
98
  /** Target of an `open` action (URL or `file://` path). */
98
99
  readonly href?: string;
100
+ /** Why the action can't run now: the button dims and a click sends nothing. */
101
+ readonly disabled?: string;
102
+ /** The button's work is running: a spinner leads the label. */
103
+ readonly busy?: boolean;
104
+ /** Icon beside the label (before it on a `button` node, after it on a row). */
105
+ readonly icon?: string;
99
106
  }
100
107
 
101
108
  /**
102
109
  * A native button (role `omp.btn`): a label plus an optional keycap. A click
103
110
  * sends `act` back as an `action` event (or runs a terminal-local `open`/`copy`),
104
111
  * which the component routes to the same code path as the key.
112
+ *
113
+ * A terminal that draws the `button` kind ({@link nativeButtons}) gets one
114
+ * (tone `accent` → variant `primary`, `error` → `danger`, `disabled` with its
115
+ * reason as the tooltip); older ones get the row their stylesheets draw by
116
+ * role. Both keep the role and tone the omp sheets' context rules key on; a
117
+ * disabled button's tone is `muted`.
105
118
  */
106
119
  export function actionButton(label: string, act: string, options: ActionButtonOptions = {}): NativeNode {
107
- const children: NativeChild[] = [text(label)];
108
- if (options.keys) children.push(kbd(options.keys));
120
+ const { keys, href, disabled, busy, icon } = options;
121
+ const title = options.title ?? (keys ? `${label} ${formatTooltipKey(keys)}` : label);
122
+ const tone = disabled === undefined ? options.tone : "muted";
123
+ const key = options.key ?? act;
124
+ if (nativeButtons()) {
125
+ return node(
126
+ "button",
127
+ {
128
+ role: "omp.btn",
129
+ text: label,
130
+ actions: { click: act },
131
+ title,
132
+ ...(keys ? { keys: keyNames(keys) } : {}),
133
+ ...(icon ? { icon } : {}),
134
+ ...(tone === "accent" ? { variant: "primary" } : tone === "error" ? { variant: "danger" } : {}),
135
+ ...(tone ? { tone } : {}),
136
+ ...(disabled !== undefined ? { disabled: disabled || true } : {}),
137
+ ...(busy ? { busy } : {}),
138
+ ...(href ? { href } : {}),
139
+ },
140
+ undefined,
141
+ key,
142
+ );
143
+ }
144
+ const children: NativeChild[] = [];
145
+ if (busy) children.push(node("spinner", { style: "dots" }));
146
+ children.push(text(label));
147
+ if (icon) children.push(node("icon", { name: icon }, undefined, "icon"));
148
+ if (keys) children.push(kbd(keys));
109
149
  return node(
110
150
  "row",
111
151
  {
112
152
  role: "omp.btn",
113
153
  gap: "xs",
114
154
  align: "center",
115
- actions: { click: act },
116
- title: options.title ?? (options.keys ? `${label} ${formatTooltipKey(options.keys)}` : label),
117
- ...(options.tone ? { tone: options.tone } : {}),
118
- ...(options.href ? { href: options.href } : {}),
155
+ ...(disabled === undefined ? { actions: { click: act } } : {}),
156
+ title,
157
+ ...(tone ? { tone } : {}),
158
+ ...(href ? { href } : {}),
119
159
  },
120
160
  children,
121
- options.key ?? act,
161
+ key,
122
162
  );
123
163
  }
124
164
 
@@ -127,12 +167,80 @@ export function actionButton(label: string, act: string, options: ActionButtonOp
127
167
  * `act` (default `close`), which the component routes to its Esc path.
128
168
  */
129
169
  export function escCloseButton(act = "close", key = "esc-close"): NativeNode {
130
- return node(
131
- "row",
132
- { role: "omp.btn", align: "center", actions: { click: act }, title: `Close ${formatTooltipKey("escape")}` },
133
- [kbd("escape")],
134
- key,
135
- );
170
+ const title = `Close ${formatTooltipKey("escape")}`;
171
+ if (nativeButtons()) {
172
+ return node(
173
+ "button",
174
+ { role: "omp.btn", keys: ["escape"], actions: { click: act }, title, aria: "Close" },
175
+ undefined,
176
+ key,
177
+ );
178
+ }
179
+ return node("row", { role: "omp.btn", align: "center", actions: { click: act }, title }, [kbd("escape")], key);
180
+ }
181
+
182
+ /** Options of an {@link iconButton}. */
183
+ export interface IconButtonOptions {
184
+ /** Sibling identity key; unkeyed buttons take their position. */
185
+ readonly key?: string;
186
+ /** Accessible name; defaults to the title. */
187
+ readonly aria?: string;
188
+ /** A toggle that is on (role `omp.app.ibtn.set`). */
189
+ readonly on?: boolean;
190
+ }
191
+
192
+ /**
193
+ * A quiet icon-only button (role `omp.app.ibtn`, the kit's icon button) for an
194
+ * app head or toolbar: a `button` node where the terminal draws one
195
+ * ({@link nativeButtons}), else an `icon` with a click action.
196
+ */
197
+ export function iconButton(name: string, act: string, title: string, options: IconButtonOptions = {}): NativeNode {
198
+ const role = options.on ? "omp.app.ibtn.set" : "omp.app.ibtn";
199
+ const aria = options.aria ?? title;
200
+ if (nativeButtons()) {
201
+ return node(
202
+ "button",
203
+ {
204
+ role,
205
+ variant: "quiet",
206
+ icon: name,
207
+ title,
208
+ aria,
209
+ actions: { click: act },
210
+ ...(options.on ? { on: true } : {}),
211
+ },
212
+ undefined,
213
+ options.key,
214
+ );
215
+ }
216
+ return node("icon", { name, role, title, aria, actions: { click: act } }, undefined, options.key);
217
+ }
218
+
219
+ /**
220
+ * A quiet text button (role `omp.app.qbtn`, the kit's quiet button) for a
221
+ * section head or toolbar, keyed by its action: a small quiet `button` where
222
+ * the terminal draws one ({@link nativeButtons}), else a row.
223
+ */
224
+ export function quietButton(label: string, act: string, title: string): NativeNode {
225
+ if (nativeButtons()) {
226
+ return node(
227
+ "button",
228
+ { role: "omp.app.qbtn", variant: "quiet", size: "sm", text: label, actions: { click: act }, title },
229
+ undefined,
230
+ act,
231
+ );
232
+ }
233
+ return node("row", { role: "omp.app.qbtn", actions: { click: act }, title }, [text(label)], act);
234
+ }
235
+
236
+ /**
237
+ * A segmented control: `tabs` drawn as a well with a raised thumb on the
238
+ * active tab. A terminal that draws the `button` kind ({@link nativeButtons})
239
+ * draws `variant: "segmented"` too; older ones restyle the tabs by role
240
+ * (`omp.app.seg`) or parent.
241
+ */
242
+ export function segmentedTabs(p: TspProps<"tabs">, key: string): NativeNode {
243
+ return node("tabs", nativeButtons() ? { ...p, variant: "segmented" } : p, undefined, key);
136
244
  }
137
245
 
138
246
  /** A row of {@link actionButton}s (role `omp.actions`); `null` entries become the spacer that end-aligns what follows. */
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Process-wide flag: a Tern Surface Protocol backend is rendering.
2
+ * Process-wide native rendering state, set only by the native backend.
3
3
  *
4
- * Components that animate by re-rendering on a timer (spinners, shimmer,
5
- * thinking frames, countdowns) check it and skip scheduling, because the
6
- * terminal clocks motion from the described nodes. Set only by the native
7
- * backend when a surface opens and cleared when it closes.
4
+ * Whether a Tern Surface Protocol backend is rendering: components that
5
+ * animate by re-rendering on a timer (spinners, shimmer, thinking frames,
6
+ * countdowns) check it and skip scheduling, because the terminal clocks
7
+ * motion from the described nodes. Set when a surface opens and cleared when
8
+ * it closes.
8
9
  */
9
10
  let active = false;
10
11
  const listeners = new Set<(on: boolean) => void>();
@@ -21,6 +22,25 @@ export function setNativeRendering(on: boolean): void {
21
22
  for (const listener of listeners) listener(on);
22
23
  }
23
24
 
25
+ /**
26
+ * Whether the live terminal draws the `button` kind (its `hello` lists it),
27
+ * and with it `tabs` `variant: "segmented"`. The button builders
28
+ * (`native/overlay.ts`) read it; older Terns get the `row` buttons they style
29
+ * by role. Set only by the native backend from the `hello` it adopts; false
30
+ * while no surface is live.
31
+ */
32
+ let buttons = false;
33
+
34
+ /** Whether omp's buttons go out as `button` nodes (see {@link setNativeButtons}). */
35
+ export function nativeButtons(): boolean {
36
+ return buttons;
37
+ }
38
+
39
+ /** Called by the native backend when it adopts a `hello` reply and when it stops. */
40
+ export function setNativeButtons(on: boolean): void {
41
+ buttons = on;
42
+ }
43
+
24
44
  /** Call `listener` whenever native rendering starts or stops; returns the unsubscribe. */
25
45
  export function onNativeRenderingChange(listener: (on: boolean) => void): () => void {
26
46
  listeners.add(listener);
@@ -150,6 +150,7 @@ export class BtwHistoryPanel implements Component, Focusable {
150
150
  #listScroll = 0;
151
151
  #listHeight = 1;
152
152
  #focused = false;
153
+ #useTerminalCursor = false;
153
154
  #composer: FollowUpComposer | undefined;
154
155
  readonly #pasteHandler = new BracketedPasteHandler();
155
156
  #followUpPending = false;
@@ -225,6 +226,12 @@ export class BtwHistoryPanel implements Component, Focusable {
225
226
  this.#focused = value;
226
227
  }
227
228
 
229
+ /** Keep the follow-up input's cursor mode aligned so the hardware cursor (and IME preedit) lands on its caret. */
230
+ setUseTerminalCursor(useTerminalCursor: boolean): void {
231
+ this.#useTerminalCursor = useTerminalCursor;
232
+ this.#composer?.input.setUseTerminalCursor(useTerminalCursor);
233
+ }
234
+
228
235
  update(records: readonly BtwHistoryRecord[]): void {
229
236
  this.#records = records;
230
237
  this.#detailDirty = true;
@@ -329,6 +336,7 @@ export class BtwHistoryPanel implements Component, Focusable {
329
336
  const input = new Input();
330
337
  input.prompt = theme.fg("accent", "Follow up: ");
331
338
  input.placeholder = "Ask about this answer…";
339
+ input.setUseTerminalCursor(this.#useTerminalCursor);
332
340
  if (this.#options.spaceHoldKeys !== undefined) input.spaceHold.keys = this.#options.spaceHoldKeys;
333
341
  input.spaceHold.handler = this.#options.spaceHold?.(input);
334
342
  const composer: FollowUpComposer = {
@@ -23,7 +23,7 @@ import type { Component } from "../../tui";
23
23
  import { col, keyed, node, span, text } from "../../native/describe";
24
24
  import { Memo } from "../../native/memo";
25
25
  import type { DescribeContext, NativeNode, NativeScroll, NativeUiEvent } from "../../native/node";
26
- import { actionBar, actionButton } from "../../native/overlay";
26
+ import { actionBar, actionButton, iconButton } from "../../native/overlay";
27
27
  import { CLOSE_ACTION, type PickerEvent, picker, pickerAction, pickerEvent } from "../../native/picker";
28
28
  import { type KeyId, matchesKey } from "../../keys";
29
29
  import { parseSgrMouse } from "../../mouse";
@@ -711,13 +711,12 @@ export class ExtensionDashboard implements Component {
711
711
  text(DASHBOARD_TITLE, { role: "omp.app.title" }),
712
712
  text([span(query ? `${shown} of ${total}` : `${total} extensions`, "muted")], { truncate: "end" }),
713
713
  ]),
714
- node("icon", {
715
- name: "x",
716
- role: "omp.app.ibtn",
717
- title: `Close ${formatTooltipKey(boundKeys("app.interrupt", ["escape"])[0] ?? "escape")}`,
718
- aria: "Close",
719
- actions: { click: "close" },
720
- }),
714
+ iconButton(
715
+ "x",
716
+ "close",
717
+ `Close ${formatTooltipKey(boundKeys("app.interrupt", ["escape"])[0] ?? "escape")}`,
718
+ { aria: "Close" },
719
+ ),
721
720
  ],
722
721
  "head",
723
722
  );
@@ -130,6 +130,12 @@ export class JobsSheet implements Component {
130
130
  #native: { key: string; output: string | undefined; node: NativeNode } | undefined;
131
131
  /** Fallback output tail, reused while the selected job's output is unchanged. */
132
132
  #tail: { id: string; output: string; lines: readonly string[] } | undefined;
133
+ /**
134
+ * The selected job as last seen: its row, detail and the subagents placing
135
+ * it. The session evicts settled jobs on its own clock; while the user keeps
136
+ * one selected it stays listed with its last output, until they select another.
137
+ */
138
+ #held: { job: JobsPanelJob; detail: JobsPanelDetail | undefined; agents: readonly JobTreeAgent[] } | undefined;
133
139
 
134
140
  constructor(source: JobsSheetSource) {
135
141
  this.#source = source;
@@ -137,7 +143,7 @@ export class JobsSheet implements Component {
137
143
 
138
144
  describe(_cx: DescribeContext): NativeNode {
139
145
  const { rows, jobs, selected, cancellable } = this.#current();
140
- const detail = selected && this.#source.inspect(selected.id);
146
+ const detail = selected && this.#inspect(selected.id);
141
147
  const now = Date.now();
142
148
  // Elapsed nodes tick on their own; list values and the output pane redraw
143
149
  // when a job starts, settles, leaves or moves in the tree, its process
@@ -201,7 +207,7 @@ export class JobsSheet implements Component {
201
207
  ),
202
208
  );
203
209
  }
204
- const detail = selected && this.#source.inspect(selected.id);
210
+ const detail = selected && this.#inspect(selected.id);
205
211
  if (detail) {
206
212
  // Rows show the cut label; the selected job's command wraps here,
207
213
  // bounded so a long script never pushes the facts and output off the sheet.
@@ -262,15 +268,43 @@ export class JobsSheet implements Component {
262
268
  selected: JobsPanelJob | undefined;
263
269
  cancellable: boolean;
264
270
  } {
265
- const { running, recent, rootId, agents } = this.#source.load();
266
- const rows = buildJobTree([...running, ...recent], agents ?? [], rootId);
271
+ const snapshot = this.#source.load();
272
+ const { running, rootId } = snapshot;
273
+ let { recent, agents = [] } = snapshot;
274
+ // A settled job the session evicted stays listed while it is selected.
275
+ const held = this.#held?.job.id === this.#selectedId ? this.#held : undefined;
276
+ const evicted =
277
+ held !== undefined &&
278
+ held.job.status !== "running" &&
279
+ !running.some(job => job.id === held.job.id) &&
280
+ !recent.some(job => job.id === held.job.id);
281
+ if (evicted) {
282
+ recent = [...recent, held.job];
283
+ const known = new Set(agents.map(agent => agent.id));
284
+ agents = [...agents, ...held.agents.filter(agent => !known.has(agent.id))];
285
+ }
286
+ const rows = buildJobTree([...running, ...recent], agents, rootId);
267
287
  const jobs = rows.flatMap(entry => (entry.kind === "job" ? [entry.job] : []));
268
288
  const selected = jobs.find(job => job.id === this.#selectedId) ?? jobs[0];
269
289
  this.#selectedId = selected?.id;
290
+ if (!selected) this.#held = undefined;
291
+ else if (!evicted) {
292
+ const detail = held?.job.id === selected.id ? held.detail : undefined;
293
+ this.#held = { job: selected, detail, agents: snapshot.agents ?? [] };
294
+ }
270
295
  const owned = selected?.ownerId === undefined || selected.ownerId === rootId;
271
296
  return { rows, jobs, selected, cancellable: this.#source.cancel !== undefined && owned };
272
297
  }
273
298
 
299
+ /** The job's detail from the source, else the last one seen while it was held. */
300
+ #inspect(id: string): JobsPanelDetail | undefined {
301
+ const detail = this.#source.inspect(id);
302
+ const held = this.#held?.job.id === id ? this.#held : undefined;
303
+ if (!held) return detail;
304
+ if (detail) held.detail = detail;
305
+ return held.detail;
306
+ }
307
+
274
308
  #tailLines(id: string, output: string): readonly string[] {
275
309
  const cached = this.#tail;
276
310
  if (cached?.id === id && cached.output === output) return cached.lines;
@@ -468,18 +468,7 @@ export class LoginDialogComponent extends OverlayPanel {
468
468
  [
469
469
  node("icon", { name: "link" }, undefined, "icon"),
470
470
  keyed(shown, "url"),
471
- node(
472
- "row",
473
- {
474
- role: "omp.btn",
475
- align: "center",
476
- actions: { click: "copy" },
477
- href: url,
478
- title: "Copy the sign-in link",
479
- },
480
- [text("Copy")],
481
- "copyLink",
482
- ),
471
+ actionButton("Copy", "copy", { key: "copyLink", href: url, title: "Copy the sign-in link" }),
483
472
  ],
484
473
  "link",
485
474
  );
@@ -487,20 +476,13 @@ export class LoginDialogComponent extends OverlayPanel {
487
476
 
488
477
  /** Open sign-in page (Tern opens the link itself): the primary action while nothing waits for an answer. */
489
478
  #describeOpen(url: string, primary: boolean): NativeNode {
490
- return node(
491
- "row",
492
- {
493
- role: "omp.btn",
494
- gap: "xs",
495
- align: "center",
496
- actions: { click: "open" },
497
- href: url,
498
- title: `Open ${urlPlace(url)} in your browser`,
499
- ...(primary ? { tone: "accent" as const } : {}),
500
- },
501
- [text("Open sign-in page"), node("icon", { name: "open" }, undefined, "icon")],
502
- "openLink",
503
- );
479
+ return actionButton("Open sign-in page", "open", {
480
+ key: "openLink",
481
+ href: url,
482
+ title: `Open ${urlPlace(url)} in your browser`,
483
+ icon: "open",
484
+ tone: primary ? "accent" : undefined,
485
+ });
504
486
  }
505
487
 
506
488
  /** The stage running now: a spinner and what it waits for. */
@@ -10,7 +10,7 @@ import {
10
10
  TruncatedText,
11
11
  truncateToWidth,
12
12
  } from "../index";
13
- import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
13
+ import { isRecord, oklabDistance, sanitizeText } from "@oh-my-pi/pi-utils";
14
14
  /** Available session-tree display filters. */
15
15
  export const TREE_FILTER_MODES = ["default", "no-tools", "user-only", "labeled-only", "all"] as const;
16
16
  /** Session-tree display filter. */
@@ -94,10 +94,15 @@ interface AdvisorTreeDisplay {
94
94
  */
95
95
  function sanitizeTreeField(value: string): string {
96
96
  return sanitizeText(value)
97
- .replace(/[\n\t]/g, " ")
97
+ .replace(/[\r\n\t\u2028\u2029]/g, " ")
98
98
  .trim();
99
99
  }
100
100
 
101
+ /** Strip ANSI/control characters and fold line breaks for single-line tree rows and headers. */
102
+ function treeRowText(value: string): string {
103
+ return plainText(value.replace(/[\r\n\u2028\u2029]/g, " "));
104
+ }
105
+
101
106
  /**
102
107
  * Extract display metadata from an advisor custom-message's `details.notes`,
103
108
  * ignoring the model-facing `<advisory>` wrapper stored in `content`. Collects
@@ -165,10 +170,38 @@ function stripSystemWrapperTags(content: string): string {
165
170
  /** Per-message cap on text folded into the tree search index. */
166
171
  const SEARCH_TEXT_LIMIT = 200;
167
172
 
168
- /** One theme-colored piece of an entry's display text. */
173
+ /** One theme-colored piece of an entry's display text; `strong` draws it bold. */
169
174
  interface EntryPart {
170
175
  t: string;
171
176
  s?: ThemeColor;
177
+ strong?: boolean;
178
+ }
179
+
180
+ /** An entry part as a native span. */
181
+ function partSpan(part: EntryPart): TspSpan {
182
+ const style = part.strong ? (part.s ? `${part.s} strong` : "strong") : part.s;
183
+ return span(treeRowText(part.t), style);
184
+ }
185
+
186
+ /** OKLab distance under which two role colors read as the same color. */
187
+ const SAME_COLOR_DISTANCE = 0.05;
188
+
189
+ let assistantLabel: { theme: typeof theme; color: ThemeColor } | undefined;
190
+
191
+ /**
192
+ * The `assistant:` label color: `success`, unless it looks like the `user:` label's
193
+ * `accent` (`dark-terminal`, `*-poimandres`); then the path color, then `muted`.
194
+ */
195
+ function assistantLabelColor(): ThemeColor {
196
+ if (assistantLabel?.theme !== theme) {
197
+ const accent = theme.getColorHex("accent");
198
+ const color =
199
+ (["success", "statusLinePath"] as const).find(
200
+ c => oklabDistance(theme.getColorHex(c), accent) >= SAME_COLOR_DISTANCE,
201
+ ) ?? "muted";
202
+ assistantLabel = { theme, color };
203
+ }
204
+ return assistantLabel.color;
172
205
  }
173
206
 
174
207
  /** A cached native tree item and the inputs it was built from. */
@@ -234,6 +267,8 @@ class TreeList implements Component {
234
267
  | undefined;
235
268
  /** Bumped on every label edit: labels are item badges. */
236
269
  #labelVersion = 0;
270
+ /** Theme the native item caches were built under: labels bake in the `assistant:` color. */
271
+ #nativeTheme = theme;
237
272
  #pickerItems:
238
273
  | { rows: readonly TreeRow<TreeSelectorNode, string>[]; labels: number; items: TspPickerItem[] }
239
274
  | undefined;
@@ -703,6 +738,7 @@ class TreeList implements Component {
703
738
  * only `order` (the visible entries) and the selection.
704
739
  */
705
740
  pickerView(): TreePickerView {
741
+ this.#syncNativeTheme();
706
742
  const allRows = this.#tree.allRows;
707
743
  if (this.#pickerItems?.rows !== allRows || this.#pickerItems.labels !== this.#labelVersion) {
708
744
  // Real branch points get a disclosure; linear chains stay flat at their head's depth.
@@ -734,9 +770,7 @@ class TreeList implements Component {
734
770
  #pickerItem(row: TreeRow<TreeSelectorNode, string>, open: boolean): TspPickerItem {
735
771
  const item = row.item;
736
772
  const entry = item.entry;
737
- const label = this.#entryParts(item).map(part =>
738
- span(part.t.includes("\x1b") ? plainText(part.t) : part.t, part.s),
739
- );
773
+ const label = this.#entryParts(item).map(partSpan);
740
774
  let kind: TspPickerItem["node"] = "marker";
741
775
  let role: string | undefined;
742
776
  if (isUserRequestEntry(entry)) {
@@ -755,7 +789,7 @@ class TreeList implements Component {
755
789
  node: kind,
756
790
  ...(role ? { role } : {}),
757
791
  ...(open ? { open: true } : {}),
758
- ...(item.label ? { badges: [{ text: plainText(item.label), tone: "warning" as const }] } : {}),
792
+ ...(item.label ? { badges: [{ text: treeRowText(item.label), tone: "warning" as const }] } : {}),
759
793
  ...(this.#activePathIds.has(row.key) ? { dot: "accent" as const } : {}),
760
794
  };
761
795
  }
@@ -772,7 +806,7 @@ class TreeList implements Component {
772
806
  if (selected) {
773
807
  const entry = selected.entry;
774
808
  const parts = this.#entryParts(selected)
775
- .map(part => (part.t.includes("\x1b") ? plainText(part.t) : part.t))
809
+ .map(part => treeRowText(part.t))
776
810
  .join("");
777
811
  if (entry.type === "message" || entry.type === "custom_message") {
778
812
  const target: OutlineTarget = {
@@ -784,12 +818,14 @@ class TreeList implements Component {
784
818
  entries: [entry],
785
819
  };
786
820
  const copy = targetCopy(target, collectBlocks(target.entries));
787
- preview.push(text(`${copy.label[0]!.toUpperCase()}${copy.label.slice(1)}`, { role: "omp.picker.title" }));
788
- if (selected.label) preview.push(text([span(plainText(selected.label), "warning")]));
821
+ preview.push(
822
+ text(treeRowText(`${copy.label[0]!.toUpperCase()}${copy.label.slice(1)}`), { role: "omp.picker.title" }),
823
+ );
824
+ if (selected.label) preview.push(text([span(treeRowText(selected.label), "warning")]));
789
825
  preview.push(turnPreview(target, copy.content || parts));
790
826
  } else {
791
827
  preview.push(text(parts, { role: "omp.picker.title" }));
792
- if (selected.label) preview.push(text([span(plainText(selected.label), "warning")]));
828
+ if (selected.label) preview.push(text([span(treeRowText(selected.label), "warning")]));
793
829
  }
794
830
  }
795
831
  this.#previewMemo = { node: selected, labels: this.#labelVersion, preview };
@@ -803,6 +839,7 @@ class TreeList implements Component {
803
839
  * instead of drawn tree connectors.
804
840
  */
805
841
  describeList(): NativeNode {
842
+ this.#syncNativeTheme();
806
843
  const rows = this.#tree.rows;
807
844
  let items = this.#itemsNative;
808
845
  if (items?.rows !== rows) {
@@ -852,6 +889,15 @@ class TreeList implements Component {
852
889
  ];
853
890
  }
854
891
 
892
+ /** Drop native items built under another theme. */
893
+ #syncNativeTheme(): void {
894
+ if (this.#nativeTheme === theme) return;
895
+ this.#nativeTheme = theme;
896
+ this.#pickerItems = undefined;
897
+ this.#itemsNative = undefined;
898
+ this.#itemCache.clear();
899
+ }
900
+
855
901
  #describeRow(row: TreeRow<TreeSelectorNode, string>): NativeNode {
856
902
  const item = row.item;
857
903
  const branch = row.parentKey !== undefined && row.siblingCount > 1;
@@ -859,10 +905,8 @@ class TreeList implements Component {
859
905
  if (cached !== undefined && cached.label === item.label && cached.branch === branch) return cached.node;
860
906
  const label: TspSpan[] = [];
861
907
  if (this.#activePathIds.has(row.key)) label.push(span(`${theme.md.bullet} `, "accent"));
862
- if (item.label) label.push(span(`[${plainText(item.label)}] `, "warning"));
863
- for (const part of this.#entryParts(item)) {
864
- label.push(span(part.t.includes("\x1b") ? plainText(part.t) : part.t, part.s));
865
- }
908
+ if (item.label) label.push(span(`[${treeRowText(item.label)}] `, "warning"));
909
+ for (const part of this.#entryParts(item)) label.push(partSpan(part));
866
910
  const described = node("item", { label, icon: branch ? "branch" : undefined }, undefined, row.key);
867
911
  this.#itemCache.set(row.key, { label: item.label, branch, node: described });
868
912
  return described;
@@ -954,7 +998,7 @@ class TreeList implements Component {
954
998
  // Active path marker - shown right before the entry text
955
999
  const isOnActivePath = this.#activePathIds.has(node.entry.id);
956
1000
  const pathMarker = isOnActivePath ? theme.fg("accent", `${theme.md.bullet} `) : "";
957
- const label = node.label ? theme.fg("warning", `[${node.label}] `) : "";
1001
+ const label = node.label ? theme.fg("warning", `[${treeRowText(node.label)}] `) : "";
958
1002
  return `${pathMarker}${label}${this.#getEntryDisplayText(node, selected)}`;
959
1003
  }
960
1004
 
@@ -1050,7 +1094,13 @@ class TreeList implements Component {
1050
1094
 
1051
1095
  #getEntryDisplayText(node: TreeSelectorNode, isSelected: boolean): string {
1052
1096
  let result = "";
1053
- for (const part of this.#entryParts(node)) result += part.s ? theme.fg(part.s, part.t) : part.t;
1097
+ for (const part of this.#entryParts(node)) {
1098
+ let value = treeRowText(part.t);
1099
+ if (part.s) value = theme.fg(part.s, value);
1100
+ // A selected row is bolded whole; nested bold would close it early.
1101
+ if (part.strong && !isSelected) value = theme.bold(value);
1102
+ result += value;
1103
+ }
1054
1104
  return isSelected ? theme.bold(result) : result;
1055
1105
  }
1056
1106
 
@@ -1066,7 +1116,10 @@ class TreeList implements Component {
1066
1116
  if (role === "user") {
1067
1117
  const msgWithContent = msg as { content?: unknown };
1068
1118
  const content = normalize(this.#extractContent(msgWithContent.content));
1069
- return [{ t: "user: ", s: "accent" }, { t: content }];
1119
+ return [
1120
+ { t: "user: ", s: "accent", strong: true },
1121
+ { t: content, s: "accent", strong: true },
1122
+ ];
1070
1123
  }
1071
1124
  if (role === "developer") {
1072
1125
  const msgWithContent = msg as { content?: unknown };
@@ -1078,15 +1131,10 @@ class TreeList implements Component {
1078
1131
  }
1079
1132
  if (role === "assistant") {
1080
1133
  const presentation = resolveAssistantErrorPresentation(msg);
1081
- if (presentation.kind === "compact-recovered") {
1082
- return [
1083
- { t: "assistant: ", s: "success" },
1084
- { t: presentation.text, s: "dim" },
1085
- ];
1086
- }
1134
+ const head: EntryPart = { t: "assistant: ", s: assistantLabelColor() };
1135
+ if (presentation.kind === "compact-recovered") return [head, { t: presentation.text, s: "dim" }];
1087
1136
  const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };
1088
1137
  const textContent = normalize(this.#extractContent(msgWithContent.content));
1089
- const head: EntryPart = { t: "assistant: ", s: "success" };
1090
1138
  if (textContent) return [head, { t: textContent }];
1091
1139
  if (presentation.kind === "full") {
1092
1140
  return [head, { t: normalize(presentation.text).slice(0, 80), s: "error" }];
@@ -1110,10 +1158,16 @@ class TreeList implements Component {
1110
1158
  if (entry.customType === "advisor") {
1111
1159
  const { qualifier, text } = advisorTreeDisplay(entry.details);
1112
1160
  const label = qualifier ? `advisor (${qualifier}): ` : "advisor: ";
1113
- return [{ t: label, s: "customMessageLabel" }, { t: normalize(text) }];
1161
+ return [
1162
+ { t: label, s: "customMessageLabel" },
1163
+ { t: normalize(text), s: "muted" },
1164
+ ];
1114
1165
  }
1115
1166
  const content = stripSystemWrapperTags(this.#joinTextContent(entry.content));
1116
- return [{ t: `[${entry.customType}]: `, s: "customMessageLabel" }, { t: normalize(content) }];
1167
+ return [
1168
+ { t: `[${entry.customType}]: `, s: "customMessageLabel" },
1169
+ { t: normalize(content), s: "muted" },
1170
+ ];
1117
1171
  }
1118
1172
  case "compaction": {
1119
1173
  const tokens = Math.round(entry.tokensBefore / 1000);
@@ -43,7 +43,7 @@ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
43
43
  import type { TspSpan, TspTableColumn, TspText, TspTone } from "@oh-my-pi/pi-wire";
44
44
  import { col, elapsed, node, span, text } from "../native/describe";
45
45
  import { type DescribeContext, leafKey, type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
46
- import { actionBar, actionButton } from "../native/overlay";
46
+ import { actionBar, actionButton, segmentedTabs } from "../native/overlay";
47
47
 
48
48
  /** Local calendar-day activity consumed by the usage heatmap. */
49
49
  export interface DailyActivityPoint {
@@ -1135,8 +1135,7 @@ export class UsageDashboardComponent implements Component {
1135
1135
  );
1136
1136
  }
1137
1137
  const children: NativeChild[] = [
1138
- node(
1139
- "tabs",
1138
+ segmentedTabs(
1140
1139
  {
1141
1140
  items: [
1142
1141
  { id: "overview", label: "Overview" },
@@ -1144,7 +1143,6 @@ export class UsageDashboardComponent implements Component {
1144
1143
  ],
1145
1144
  active: this.#view,
1146
1145
  },
1147
- undefined,
1148
1146
  "tabs",
1149
1147
  ),
1150
1148
  node("spacer", { grow: 1 }),