@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
@@ -20,6 +20,7 @@ import { matchesKey } from "../../keys";
20
20
  import { col, compact, item, keyed, list, node, row, span, stableKey, text } from "../../native/describe";
21
21
  import { Memo } from "../../native/memo";
22
22
  import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../../native/node";
23
+ import { actionButton, iconButton, quietButton, segmentedTabs } from "../../native/overlay";
23
24
  import type { TspProps, TspSpan } from "@oh-my-pi/pi-wire";
24
25
  import { TERMINAL } from "../../terminal-capabilities";
25
26
  import { truncateToWidth, visibleWidth } from "../../utils";
@@ -236,11 +237,6 @@ class GitFileTree {
236
237
  }
237
238
  }
238
239
 
239
- /** A quiet text button (`Stage all`): a clickable row the role sheet draws as `.btn.quiet`. */
240
- function quietButton(label: string, act: string, title: string): NativeNode {
241
- return node("row", { role: "omp.app.qbtn", actions: { click: act }, title }, [text(label)], act);
242
- }
243
-
244
240
  /** A text field at rest: its value (or placeholder) as text; a click starts editing it. */
245
241
  function fieldText(
246
242
  value: string,
@@ -1098,8 +1094,7 @@ export class Sidebar {
1098
1094
  }
1099
1095
 
1100
1096
  #describeStyleToggle(): NativeNode {
1101
- return node(
1102
- "tabs",
1097
+ return segmentedTabs(
1103
1098
  {
1104
1099
  items: [
1105
1100
  { id: "path", label: "Path" },
@@ -1108,7 +1103,6 @@ export class Sidebar {
1108
1103
  active: this.viewStyle,
1109
1104
  role: "omp.app.seg",
1110
1105
  },
1111
- undefined,
1112
1106
  "style",
1113
1107
  );
1114
1108
  }
@@ -1127,18 +1121,10 @@ export class Sidebar {
1127
1121
  area === "unstaged"
1128
1122
  ? [
1129
1123
  quietButton("Stage all", "stage-all", "Stage every change (space on the head)"),
1130
- node(
1131
- "icon",
1132
- {
1133
- name: "wand",
1134
- role: "omp.app.ibtn",
1135
- title: "Stage by description…",
1136
- aria: "Stage by description",
1137
- actions: { click: "stage-ai" },
1138
- },
1139
- undefined,
1140
- "ai",
1141
- ),
1124
+ iconButton("wand", "stage-ai", "Stage by description…", {
1125
+ key: "ai",
1126
+ aria: "Stage by description",
1127
+ }),
1142
1128
  ]
1143
1129
  : [quietButton("Unstage all", "unstage-all", "Unstage every change (space on the head)")];
1144
1130
  const head = row(
@@ -1263,8 +1249,13 @@ export class Sidebar {
1263
1249
  "omp.app.git.body",
1264
1250
  );
1265
1251
  const hasChanges = this.#model.staged.length > 0 || this.#model.unstaged.length > 0 || this.amend;
1266
- const canCommit =
1267
- hasChanges && !this.generating && (summary.trim().length > 0 || description.trim().length === 0);
1252
+ const blocked = !hasChanges
1253
+ ? "Nothing to commit"
1254
+ : this.generating
1255
+ ? "Generating the commit message…"
1256
+ : summary.trim().length === 0 && description.trim().length > 0
1257
+ ? "Write a summary line first"
1258
+ : undefined;
1268
1259
  const label = this.generating
1269
1260
  ? "Generating message…"
1270
1261
  : summary.trim().length === 0 && description.trim().length === 0
@@ -1272,8 +1263,13 @@ export class Sidebar {
1272
1263
  : this.#model.staged.length > 0
1273
1264
  ? "Commit"
1274
1265
  : "Stage all & commit";
1275
- // The panels' button (omp-panels.css `omp.btn`): accent while the keyboard is on it, muted when it can't run.
1276
- const commitTone = !canCommit ? "muted" : on({ kind: "commit-button" }) ? "accent" : undefined;
1266
+ // The panels' button: the primary while the keyboard is on it, disabled (saying why) when it can't run.
1267
+ const commit = actionButton(label, "commit", {
1268
+ title: this.#model.staged.length > 0 ? "Commit the staged changes" : "Stage everything, then commit",
1269
+ busy: this.generating,
1270
+ disabled: blocked,
1271
+ tone: on({ kind: "commit-button" }) ? "accent" : undefined,
1272
+ });
1277
1273
  return col(
1278
1274
  [
1279
1275
  keyed(summaryField, "summary"),
@@ -1288,17 +1284,7 @@ export class Sidebar {
1288
1284
  actions: { click: "amend" },
1289
1285
  title: "Amend the previous commit",
1290
1286
  }),
1291
- row(compact([this.generating && node("spinner", { style: "dots" }), text(label)]), {
1292
- gap: "xs",
1293
- align: "center",
1294
- role: "omp.btn",
1295
- tone: commitTone,
1296
- actions: canCommit ? { click: "commit" } : undefined,
1297
- title:
1298
- this.#model.staged.length > 0
1299
- ? "Commit the staged changes"
1300
- : "Stage everything, then commit",
1301
- }),
1287
+ commit,
1302
1288
  ],
1303
1289
  { justify: "between", align: "center", role: "omp.app.git.foot" },
1304
1290
  ),
@@ -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;
@@ -14,6 +14,8 @@ export interface FigureMarkdownOptions {
14
14
  charts: boolean;
15
15
  /** Shared inline-image budget for the figures. */
16
16
  budget?: ImageBudget;
17
+ /** Rows each figure may fill, read on every render; callers reserve prose, chrome and separators themselves. Unset or `undefined` takes a share of the terminal viewport. */
18
+ maxRows?: () => number | undefined;
17
19
  /** A figure's rows changed outside a text update (raster landed, fell back to code). */
18
20
  onChange: () => void;
19
21
  }
@@ -108,8 +110,8 @@ export class FigureMarkdown extends Container {
108
110
  return old;
109
111
  }
110
112
  reshaped = true;
111
- const { budget, onChange } = this.#options;
112
- return { kind: "chart", figure: new TableChartFigure({ budget, onChange }), table: segment.table };
113
+ const { budget, maxRows, onChange } = this.#options;
114
+ return { kind: "chart", figure: new TableChartFigure({ budget, maxRows, onChange }), table: segment.table };
113
115
  }
114
116
  if (segment.kind === "markdown") {
115
117
  const text = segment.text.trim();
@@ -127,10 +129,10 @@ export class FigureMarkdown extends Container {
127
129
  return old;
128
130
  }
129
131
  reshaped = true;
130
- const { markdown, budget, onChange } = this.#options;
132
+ const { markdown, budget, maxRows, onChange } = this.#options;
131
133
  return {
132
134
  kind: "svg",
133
- figure: new SvgFigure({ markdown, budget, onChange }),
135
+ figure: new SvgFigure({ markdown, budget, maxRows, onChange }),
134
136
  source: segment.source,
135
137
  closed: segment.closed,
136
138
  };
@@ -113,6 +113,11 @@ export interface SvgFigureOptions {
113
113
  markdown?: (text: string) => Markdown;
114
114
  /** Shared inline-image budget; superseded rasters are released from it. */
115
115
  budget?: ImageBudget;
116
+ /**
117
+ * Rows the figure may fill, fallback code included, read on every render;
118
+ * zero hides it. Unset or `undefined` takes a share of the terminal viewport.
119
+ */
120
+ maxRows?: () => number | undefined;
116
121
  /** The figure's rows changed outside a text update: a raster landed or rendering fell back to code. */
117
122
  onChange: () => void;
118
123
  }
@@ -149,12 +154,15 @@ interface Attempt {
149
154
 
150
155
  let nextFigureId = 0;
151
156
 
152
- /** Cells a figure rendered at `width` may fill: the image's width, a share of the viewport, and the raster edge cap. */
153
- function limitsFor(width: number, cell: CellDimensions): Limits {
154
- const viewportRows = Math.floor((process.stdout.rows || 24) * MAX_VIEWPORT_SHARE);
157
+ /** Cells a figure rendered at `width` may fill: the image's width, the caller's row ceiling (or a share of the viewport), and the raster edge cap. */
158
+ function limitsFor(
159
+ width: number,
160
+ cell: CellDimensions,
161
+ maxRows = Math.floor((process.stdout.rows || 24) * MAX_VIEWPORT_SHARE),
162
+ ): Limits {
155
163
  return {
156
164
  columns: Math.min(imageMaxColumns(width), Math.floor(MAX_EDGE_PX / cell.widthPx)),
157
- rows: Math.max(1, Math.min(viewportRows, Math.floor(MAX_EDGE_PX / cell.heightPx))),
165
+ rows: Math.max(1, Math.min(maxRows, Math.floor(MAX_EDGE_PX / cell.heightPx))),
158
166
  };
159
167
  }
160
168
 
@@ -178,7 +186,14 @@ export class SvgFigure implements Component {
178
186
  /** Last render width; rasters wait for the first render to know their room. */
179
187
  #width: number | undefined;
180
188
  #image: { raster: Raster; component: Image } | undefined;
181
- #fallback: { source: string; md: Markdown } | undefined;
189
+ #fallback:
190
+ | {
191
+ source: string;
192
+ md: Markdown;
193
+ /** `rows` cut to a ceiling, kept so unchanged frames return the same array. */
194
+ clip?: { of: readonly string[]; rows: readonly string[] };
195
+ }
196
+ | undefined;
182
197
  #placeholder: readonly string[] | undefined;
183
198
 
184
199
  constructor(options: SvgFigureOptions) {
@@ -203,21 +218,34 @@ export class SvgFigure implements Component {
203
218
 
204
219
  /** Whether the final source still waits for its raster (or for failing to render). */
205
220
  get pending(): boolean {
206
- return this.#final && this.#raster?.source !== this.#source && this.#failed !== this.#source;
221
+ return (
222
+ this.#maxRows() !== 0 && this.#final && this.#raster?.source !== this.#source && this.#failed !== this.#source
223
+ );
224
+ }
225
+
226
+ /** The caller's row ceiling, if any; a surface shorter than its chrome leaves none. */
227
+ #maxRows(): number | undefined {
228
+ const rows = this.#options.maxRows?.();
229
+ return rows === undefined ? undefined : Math.max(0, Math.floor(rows));
207
230
  }
208
231
 
209
232
  render(width: number): readonly string[] {
210
233
  // Width, cell size and theme are render-time inputs; a change re-rasterizes.
211
234
  this.#width = width;
212
235
  this.#schedule();
213
- if (this.#failed === this.#source) return this.#fallbackRows(width);
236
+ const maxRows = this.#maxRows();
237
+ if (maxRows === 0) return [];
238
+ if (this.#failed === this.#source) return this.#fallbackRows(width, maxRows);
214
239
  const raster = this.#raster;
215
240
  if (!raster) {
216
241
  if (!this.#options.markdown) return [];
217
242
  this.#placeholder ??= [` ${theme.fg("dim", "Drawing SVG…")}`];
218
243
  return this.#placeholder;
219
244
  }
220
- return this.#imageFor(raster).render(width);
245
+ const rows = this.#imageFor(raster, maxRows).render(width);
246
+ // A SIXEL band is six pixels whole: cells shorter than that reserve more
247
+ // rows than the ceiling, and a cut band would draw nothing anyway.
248
+ return maxRows !== undefined && rows.length > maxRows ? [] : rows;
221
249
  }
222
250
 
223
251
  invalidate(): void {
@@ -247,7 +275,7 @@ export class SvgFigure implements Component {
247
275
  const attempt = this.#attempt;
248
276
  if (this.#failed === this.#source) return false;
249
277
  const cell = getCellDimensions();
250
- const limits = limitsFor(width, cell);
278
+ const limits = limitsFor(width, cell, this.#maxRows());
251
279
  return (
252
280
  attempt === undefined ||
253
281
  attempt.source !== this.#source ||
@@ -280,6 +308,7 @@ export class SvgFigure implements Component {
280
308
  #schedule(): void {
281
309
  const width = this.#width;
282
310
  if (this.#disposed || this.#rasterizing || width === undefined) return;
311
+ if (this.#maxRows() === 0) return;
283
312
  if (this.#final && this.#timer) {
284
313
  clearTimeout(this.#timer);
285
314
  this.#timer = undefined;
@@ -302,7 +331,7 @@ export class SvgFigure implements Component {
302
331
  const attempt: Attempt = {
303
332
  source: this.#source,
304
333
  cell,
305
- limits: limitsFor(width, cell),
334
+ limits: limitsFor(width, cell, this.#maxRows()),
306
335
  themeEpoch: getThemeEpoch(),
307
336
  final: this.#final,
308
337
  };
@@ -364,13 +393,17 @@ export class SvgFigure implements Component {
364
393
  /**
365
394
  * The image for `raster` over the whole cells it was padded to. At the
366
395
  * cell size and room it was drawn for that box is its pixels exactly; a
367
- * raster outdated by a resize is scaled by {@link Image} until its redraw lands.
396
+ * raster outdated by a resize is scaled by {@link Image} until its redraw
397
+ * lands. A caller's ceiling (`maxRows`) refits the shown image at once.
368
398
  */
369
- #imageFor(raster: Raster): Image {
399
+ #imageFor(raster: Raster, maxRows = Number.POSITIVE_INFINITY): Image {
370
400
  const current = this.#image;
371
- if (current?.raster === raster) return current.component;
372
401
  const columns = raster.widthPx / raster.cell.widthPx;
373
- const rows = raster.heightPx / raster.cell.heightPx;
402
+ const rows = Math.min(raster.heightPx / raster.cell.heightPx, maxRows);
403
+ if (current?.raster === raster) {
404
+ current.component.setMaxHeightCells(rows);
405
+ return current.component;
406
+ }
374
407
  const component = new Image(
375
408
  raster.data,
376
409
  "image/png",
@@ -390,12 +423,18 @@ export class SvgFigure implements Component {
390
423
  }
391
424
 
392
425
  /** The source as the fenced code block it was written as, fenced longer than any backtick run inside. */
393
- #fallbackRows(width: number): readonly string[] {
426
+ #fallbackRows(width: number, maxRows?: number): readonly string[] {
394
427
  const source = this.#source;
395
428
  if (!this.#options.markdown) return [];
396
429
  if (this.#fallback?.source !== source) {
397
430
  this.#fallback = { source, md: this.#options.markdown(fencedCode("svg", source)) };
398
431
  }
399
- return this.#fallback.md.render(width);
432
+ const fallback = this.#fallback;
433
+ const rows = fallback.md.render(width);
434
+ if (maxRows === undefined || rows.length <= maxRows) return rows;
435
+ if (fallback.clip?.of !== rows || fallback.clip.rows.length !== maxRows) {
436
+ fallback.clip = { of: rows, rows: rows.slice(0, maxRows) };
437
+ }
438
+ return fallback.clip.rows;
400
439
  }
401
440
  }
@@ -194,6 +194,8 @@ export function chartFence(svg: string): string {
194
194
  export interface TableChartFigureOptions {
195
195
  /** Shared inline-image budget. */
196
196
  budget?: ImageBudget;
197
+ /** Rows the chart may fill, its separator included, read on every render; under two hides it. Unset or `undefined` takes a share of the terminal viewport. */
198
+ maxRows?: () => number | undefined;
197
199
  /** The chart's rows changed outside a text update: its pick or raster landed. */
198
200
  onChange: () => void;
199
201
  }
@@ -215,7 +217,17 @@ export class TableChartFigure implements Component {
215
217
 
216
218
  constructor(options: TableChartFigureOptions) {
217
219
  this.#options = options;
218
- this.#figure = new SvgFigure({ budget: options.budget, onChange: options.onChange });
220
+ this.#figure = new SvgFigure({
221
+ budget: options.budget,
222
+ maxRows: () => this.#figureRows(),
223
+ onChange: options.onChange,
224
+ });
225
+ }
226
+
227
+ /** The image's share of the caller's ceiling: all but the separator row. */
228
+ #figureRows(): number | undefined {
229
+ const rows = this.#options.maxRows?.();
230
+ return rows === undefined ? undefined : Math.max(0, Math.floor(rows) - 1);
219
231
  }
220
232
 
221
233
  /** Point the figure at `table`. */
@@ -228,10 +240,15 @@ export class TableChartFigure implements Component {
228
240
 
229
241
  /** Whether the chart is still being looked up, picked or drawn. */
230
242
  get pending(): boolean {
231
- return this.#table !== undefined && (this.#chart === undefined || (this.#chart !== null && this.#figure.pending));
243
+ return (
244
+ this.#figureRows() !== 0 &&
245
+ this.#table !== undefined &&
246
+ (this.#chart === undefined || (this.#chart !== null && this.#figure.pending))
247
+ );
232
248
  }
233
249
 
234
250
  render(width: number): readonly string[] {
251
+ if (this.#figureRows() === 0) return [];
235
252
  if (this.#chart === undefined && !this.#waiting) this.#resolve();
236
253
  if (!this.#chart) return [];
237
254
  const rows = this.#figure.render(width);
@@ -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
 
@@ -744,6 +744,19 @@ export class Image implements Component {
744
744
  this.#cachedWidth = undefined;
745
745
  }
746
746
 
747
+ /**
748
+ * Refit to a new row ceiling; a SIXEL reserves its rows until the off-thread
749
+ * encode lands. A demoted fallback keeps the height already drawn only
750
+ * within the new ceiling: the owner chose to shrink the block.
751
+ */
752
+ setMaxHeightCells(maxHeightCells: number): void {
753
+ if (this.#options.maxHeightCells === maxHeightCells) return;
754
+ this.#options.maxHeightCells = maxHeightCells;
755
+ this.#renderedGraphicRows = Math.min(this.#renderedGraphicRows, maxHeightCells);
756
+ this.#native = undefined;
757
+ this.invalidate();
758
+ }
759
+
747
760
  releaseRenderCaches(): void {
748
761
  this.#cachedLines = undefined;
749
762
  this.#cachedWidth = undefined;
@@ -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
  }