@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.
- package/CHANGELOG.md +36 -0
- package/README.md +6 -0
- package/dist/types/charts/table-data.d.ts +4 -2
- package/dist/types/chat/figure-markdown.d.ts +2 -0
- package/dist/types/chat/svg-figure.d.ts +5 -0
- package/dist/types/chat/table-chart.d.ts +2 -0
- package/dist/types/chat/transcript-render-helpers.d.ts +3 -3
- package/dist/types/components/image.d.ts +6 -0
- package/dist/types/native/describe.d.ts +4 -2
- package/dist/types/native/overlay.d.ts +42 -2
- package/dist/types/native/state.d.ts +4 -0
- package/dist/types/overlays/btw-history-panel.d.ts +2 -0
- package/dist/types/prompt/composer.d.ts +13 -5
- package/dist/types/render/render-utils.d.ts +2 -0
- package/dist/types/theme/theme.d.ts +1 -1
- package/dist/types/theme/tui-adapters.d.ts +2 -0
- package/dist/types/thinking.d.ts +8 -6
- package/dist/types/tools/ast-grep.d.ts +2 -0
- package/dist/types/tools/eval.d.ts +2 -0
- package/dist/types/tools/find.d.ts +1 -1
- package/dist/types/tools/grouped-file-output.d.ts +24 -11
- package/dist/types/tools/read.d.ts +4 -4
- package/dist/types/tools/task.d.ts +2 -2
- package/package.json +9 -9
- package/src/apps/git/git-tui.ts +10 -17
- package/src/apps/git/sidebar.ts +21 -35
- package/src/apps/ps-top.ts +4 -12
- package/src/charts/chart-plan.ts +0 -29
- package/src/charts/table-data.ts +55 -6
- package/src/chat/figure-markdown.ts +6 -4
- package/src/chat/svg-figure.ts +55 -16
- package/src/chat/table-chart.ts +19 -2
- package/src/chat/transcript-render-helpers.ts +18 -16
- package/src/components/image.ts +13 -0
- package/src/native/backend.ts +7 -3
- package/src/native/describe.ts +9 -4
- package/src/native/overlay.ts +124 -16
- package/src/native/state.ts +25 -5
- package/src/overlays/btw-history-panel.ts +8 -0
- package/src/overlays/extensions/extension-dashboard.ts +7 -8
- package/src/overlays/jobs-panel.ts +38 -4
- package/src/overlays/login-dialog.ts +8 -26
- package/src/overlays/tree-selector.ts +81 -27
- package/src/overlays/usage-dashboard.ts +2 -4
- package/src/prompt/composer.ts +17 -5
- package/src/render/render-utils.ts +2 -0
- package/src/theme/theme-class.ts +12 -3
- package/src/theme/theme.ts +1 -0
- package/src/theme/tui-adapters.ts +8 -3
- package/src/thinking.ts +17 -11
- package/src/tools/ast-grep.ts +10 -2
- package/src/tools/eval.ts +2 -0
- package/src/tools/find.ts +7 -2
- package/src/tools/grep.ts +8 -2
- package/src/tools/grouped-file-output.ts +172 -47
- package/src/tools/lsp.ts +158 -8
- package/src/tools/read.ts +6 -5
- package/src/tools/task.ts +2 -2
package/src/apps/git/sidebar.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1131
|
-
"
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
|
1267
|
-
|
|
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
|
|
1276
|
-
const
|
|
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
|
-
|
|
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
|
),
|
package/src/apps/ps-top.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
+
iconButton("x", "quit", "Quit q", { aria: "Quit" }),
|
|
472
464
|
);
|
|
473
465
|
return keyed(
|
|
474
466
|
row(
|
package/src/charts/chart-plan.ts
CHANGED
|
@@ -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
|
package/src/charts/table-data.ts
CHANGED
|
@@ -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)
|
|
112
|
-
* (`Offset` summing `Verts`)
|
|
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,
|
|
910
|
-
* (`Offset` stepping by `Verts`)
|
|
911
|
-
* stays, unless the second is a share
|
|
912
|
-
* table bolded more of its cells
|
|
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
|
};
|
package/src/chat/svg-figure.ts
CHANGED
|
@@ -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(
|
|
154
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/chat/table-chart.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
|
141
|
-
* `irc:relay`) as a
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
package/src/components/image.ts
CHANGED
|
@@ -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;
|
package/src/native/backend.ts
CHANGED
|
@@ -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
|
|
package/src/native/describe.ts
CHANGED
|
@@ -132,10 +132,15 @@ export function elapsed(age: number, stopped?: boolean): NativeNode {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
138
|
+
export function keyNames(key: string): string[] {
|
|
139
139
|
const keys = key === "+" ? ["+"] : key.endsWith("++") ? [...key.slice(0, -2).split("+"), "+"] : key.split("+");
|
|
140
|
-
return
|
|
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
|
}
|