@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 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.0",
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.0",
45
- "@oh-my-pi/pi-agent-core": "18.14.0",
46
- "@oh-my-pi/pi-ai": "18.14.0",
47
- "@oh-my-pi/pi-catalog": "18.14.0",
48
- "@oh-my-pi/pi-natives": "18.14.0",
49
- "@oh-my-pi/pi-utils": "18.14.0",
50
- "@oh-my-pi/pi-wire": "18.14.0",
51
- "@oh-my-pi/snapcompact": "18.14.0"
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
  };
@@ -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);
@@ -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.#source.inspect(selected.id);
146
+ const detail = selected && this.#inspect(selected.id);
141
147
  const now = Date.now();
142
148
  // Elapsed nodes tick on their own; list values and the output pane redraw
143
149
  // when a job starts, settles, leaves or moves in the tree, its process
@@ -201,7 +207,7 @@ export class JobsSheet implements Component {
201
207
  ),
202
208
  );
203
209
  }
204
- const detail = selected && this.#source.inspect(selected.id);
210
+ const detail = selected && this.#inspect(selected.id);
205
211
  if (detail) {
206
212
  // Rows show the cut label; the selected job's command wraps here,
207
213
  // bounded so a long script never pushes the facts and output off the sheet.
@@ -262,15 +268,43 @@ export class JobsSheet implements Component {
262
268
  selected: JobsPanelJob | undefined;
263
269
  cancellable: boolean;
264
270
  } {
265
- const { running, recent, rootId, agents } = this.#source.load();
266
- const rows = buildJobTree([...running, ...recent], agents ?? [], rootId);
271
+ const snapshot = this.#source.load();
272
+ const { running, rootId } = snapshot;
273
+ let { recent, agents = [] } = snapshot;
274
+ // A settled job the session evicted stays listed while it is selected.
275
+ const held = this.#held?.job.id === this.#selectedId ? this.#held : undefined;
276
+ const evicted =
277
+ held !== undefined &&
278
+ held.job.status !== "running" &&
279
+ !running.some(job => job.id === held.job.id) &&
280
+ !recent.some(job => job.id === held.job.id);
281
+ if (evicted) {
282
+ recent = [...recent, held.job];
283
+ const known = new Set(agents.map(agent => agent.id));
284
+ agents = [...agents, ...held.agents.filter(agent => !known.has(agent.id))];
285
+ }
286
+ const rows = buildJobTree([...running, ...recent], agents, rootId);
267
287
  const jobs = rows.flatMap(entry => (entry.kind === "job" ? [entry.job] : []));
268
288
  const selected = jobs.find(job => job.id === this.#selectedId) ?? jobs[0];
269
289
  this.#selectedId = selected?.id;
290
+ if (!selected) this.#held = undefined;
291
+ else if (!evicted) {
292
+ const detail = held?.job.id === selected.id ? held.detail : undefined;
293
+ this.#held = { job: selected, detail, agents: snapshot.agents ?? [] };
294
+ }
270
295
  const owned = selected?.ownerId === undefined || selected.ownerId === rootId;
271
296
  return { rows, jobs, selected, cancellable: this.#source.cancel !== undefined && owned };
272
297
  }
273
298
 
299
+ /** The job's detail from the source, else the last one seen while it was held. */
300
+ #inspect(id: string): JobsPanelDetail | undefined {
301
+ const detail = this.#source.inspect(id);
302
+ const held = this.#held?.job.id === id ? this.#held : undefined;
303
+ if (!held) return detail;
304
+ if (detail) held.detail = detail;
305
+ return held.detail;
306
+ }
307
+
274
308
  #tailLines(id: string, output: string): readonly string[] {
275
309
  const cached = this.#tail;
276
310
  if (cached?.id === id && cached.output === output) return cached.lines;
@@ -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: plainText(item.label), tone: "warning" as const }] } : {}),
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.includes("\x1b") ? plainText(part.t) : 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(text(`${copy.label[0]!.toUpperCase()}${copy.label.slice(1)}`, { role: "omp.picker.title" }));
788
- if (selected.label) preview.push(text([span(plainText(selected.label), "warning")]));
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(plainText(selected.label), "warning")]));
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(`[${plainText(item.label)}] `, "warning"));
867
+ if (item.label) label.push(span(`[${treeRowText(item.label)}] `, "warning"));
863
868
  for (const part of this.#entryParts(item)) {
864
- label.push(span(part.t.includes("\x1b") ? plainText(part.t) : part.t, part.s));
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)) result += part.s ? theme.fg(part.s, part.t) : part.t;
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