@oh-my-pi/pi-tui 18.14.1 → 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 (46) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/charts/table-data.d.ts +4 -2
  3. package/dist/types/chat/transcript-render-helpers.d.ts +3 -3
  4. package/dist/types/native/describe.d.ts +4 -2
  5. package/dist/types/native/overlay.d.ts +42 -2
  6. package/dist/types/native/state.d.ts +4 -0
  7. package/dist/types/prompt/composer.d.ts +13 -5
  8. package/dist/types/render/render-utils.d.ts +2 -0
  9. package/dist/types/theme/theme.d.ts +1 -1
  10. package/dist/types/theme/tui-adapters.d.ts +2 -0
  11. package/dist/types/thinking.d.ts +8 -6
  12. package/dist/types/tools/ast-grep.d.ts +2 -0
  13. package/dist/types/tools/eval.d.ts +2 -0
  14. package/dist/types/tools/find.d.ts +1 -1
  15. package/dist/types/tools/grouped-file-output.d.ts +24 -11
  16. package/dist/types/tools/read.d.ts +4 -4
  17. package/dist/types/tools/task.d.ts +2 -2
  18. package/package.json +9 -9
  19. package/src/apps/git/git-tui.ts +10 -17
  20. package/src/apps/git/sidebar.ts +21 -35
  21. package/src/apps/ps-top.ts +4 -12
  22. package/src/charts/chart-plan.ts +0 -29
  23. package/src/charts/table-data.ts +55 -6
  24. package/src/chat/transcript-render-helpers.ts +18 -16
  25. package/src/native/backend.ts +7 -3
  26. package/src/native/describe.ts +9 -4
  27. package/src/native/overlay.ts +124 -16
  28. package/src/native/state.ts +25 -5
  29. package/src/overlays/extensions/extension-dashboard.ts +7 -8
  30. package/src/overlays/login-dialog.ts +8 -26
  31. package/src/overlays/tree-selector.ts +64 -18
  32. package/src/overlays/usage-dashboard.ts +2 -4
  33. package/src/prompt/composer.ts +17 -5
  34. package/src/render/render-utils.ts +2 -0
  35. package/src/theme/theme-class.ts +12 -3
  36. package/src/theme/theme.ts +1 -0
  37. package/src/theme/tui-adapters.ts +8 -3
  38. package/src/thinking.ts +17 -11
  39. package/src/tools/ast-grep.ts +10 -2
  40. package/src/tools/eval.ts +2 -0
  41. package/src/tools/find.ts +7 -2
  42. package/src/tools/grep.ts +8 -2
  43. package/src/tools/grouped-file-output.ts +172 -47
  44. package/src/tools/lsp.ts +158 -8
  45. package/src/tools/read.ts +6 -5
  46. package/src/tools/task.ts +2 -2
@@ -17,7 +17,7 @@ import { type Component, TUI } from "../tui";
17
17
  import type { TspSpan, TspText, TspTone } from "@oh-my-pi/pi-wire";
18
18
  import { col, compact, elapsed, keyed, node, row, span, stableKey, text } from "../native/describe";
19
19
  import type { NativeNode, NativeUiEvent } from "../native/node";
20
- import { actionBar, actionButton } from "../native/overlay";
20
+ import { actionBar, actionButton, iconButton, segmentedTabs } from "../native/overlay";
21
21
  import { Memo } from "../native/memo";
22
22
  import { truncateToWidth } from "../utils";
23
23
  import { formatDuration } from "@oh-my-pi/pi-utils";
@@ -410,13 +410,7 @@ export class PsTopComponent implements Component {
410
410
  );
411
411
  } else {
412
412
  left.push(
413
- node("icon", {
414
- name: "back",
415
- role: "omp.app.ibtn",
416
- title: "Back esc",
417
- aria: "Back",
418
- actions: { click: "back" },
419
- }),
413
+ iconButton("back", "back", "Back esc", { aria: "Back" }),
420
414
  text(
421
415
  this.#view === "logs" ? `Logs · ${entry?.row.snapshot.name ?? "?"}` : (entry?.row.snapshot.name ?? "?"),
422
416
  {
@@ -432,8 +426,7 @@ export class PsTopComponent implements Component {
432
426
  const right: NativeNode[] = [];
433
427
  if (this.#view === "table") {
434
428
  right.push(
435
- node(
436
- "tabs",
429
+ segmentedTabs(
437
430
  {
438
431
  items: [
439
432
  {
@@ -448,7 +441,6 @@ export class PsTopComponent implements Component {
448
441
  active: this.#all ? "all" : "current",
449
442
  role: "omp.app.seg",
450
443
  },
451
- undefined,
452
444
  "scope",
453
445
  ),
454
446
  );
@@ -468,7 +460,7 @@ export class PsTopComponent implements Component {
468
460
  align: "center",
469
461
  role: "omp.app.fresh",
470
462
  }),
471
- node("icon", { name: "x", role: "omp.app.ibtn", title: "Quit q", aria: "Quit", actions: { click: "quit" } }),
463
+ iconButton("x", "quit", "Quit q", { aria: "Quit" }),
472
464
  );
473
465
  return keyed(
474
466
  row(
@@ -1523,10 +1523,6 @@ export function buildChart(table: TableAnalysis, plan: ChartPlan): ChartSpec | u
1523
1523
  // its rows are in different units.
1524
1524
  if (arrows.length === 1 && plan.kind !== "paired" && plan.kind !== "range" && !plan.transpose)
1525
1525
  return buildChart(table, { ...plan, kind: arrows[0]!.mixed ? "change" : "paired", series: [arrows[0]!.index] });
1526
- // A dumbbell of two measures beside their ratio (`pi-walker | Go | Speedup`): the ratio restates the gap the
1527
- // dumbbell draws (on a log axis its length), so the pair draws alone.
1528
- const pair = plan.kind === "paired" && !plan.transpose ? ratioPair(table, columns) : undefined;
1529
- if (pair) return buildChart(table, { ...plan, series: pair.map(column => column.index) });
1530
1526
  // Unitless before/after values spanning decades are rows of different quantities, and rows of very different
1531
1527
  // sizes beside a baseline column share no readable axis; their factors do (`3.95M → 190k` is 21× less).
1532
1528
  // Intervals keep their values: a range chart prints the factor between a pair's midpoints beside both.
@@ -2810,31 +2806,6 @@ function againstBaseline(
2810
2806
  return [baseline, ...columns.filter(column => column !== baseline)];
2811
2807
  }
2812
2808
 
2813
- /** Relative slack within which a ratio column restates the ratio of two others (`4.1×` for 75.3 / 18.2). */
2814
- const RATIO_SLACK = 0.05;
2815
-
2816
- /**
2817
- * Of three plotted columns, the two same-unit measures when the third is their ratio (`Speedup`
2818
- * `4.1×`, a `ratio` column) matching `a ÷ b` or `b ÷ a` within {@link RATIO_SLACK}
2819
- * in every row holding all three, two rows at least; else `undefined`.
2820
- */
2821
- function ratioPair(table: TableAnalysis, columns: readonly TableColumn[]): [TableColumn, TableColumn] | undefined {
2822
- if (columns.length !== 3) return undefined;
2823
- const ratio = columns.find(column => column.dim === "ratio");
2824
- const rest = columns.filter(column => column !== ratio);
2825
- const [a, b] = rest;
2826
- if (!ratio || !a || !b || a.dim !== b.dim || a.unit !== b.unit || a.dim === "ratio") return undefined;
2827
- const near = (value: number, target: number) => Math.abs(value - target) <= RATIO_SLACK * target;
2828
- let matched = 0;
2829
- for (const row of table.rows) {
2830
- const [x, y, r] = [a.cells[row], b.cells[row], ratio.cells[row]];
2831
- if (!isNumber(x) || !isNumber(y) || !isNumber(r) || x.value <= 0 || y.value <= 0) continue;
2832
- if (!near(r.value, x.value / y.value) && !near(r.value, y.value / x.value)) return undefined;
2833
- matched++;
2834
- }
2835
- return matched >= 2 ? [a, b] : undefined;
2836
- }
2837
-
2838
2809
  /**
2839
2810
  * Measure columns pairing a before condition with another over the same
2840
2811
  * metric (`Baseline Turns` → `Iter1 Turns`, `Baseline Tokens In` → `Iter1
@@ -108,8 +108,10 @@ export interface TableColumn {
108
108
  /**
109
109
  * Index of the measure column this one restates, read from the values: a
110
110
  * proportional copy (`pass` 33/38 beside `pass%` 87%, `Samples` beside `% of
111
- * Total`, `Leverage vs $200` beside the cost) or a running total of it
112
- * (`Offset` summing `Verts`). Such a column is never a measure of its own.
111
+ * Total`, `Leverage vs $200` beside the cost), a running total of it
112
+ * (`Offset` summing `Verts`), or its ratio to another column of its unit
113
+ * (`ratio` 0.997 for `Python (ms)` ÷ `C++ (ms)`, `Speedup` 4.1×: the
114
+ * numerator's index). Such a column is never a measure of its own.
113
115
  */
114
116
  readonly restates?: number;
115
117
  }
@@ -906,10 +908,11 @@ function plainValue(column: TableColumn, row: number): NumberCell | undefined {
906
908
 
907
909
  /**
908
910
  * Columns restating another column's values: `y = k·x` within the written
909
- * precision over three or more rows where `x` varies, or a running total
910
- * (`Offset` stepping by `Verts`). Of a proportional pair the first column
911
- * stays, unless the second is a share summing to 100% beside counts or the
912
- * table bolded more of its cells (`**87%**` beside `33/38`).
911
+ * precision over three or more rows where `x` varies, a running total
912
+ * (`Offset` stepping by `Verts`), or the ratio of two others ({@link quotient}).
913
+ * Of a proportional pair the first column stays, unless the second is a share
914
+ * summing to 100% beside counts or the table bolded more of its cells
915
+ * (`**87%**` beside `33/38`).
913
916
  */
914
917
  function restatedColumns(measures: readonly TableColumn[], rows: readonly number[]): Map<number, number> {
915
918
  const restated = new Map<number, number>();
@@ -931,9 +934,55 @@ function restatedColumns(measures: readonly TableColumn[], rows: readonly number
931
934
  const source = measures.find(other => other !== column && runningTotal(column, other, rows));
932
935
  if (source) restated.set(column.index, source.index);
933
936
  }
937
+ const kept = measures.filter(column => !restated.has(column.index));
938
+ for (const ratio of kept) {
939
+ if (restated.has(ratio.index)) continue;
940
+ const terms = kept.filter(column => column !== ratio && !restated.has(column.index));
941
+ const over = terms
942
+ .flatMap(a => terms.map(b => [a, b] as const))
943
+ .find(([a, b]) => a !== b && quotient(ratio, a, b, rows));
944
+ if (over) restated.set(ratio.index, over[0].index);
945
+ }
934
946
  return restated;
935
947
  }
936
948
 
949
+ /** Most relative rounding a row's quotient may carry to count toward proving it (`2×` beside its terms proves little). */
950
+ const LOOSE_QUOTIENT = 0.05;
951
+
952
+ /**
953
+ * `ratio = a ÷ b` in every row holding all three, `a` and `b` in one unit
954
+ * (`Python (ms)` ÷ `old C++ (ms)` = `0.997`; `Go` ÷ `pi-walker` = `4.1×`),
955
+ * within the precision each figure is written to, over three or more rows
956
+ * pinned to {@link LOOSE_QUOTIENT}. Percent columns are left alone: a part's
957
+ * share of a whole (`Share` = `Tokens` ÷ `Total`) is a measure the table states.
958
+ *
959
+ * Bare numbers all read as counts, and of three columns related so each is
960
+ * the others' quotient or product (`C++ = Python ÷ ratio` holds too), the
961
+ * ratio is the one that varies less from row to row than both its terms:
962
+ * workloads of different sizes move both timings, not their ratio. Where none
963
+ * does, none is taken for the ratio.
964
+ */
965
+ function quotient(ratio: TableColumn, a: TableColumn, b: TableColumn, rows: readonly number[]): boolean {
966
+ if ((ratio.dim !== "count" && ratio.dim !== "ratio") || a.dim !== b.dim || a.unit !== b.unit) return false;
967
+ let tight = 0;
968
+ const matched: (readonly [number, number, number])[] = [];
969
+ for (const row of rows) {
970
+ const [r, x, y] = [plainValue(ratio, row), plainValue(a, row), plainValue(b, row)];
971
+ if (!r || !x || !y) continue;
972
+ if (x.value <= 0 || y.value <= 0 || r.value <= 0) return false;
973
+ const exact = x.value / y.value;
974
+ const slack = writtenSlack(r) + exact * (writtenSlack(x) / x.value + writtenSlack(y) / y.value) + 1e-9 * exact;
975
+ if (Math.abs(r.value - exact) > slack) return false;
976
+ if (slack <= LOOSE_QUOTIENT * exact) tight++;
977
+ matched.push([r.value, x.value, y.value]);
978
+ }
979
+ if (tight < 3) return false;
980
+ if (ratio.dim !== a.dim) return true;
981
+ const spread = (at: 0 | 1 | 2) =>
982
+ Math.max(...matched.map(values => values[at])) / Math.min(...matched.map(values => values[at]));
983
+ return spread(0) < spread(1) && spread(0) < spread(2);
984
+ }
985
+
937
986
  /** How many of `rows` the table bolded in `column`. */
938
987
  function emphasized(column: TableColumn, rows: readonly number[]): number {
939
988
  return rows.filter(row => column.cells[row]?.emphasis).length;
@@ -137,9 +137,9 @@ export function buildLaunchCompletionBlock(message: CustomOrHookMessage): ToolAc
137
137
  }
138
138
 
139
139
  /**
140
- * Render a live IRC traffic custom message (`irc:incoming` / `irc:autoreply` /
141
- * `irc:relay`) as a transcript card. `getExpanded` supplies the live
142
- * expanded-state getter for the cached card.
140
+ * Render an IRC coordination custom message (`irc:incoming` / `irc:autoreply` /
141
+ * `irc:relay` / `irc:workpool`) as a tool-activity card. `getExpanded` supplies
142
+ * the live expanded-state getter for the cached card.
143
143
  */
144
144
  export function buildIrcMessageCard(message: CustomOrHookMessage, getExpanded: () => boolean): Component {
145
145
  const details = (
@@ -161,19 +161,21 @@ export function buildIrcMessageCard(message: CustomOrHookMessage, getExpanded: (
161
161
  : message.customType === "irc:workpool"
162
162
  ? ("workpool" as const)
163
163
  : ("relay" as const);
164
- return createIrcMessageCard(
165
- {
166
- kind,
167
- from: details?.from,
168
- to: details?.to,
169
- body: kind === "incoming" ? details?.message : details?.body,
170
- replyTo: details?.replyTo,
171
- timestamp: message.timestamp,
172
- pool: details?.pool,
173
- mode: details?.mode,
174
- },
175
- getExpanded,
176
- theme,
164
+ return new ToolActivityContainer(
165
+ createIrcMessageCard(
166
+ {
167
+ kind,
168
+ from: details?.from,
169
+ to: details?.to,
170
+ body: kind === "incoming" ? details?.message : details?.body,
171
+ replyTo: details?.replyTo,
172
+ timestamp: message.timestamp,
173
+ pool: details?.pool,
174
+ mode: details?.mode,
175
+ },
176
+ getExpanded,
177
+ theme,
178
+ ),
177
179
  );
178
180
  }
179
181
 
@@ -48,7 +48,7 @@ import { encodeTspJson, encodeTspMessage, type TspHello, TspReader, splitTspMess
48
48
  import type { DescribeContext, NativeChild, NativeNode, NativeSurface, NativeUiEvent } from "./node";
49
49
  import { NARROW_SHEET_COLS } from "./overlay";
50
50
  import { nativeComponentId, Reconciler } from "./reconcile";
51
- import { setNativeRendering } from "./state";
51
+ import { setNativeButtons, setNativeRendering } from "./state";
52
52
 
53
53
  /** A visible TUI overlay, bottom to top. */
54
54
  export interface NativeOverlay {
@@ -199,9 +199,10 @@ function sameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
199
199
  * Kinds newer than some Tern builds that answer `TERM_PROGRAM=tern`: used only
200
200
  * once the real `hello` lists them, so the optimistic start never emits a node
201
201
  * an older terminal cannot draw (`media`: files Tern loads by path; `json`:
202
- * raw JSON values Tern draws as its own tree).
202
+ * raw JSON values Tern draws as its own tree; `button`: omp's buttons, which
203
+ * older builds get as role-styled rows).
203
204
  */
204
- const UNASSUMED_KINDS: Partial<Record<TspKind, true>> = { media: true, json: true };
205
+ const UNASSUMED_KINDS: Partial<Record<TspKind, true>> = { media: true, json: true, button: true };
205
206
 
206
207
  /**
207
208
  * The reply a v1 terminal is assumed to give before its real `hello` arrives
@@ -380,6 +381,7 @@ export class NativeBackend {
380
381
  this.#close(this.#inline, keep);
381
382
  this.#inline.doc?.close();
382
383
  setNativeRendering(false);
384
+ setNativeButtons(false);
383
385
  this.#useNerdSymbols(false);
384
386
  this.#clearStallTimer();
385
387
  // Replies no longer reach the backend: forget the queries outright.
@@ -539,6 +541,8 @@ export class NativeBackend {
539
541
  this.#limit = hello.apc !== undefined && hello.apc > 0 ? hello.apc : TSP_DEFAULT_APC_LIMIT;
540
542
  this.#credits = hello.credits !== undefined && hello.credits > 0 ? hello.credits : TSP_DEFAULT_CREDITS;
541
543
  this.#cx = new NativeContext(hello, hello.cols ?? this.#cx.cols);
544
+ // A changed vocabulary re-describes every component (`confirm`).
545
+ setNativeButtons(this.#cx.supports("button"));
542
546
  this.#sawResize = false;
543
547
  }
544
548
 
@@ -132,10 +132,15 @@ export function elapsed(age: number, stopped?: boolean): NativeNode {
132
132
  }
133
133
 
134
134
  /**
135
- * Keycap for one key id (`"ctrl+o"` → keys `["ctrl", "o"]`); the terminal
136
- * draws platform glyphs. A literal `+` key survives (`"+"`, `"ctrl++"`).
135
+ * The key names of one key id (`"ctrl+o"` → `["ctrl", "o"]`), as `kbd` and
136
+ * `button` `keys` take them. A literal `+` key survives (`"+"`, `"ctrl++"`).
137
137
  */
138
- export function kbd(key: string, nodeKey?: string): NativeNode {
138
+ export function keyNames(key: string): string[] {
139
139
  const keys = key === "+" ? ["+"] : key.endsWith("++") ? [...key.slice(0, -2).split("+"), "+"] : key.split("+");
140
- return node("kbd", { keys: keys.filter(part => part.length > 0) }, undefined, nodeKey);
140
+ return keys.filter(part => part.length > 0);
141
+ }
142
+
143
+ /** Keycap for one key id ({@link keyNames}); the terminal draws platform glyphs. */
144
+ export function kbd(key: string, nodeKey?: string): NativeNode {
145
+ return node("kbd", { keys: keyNames(key) }, undefined, nodeKey);
141
146
  }
@@ -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);
@@ -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
  );
@@ -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. */