@oh-my-pi/pi-tui 18.14.0 → 18.14.1
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 +12 -0
- package/README.md +6 -0
- 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/components/image.d.ts +6 -0
- package/dist/types/overlays/btw-history-panel.d.ts +2 -0
- package/package.json +9 -9
- 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/components/image.ts +13 -0
- package/src/overlays/btw-history-panel.ts +8 -0
- package/src/overlays/jobs-panel.ts +38 -4
- package/src/overlays/tree-selector.ts +21 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.14.1] - 2026-10-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a `maxRows` callback to `FigureMarkdown`, `SvgFigure` and `TableChartFigure` so surfaces shorter than the screen can fit their figures; zero hides a figure without delaying transcript completion ([#15661](https://github.com/can1357/oh-my-pi/issues/15661), [#15853](https://github.com/can1357/oh-my-pi/pull/15853) by [@KunoLu](https://github.com/KunoLu))
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed IME composition text in the BTW history `Follow up:` input appearing at the left edge instead of at the caret when the hardware cursor is enabled ([#17427](https://github.com/can1357/oh-my-pi/issues/17427))
|
|
14
|
+
- Fixed session tree rows overlapping when messages, tool arguments, or labels contain carriage returns or Unicode line/paragraph separators (`U+2028`, `U+2029`), while keeping multiline formatting in the selected entry preview ([#17430](https://github.com/can1357/oh-my-pi/pull/17430) by [@lincicomb](https://github.com/lincicomb)).
|
|
15
|
+
- Fixed the Background jobs sheet dropping a finished job and its output while you were reading it; the selected job now stays until you select another ([#17506](https://github.com/can1357/oh-my-pi/pull/17506) by [@H4vC](https://github.com/H4vC))
|
|
16
|
+
|
|
5
17
|
## [18.14.0] - 2026-10-29
|
|
6
18
|
|
|
7
19
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -521,6 +521,12 @@ SIXEL images encode off the JavaScript thread: the first render reserves the ima
|
|
|
521
521
|
|
|
522
522
|
Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
|
|
523
523
|
|
|
524
|
+
### Figures
|
|
525
|
+
|
|
526
|
+
`FigureMarkdown`, `SvgFigure`, and `TableChartFigure` size their images to a share of the terminal viewport. A surface shorter than the screen (an overlay, a pane) passes `maxRows: () => rows`, read on every render: the rows each figure may fill, a table chart's separator included. Zero hides a figure and keeps it from holding up transcript completion. Callers reserve room for prose and chrome themselves.
|
|
527
|
+
|
|
528
|
+
Lowering the ceiling refits the drawn image at once and redraws it at the new size; a SIXEL reserves its rows while each re-encode runs off the JavaScript thread, as for a width change. Changing the ceiling of rows already committed to native scrollback forces a resync, so leave committed figures alone.
|
|
529
|
+
|
|
524
530
|
## Autocomplete
|
|
525
531
|
|
|
526
532
|
### CombinedAutocompleteProvider
|
|
@@ -8,6 +8,8 @@ export interface FigureMarkdownOptions {
|
|
|
8
8
|
charts: boolean;
|
|
9
9
|
/** Shared inline-image budget for the figures. */
|
|
10
10
|
budget?: ImageBudget;
|
|
11
|
+
/** 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. */
|
|
12
|
+
maxRows?: () => number | undefined;
|
|
11
13
|
/** A figure's rows changed outside a text update (raster landed, fell back to code). */
|
|
12
14
|
onChange: () => void;
|
|
13
15
|
}
|
|
@@ -16,6 +16,11 @@ export interface SvgFigureOptions {
|
|
|
16
16
|
markdown?: (text: string) => Markdown;
|
|
17
17
|
/** Shared inline-image budget; superseded rasters are released from it. */
|
|
18
18
|
budget?: ImageBudget;
|
|
19
|
+
/**
|
|
20
|
+
* Rows the figure may fill, fallback code included, read on every render;
|
|
21
|
+
* zero hides it. Unset or `undefined` takes a share of the terminal viewport.
|
|
22
|
+
*/
|
|
23
|
+
maxRows?: () => number | undefined;
|
|
19
24
|
/** The figure's rows changed outside a text update: a raster landed or rendering fell back to code. */
|
|
20
25
|
onChange: () => void;
|
|
21
26
|
}
|
|
@@ -64,6 +64,8 @@ export declare function chartFence(svg: string): string;
|
|
|
64
64
|
export interface TableChartFigureOptions {
|
|
65
65
|
/** Shared inline-image budget. */
|
|
66
66
|
budget?: ImageBudget;
|
|
67
|
+
/** 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. */
|
|
68
|
+
maxRows?: () => number | undefined;
|
|
67
69
|
/** The chart's rows changed outside a text update: its pick or raster landed. */
|
|
68
70
|
onChange: () => void;
|
|
69
71
|
}
|
|
@@ -205,6 +205,12 @@ export declare class Image implements Component {
|
|
|
205
205
|
/** Return source metadata without exposing the encoded image buffer. */
|
|
206
206
|
debugState(): Record<string, unknown>;
|
|
207
207
|
invalidate(): void;
|
|
208
|
+
/**
|
|
209
|
+
* Refit to a new row ceiling; a SIXEL reserves its rows until the off-thread
|
|
210
|
+
* encode lands. A demoted fallback keeps the height already drawn only
|
|
211
|
+
* within the new ceiling: the owner chose to shrink the block.
|
|
212
|
+
*/
|
|
213
|
+
setMaxHeightCells(maxHeightCells: number): void;
|
|
208
214
|
releaseRenderCaches(): void;
|
|
209
215
|
/**
|
|
210
216
|
* A native `image` backed by a content-addressed blob; the terminal fits
|
|
@@ -33,6 +33,8 @@ export declare class BtwHistoryPanel implements Component, Focusable {
|
|
|
33
33
|
constructor(options: BtwHistoryPanelOptions);
|
|
34
34
|
get focused(): boolean;
|
|
35
35
|
set focused(value: boolean);
|
|
36
|
+
/** Keep the follow-up input's cursor mode aligned so the hardware cursor (and IME preedit) lands on its caret. */
|
|
37
|
+
setUseTerminalCursor(useTerminalCursor: boolean): void;
|
|
36
38
|
update(records: readonly BtwHistoryRecord[]): void;
|
|
37
39
|
/** Visual confirmation that `c` copied this record's answer to the clipboard. */
|
|
38
40
|
markCopied(recordId: string, text: string): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.14.
|
|
4
|
+
"version": "18.14.1",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@oh-my-pi/omptype": "18.14.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.14.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.14.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.14.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.14.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.14.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.14.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.14.
|
|
44
|
+
"@oh-my-pi/omptype": "18.14.1",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "18.14.1",
|
|
46
|
+
"@oh-my-pi/pi-ai": "18.14.1",
|
|
47
|
+
"@oh-my-pi/pi-catalog": "18.14.1",
|
|
48
|
+
"@oh-my-pi/pi-natives": "18.14.1",
|
|
49
|
+
"@oh-my-pi/pi-utils": "18.14.1",
|
|
50
|
+
"@oh-my-pi/pi-wire": "18.14.1",
|
|
51
|
+
"@oh-my-pi/snapcompact": "18.14.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"kitty-vt-wasm": "^0.2.0"
|
|
@@ -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);
|
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;
|
|
@@ -150,6 +150,7 @@ export class BtwHistoryPanel implements Component, Focusable {
|
|
|
150
150
|
#listScroll = 0;
|
|
151
151
|
#listHeight = 1;
|
|
152
152
|
#focused = false;
|
|
153
|
+
#useTerminalCursor = false;
|
|
153
154
|
#composer: FollowUpComposer | undefined;
|
|
154
155
|
readonly #pasteHandler = new BracketedPasteHandler();
|
|
155
156
|
#followUpPending = false;
|
|
@@ -225,6 +226,12 @@ export class BtwHistoryPanel implements Component, Focusable {
|
|
|
225
226
|
this.#focused = value;
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/** Keep the follow-up input's cursor mode aligned so the hardware cursor (and IME preedit) lands on its caret. */
|
|
230
|
+
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
231
|
+
this.#useTerminalCursor = useTerminalCursor;
|
|
232
|
+
this.#composer?.input.setUseTerminalCursor(useTerminalCursor);
|
|
233
|
+
}
|
|
234
|
+
|
|
228
235
|
update(records: readonly BtwHistoryRecord[]): void {
|
|
229
236
|
this.#records = records;
|
|
230
237
|
this.#detailDirty = true;
|
|
@@ -329,6 +336,7 @@ export class BtwHistoryPanel implements Component, Focusable {
|
|
|
329
336
|
const input = new Input();
|
|
330
337
|
input.prompt = theme.fg("accent", "Follow up: ");
|
|
331
338
|
input.placeholder = "Ask about this answer…";
|
|
339
|
+
input.setUseTerminalCursor(this.#useTerminalCursor);
|
|
332
340
|
if (this.#options.spaceHoldKeys !== undefined) input.spaceHold.keys = this.#options.spaceHoldKeys;
|
|
333
341
|
input.spaceHold.handler = this.#options.spaceHold?.(input);
|
|
334
342
|
const composer: FollowUpComposer = {
|
|
@@ -130,6 +130,12 @@ export class JobsSheet implements Component {
|
|
|
130
130
|
#native: { key: string; output: string | undefined; node: NativeNode } | undefined;
|
|
131
131
|
/** Fallback output tail, reused while the selected job's output is unchanged. */
|
|
132
132
|
#tail: { id: string; output: string; lines: readonly string[] } | undefined;
|
|
133
|
+
/**
|
|
134
|
+
* The selected job as last seen: its row, detail and the subagents placing
|
|
135
|
+
* it. The session evicts settled jobs on its own clock; while the user keeps
|
|
136
|
+
* one selected it stays listed with its last output, until they select another.
|
|
137
|
+
*/
|
|
138
|
+
#held: { job: JobsPanelJob; detail: JobsPanelDetail | undefined; agents: readonly JobTreeAgent[] } | undefined;
|
|
133
139
|
|
|
134
140
|
constructor(source: JobsSheetSource) {
|
|
135
141
|
this.#source = source;
|
|
@@ -137,7 +143,7 @@ export class JobsSheet implements Component {
|
|
|
137
143
|
|
|
138
144
|
describe(_cx: DescribeContext): NativeNode {
|
|
139
145
|
const { rows, jobs, selected, cancellable } = this.#current();
|
|
140
|
-
const detail = selected && this.#
|
|
146
|
+
const detail = selected && this.#inspect(selected.id);
|
|
141
147
|
const now = Date.now();
|
|
142
148
|
// Elapsed nodes tick on their own; list values and the output pane redraw
|
|
143
149
|
// when a job starts, settles, leaves or moves in the tree, its process
|
|
@@ -201,7 +207,7 @@ export class JobsSheet implements Component {
|
|
|
201
207
|
),
|
|
202
208
|
);
|
|
203
209
|
}
|
|
204
|
-
const detail = selected && this.#
|
|
210
|
+
const detail = selected && this.#inspect(selected.id);
|
|
205
211
|
if (detail) {
|
|
206
212
|
// Rows show the cut label; the selected job's command wraps here,
|
|
207
213
|
// bounded so a long script never pushes the facts and output off the sheet.
|
|
@@ -262,15 +268,43 @@ export class JobsSheet implements Component {
|
|
|
262
268
|
selected: JobsPanelJob | undefined;
|
|
263
269
|
cancellable: boolean;
|
|
264
270
|
} {
|
|
265
|
-
const
|
|
266
|
-
const
|
|
271
|
+
const snapshot = this.#source.load();
|
|
272
|
+
const { running, rootId } = snapshot;
|
|
273
|
+
let { recent, agents = [] } = snapshot;
|
|
274
|
+
// A settled job the session evicted stays listed while it is selected.
|
|
275
|
+
const held = this.#held?.job.id === this.#selectedId ? this.#held : undefined;
|
|
276
|
+
const evicted =
|
|
277
|
+
held !== undefined &&
|
|
278
|
+
held.job.status !== "running" &&
|
|
279
|
+
!running.some(job => job.id === held.job.id) &&
|
|
280
|
+
!recent.some(job => job.id === held.job.id);
|
|
281
|
+
if (evicted) {
|
|
282
|
+
recent = [...recent, held.job];
|
|
283
|
+
const known = new Set(agents.map(agent => agent.id));
|
|
284
|
+
agents = [...agents, ...held.agents.filter(agent => !known.has(agent.id))];
|
|
285
|
+
}
|
|
286
|
+
const rows = buildJobTree([...running, ...recent], agents, rootId);
|
|
267
287
|
const jobs = rows.flatMap(entry => (entry.kind === "job" ? [entry.job] : []));
|
|
268
288
|
const selected = jobs.find(job => job.id === this.#selectedId) ?? jobs[0];
|
|
269
289
|
this.#selectedId = selected?.id;
|
|
290
|
+
if (!selected) this.#held = undefined;
|
|
291
|
+
else if (!evicted) {
|
|
292
|
+
const detail = held?.job.id === selected.id ? held.detail : undefined;
|
|
293
|
+
this.#held = { job: selected, detail, agents: snapshot.agents ?? [] };
|
|
294
|
+
}
|
|
270
295
|
const owned = selected?.ownerId === undefined || selected.ownerId === rootId;
|
|
271
296
|
return { rows, jobs, selected, cancellable: this.#source.cancel !== undefined && owned };
|
|
272
297
|
}
|
|
273
298
|
|
|
299
|
+
/** The job's detail from the source, else the last one seen while it was held. */
|
|
300
|
+
#inspect(id: string): JobsPanelDetail | undefined {
|
|
301
|
+
const detail = this.#source.inspect(id);
|
|
302
|
+
const held = this.#held?.job.id === id ? this.#held : undefined;
|
|
303
|
+
if (!held) return detail;
|
|
304
|
+
if (detail) held.detail = detail;
|
|
305
|
+
return held.detail;
|
|
306
|
+
}
|
|
307
|
+
|
|
274
308
|
#tailLines(id: string, output: string): readonly string[] {
|
|
275
309
|
const cached = this.#tail;
|
|
276
310
|
if (cached?.id === id && cached.output === output) return cached.lines;
|
|
@@ -94,10 +94,15 @@ interface AdvisorTreeDisplay {
|
|
|
94
94
|
*/
|
|
95
95
|
function sanitizeTreeField(value: string): string {
|
|
96
96
|
return sanitizeText(value)
|
|
97
|
-
.replace(/[\n\t]/g, " ")
|
|
97
|
+
.replace(/[\r\n\t\u2028\u2029]/g, " ")
|
|
98
98
|
.trim();
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Strip ANSI/control characters and fold line breaks for single-line tree rows and headers. */
|
|
102
|
+
function treeRowText(value: string): string {
|
|
103
|
+
return plainText(value.replace(/[\r\n\u2028\u2029]/g, " "));
|
|
104
|
+
}
|
|
105
|
+
|
|
101
106
|
/**
|
|
102
107
|
* Extract display metadata from an advisor custom-message's `details.notes`,
|
|
103
108
|
* ignoring the model-facing `<advisory>` wrapper stored in `content`. Collects
|
|
@@ -734,9 +739,7 @@ class TreeList implements Component {
|
|
|
734
739
|
#pickerItem(row: TreeRow<TreeSelectorNode, string>, open: boolean): TspPickerItem {
|
|
735
740
|
const item = row.item;
|
|
736
741
|
const entry = item.entry;
|
|
737
|
-
const label = this.#entryParts(item).map(part =>
|
|
738
|
-
span(part.t.includes("\x1b") ? plainText(part.t) : part.t, part.s),
|
|
739
|
-
);
|
|
742
|
+
const label = this.#entryParts(item).map(part => span(treeRowText(part.t), part.s));
|
|
740
743
|
let kind: TspPickerItem["node"] = "marker";
|
|
741
744
|
let role: string | undefined;
|
|
742
745
|
if (isUserRequestEntry(entry)) {
|
|
@@ -755,7 +758,7 @@ class TreeList implements Component {
|
|
|
755
758
|
node: kind,
|
|
756
759
|
...(role ? { role } : {}),
|
|
757
760
|
...(open ? { open: true } : {}),
|
|
758
|
-
...(item.label ? { badges: [{ text:
|
|
761
|
+
...(item.label ? { badges: [{ text: treeRowText(item.label), tone: "warning" as const }] } : {}),
|
|
759
762
|
...(this.#activePathIds.has(row.key) ? { dot: "accent" as const } : {}),
|
|
760
763
|
};
|
|
761
764
|
}
|
|
@@ -772,7 +775,7 @@ class TreeList implements Component {
|
|
|
772
775
|
if (selected) {
|
|
773
776
|
const entry = selected.entry;
|
|
774
777
|
const parts = this.#entryParts(selected)
|
|
775
|
-
.map(part => (part.t
|
|
778
|
+
.map(part => treeRowText(part.t))
|
|
776
779
|
.join("");
|
|
777
780
|
if (entry.type === "message" || entry.type === "custom_message") {
|
|
778
781
|
const target: OutlineTarget = {
|
|
@@ -784,12 +787,14 @@ class TreeList implements Component {
|
|
|
784
787
|
entries: [entry],
|
|
785
788
|
};
|
|
786
789
|
const copy = targetCopy(target, collectBlocks(target.entries));
|
|
787
|
-
preview.push(
|
|
788
|
-
|
|
790
|
+
preview.push(
|
|
791
|
+
text(treeRowText(`${copy.label[0]!.toUpperCase()}${copy.label.slice(1)}`), { role: "omp.picker.title" }),
|
|
792
|
+
);
|
|
793
|
+
if (selected.label) preview.push(text([span(treeRowText(selected.label), "warning")]));
|
|
789
794
|
preview.push(turnPreview(target, copy.content || parts));
|
|
790
795
|
} else {
|
|
791
796
|
preview.push(text(parts, { role: "omp.picker.title" }));
|
|
792
|
-
if (selected.label) preview.push(text([span(
|
|
797
|
+
if (selected.label) preview.push(text([span(treeRowText(selected.label), "warning")]));
|
|
793
798
|
}
|
|
794
799
|
}
|
|
795
800
|
this.#previewMemo = { node: selected, labels: this.#labelVersion, preview };
|
|
@@ -859,9 +864,9 @@ class TreeList implements Component {
|
|
|
859
864
|
if (cached !== undefined && cached.label === item.label && cached.branch === branch) return cached.node;
|
|
860
865
|
const label: TspSpan[] = [];
|
|
861
866
|
if (this.#activePathIds.has(row.key)) label.push(span(`${theme.md.bullet} `, "accent"));
|
|
862
|
-
if (item.label) label.push(span(`[${
|
|
867
|
+
if (item.label) label.push(span(`[${treeRowText(item.label)}] `, "warning"));
|
|
863
868
|
for (const part of this.#entryParts(item)) {
|
|
864
|
-
label.push(span(
|
|
869
|
+
label.push(span(treeRowText(part.t), part.s));
|
|
865
870
|
}
|
|
866
871
|
const described = node("item", { label, icon: branch ? "branch" : undefined }, undefined, row.key);
|
|
867
872
|
this.#itemCache.set(row.key, { label: item.label, branch, node: described });
|
|
@@ -954,7 +959,7 @@ class TreeList implements Component {
|
|
|
954
959
|
// Active path marker - shown right before the entry text
|
|
955
960
|
const isOnActivePath = this.#activePathIds.has(node.entry.id);
|
|
956
961
|
const pathMarker = isOnActivePath ? theme.fg("accent", `${theme.md.bullet} `) : "";
|
|
957
|
-
const label = node.label ? theme.fg("warning", `[${node.label}] `) : "";
|
|
962
|
+
const label = node.label ? theme.fg("warning", `[${treeRowText(node.label)}] `) : "";
|
|
958
963
|
return `${pathMarker}${label}${this.#getEntryDisplayText(node, selected)}`;
|
|
959
964
|
}
|
|
960
965
|
|
|
@@ -1050,7 +1055,10 @@ class TreeList implements Component {
|
|
|
1050
1055
|
|
|
1051
1056
|
#getEntryDisplayText(node: TreeSelectorNode, isSelected: boolean): string {
|
|
1052
1057
|
let result = "";
|
|
1053
|
-
for (const part of this.#entryParts(node))
|
|
1058
|
+
for (const part of this.#entryParts(node)) {
|
|
1059
|
+
const value = treeRowText(part.t);
|
|
1060
|
+
result += part.s ? theme.fg(part.s, value) : value;
|
|
1061
|
+
}
|
|
1054
1062
|
return isSelected ? theme.bold(result) : result;
|
|
1055
1063
|
}
|
|
1056
1064
|
|