@oh-my-pi/pi-tui 18.3.0 → 18.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/THIRD-PARTY-NOTICES.txt +2 -2
  3. package/dist/types/chat/read-tool-group.d.ts +3 -3
  4. package/dist/types/chat/user-message.d.ts +4 -1
  5. package/dist/types/components/editor.d.ts +0 -2
  6. package/dist/types/components/input.d.ts +16 -0
  7. package/dist/types/overlays/btw-history-panel.d.ts +4 -1
  8. package/dist/types/overlays/rewind-selector.d.ts +4 -2
  9. package/dist/types/overlays/settings-defs.d.ts +5 -0
  10. package/dist/types/prompt/composer.d.ts +12 -1
  11. package/dist/types/prompt/custom-editor.d.ts +5 -26
  12. package/dist/types/space-hold.d.ts +45 -0
  13. package/dist/types/status-line/host.d.ts +2 -0
  14. package/dist/types/tools/cfg-render.d.ts +37 -0
  15. package/dist/types/tools/cfg-url.d.ts +16 -0
  16. package/dist/types/tools/find.d.ts +2 -2
  17. package/dist/types/tools/output-meta.d.ts +2 -0
  18. package/dist/types/tools/proc-render.d.ts +1 -8
  19. package/dist/types/tools/read.d.ts +14 -3
  20. package/dist/types/tools/result-card.d.ts +20 -0
  21. package/dist/types/tools/url-scheme-host.d.ts +29 -0
  22. package/dist/types/tools/write.d.ts +2 -0
  23. package/dist/types/tools/xdev.d.ts +9 -1
  24. package/package.json +9 -9
  25. package/src/chat/chat-transcript-builder.ts +5 -1
  26. package/src/chat/read-tool-group.ts +8 -9
  27. package/src/chat/tool-execution.ts +7 -5
  28. package/src/chat/user-message.ts +16 -6
  29. package/src/components/editor.ts +2 -4
  30. package/src/components/input.ts +103 -30
  31. package/src/components/markdown.ts +18 -6
  32. package/src/overlays/annotation-overlay.ts +0 -1
  33. package/src/overlays/btw-history-panel.ts +4 -0
  34. package/src/overlays/model-hub.ts +18 -6
  35. package/src/overlays/rewind-selector.ts +186 -12
  36. package/src/overlays/settings-defs.ts +6 -0
  37. package/src/overlays/settings-selector.ts +4 -3
  38. package/src/prompt/composer.ts +83 -56
  39. package/src/prompt/custom-editor.ts +15 -137
  40. package/src/prompt/tips.txt +1 -0
  41. package/src/space-hold.ts +143 -0
  42. package/src/status-line/component.ts +6 -0
  43. package/src/status-line/host.ts +2 -0
  44. package/src/status-line/segments.ts +4 -0
  45. package/src/tools/cfg-render.ts +117 -0
  46. package/src/tools/cfg-url.ts +30 -0
  47. package/src/tools/find.ts +7 -6
  48. package/src/tools/output-meta.ts +2 -0
  49. package/src/tools/proc-render.ts +2 -26
  50. package/src/tools/read.ts +109 -68
  51. package/src/tools/result-card.ts +36 -0
  52. package/src/tools/url-scheme-host.ts +42 -0
  53. package/src/tools/write.ts +109 -66
  54. package/src/tools/xdev.ts +9 -1
  55. package/dist/types/chat/read-target.d.ts +0 -4
  56. package/src/chat/read-target.ts +0 -7
@@ -19,12 +19,15 @@
19
19
  *
20
20
  * Keys: Up/Down step through rendered items in transcript order (within the
21
21
  * active column when a strip is open), Left/Right slide between branch
22
- * variants at a fork and jump between user turns elsewhere, Enter rewinds to
23
- * the outlined item, Esc cancels.
22
+ * variants at a fork and jump between user turns elsewhere, `f` opens a
23
+ * filter (typing narrows the current path to matching items; Esc leaves the
24
+ * filter with the selection kept), Enter rewinds to the outlined item, Esc
25
+ * cancels.
24
26
  */
25
27
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
26
28
  import {
27
29
  type Component,
30
+ extractPrintableText,
28
31
  matchesKey,
29
32
  padding,
30
33
  routeSgrMouseInput,
@@ -110,6 +113,13 @@ export class RewindSelectorComponent implements Component {
110
113
  #slide: { from: number; to: number; startedAt: number } | undefined;
111
114
  #slideTimer: NodeJS.Timeout | undefined;
112
115
 
116
+ /** Filter query while the filter prompt is open; undefined shows the full transcript. */
117
+ #filter: string | undefined;
118
+ /** Main transcript rows from the last frame; the filter matches what is on screen. */
119
+ #mainRows: readonly (readonly string[])[] = [];
120
+ /** Lowercased plain text per rendered child row array (row arrays are cached, so identity is stable). */
121
+ #rowText = new WeakMap<readonly string[], string>();
122
+
113
123
  constructor(
114
124
  entries: TranscriptEntry[],
115
125
  private readonly deps: RewindSelectorDeps,
@@ -235,19 +245,26 @@ export class RewindSelectorComponent implements Component {
235
245
  });
236
246
  return;
237
247
  }
248
+ if (this.#filter !== undefined) {
249
+ this.#handleFilterInput(data);
250
+ return;
251
+ }
238
252
  if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
239
253
  this.deps.onCancel();
240
254
  return;
241
255
  }
242
- if (matchesAppToolsExpand(data)) {
243
- this.#expanded = !this.#expanded;
244
- this.#builder.setExpanded(this.#expanded);
245
- for (const columns of this.#variantCache.values()) {
246
- for (const column of columns) column.builder.setExpanded(this.#expanded);
247
- }
256
+ if (matchesKey(data, "f")) {
257
+ this.#filter = "";
258
+ this.#activeVariant = 0;
259
+ this.#siblingSelected = 0;
260
+ this.#stopSlide();
248
261
  this.deps.requestRender();
249
262
  return;
250
263
  }
264
+ if (matchesAppToolsExpand(data)) {
265
+ this.#toggleExpanded();
266
+ return;
267
+ }
251
268
  if (matchesSelectUp(data)) {
252
269
  this.#moveVertical(-1);
253
270
  return;
@@ -282,6 +299,115 @@ export class RewindSelectorComponent implements Component {
282
299
  }
283
300
  }
284
301
 
302
+ #toggleExpanded(): void {
303
+ this.#expanded = !this.#expanded;
304
+ this.#builder.setExpanded(this.#expanded);
305
+ for (const columns of this.#variantCache.values()) {
306
+ for (const column of columns) column.builder.setExpanded(this.#expanded);
307
+ }
308
+ this.deps.requestRender();
309
+ }
310
+
311
+ // ========================================================================
312
+ // Filter
313
+ // ========================================================================
314
+
315
+ #handleFilterInput(data: string): void {
316
+ if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
317
+ this.#closeFilter();
318
+ return;
319
+ }
320
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
321
+ const target = this.#filterMatches().includes(this.#selected) ? this.#targets[this.#selected] : undefined;
322
+ if (target) this.deps.onSelect(target.entryId);
323
+ return;
324
+ }
325
+ if (matchesAppToolsExpand(data)) {
326
+ this.#toggleExpanded();
327
+ return;
328
+ }
329
+ if (matchesSelectUp(data) || matchesKey(data, "left")) {
330
+ const userTurnsOnly = !matchesSelectUp(data);
331
+ this.#stepFiltered(-1, userTurnsOnly);
332
+ return;
333
+ }
334
+ if (matchesSelectDown(data) || matchesKey(data, "right")) {
335
+ const userTurnsOnly = !matchesSelectDown(data);
336
+ this.#stepFiltered(1, userTurnsOnly);
337
+ return;
338
+ }
339
+ if (matchesKey(data, "backspace")) {
340
+ if (this.#filter!.length === 0) {
341
+ this.#closeFilter();
342
+ return;
343
+ }
344
+ this.#setFilter(this.#filter!.slice(0, -1));
345
+ return;
346
+ }
347
+ const printable = extractPrintableText(data);
348
+ if (printable) {
349
+ this.#setFilter(this.#filter! + printable);
350
+ return;
351
+ }
352
+ if (this.#browser.handleScrollKey(data)) {
353
+ this.deps.requestRender();
354
+ }
355
+ }
356
+
357
+ /** Leave the filter, keeping the selected item outlined in the full transcript. */
358
+ #closeFilter(): void {
359
+ this.#filter = undefined;
360
+ this.deps.requestRender();
361
+ }
362
+
363
+ /** Update the query; keep the selection when it still matches, else rest on the newest match above it. */
364
+ #setFilter(query: string): void {
365
+ this.#filter = query;
366
+ const matches = this.#filterMatches();
367
+ if (!matches.includes(this.#selected)) {
368
+ this.#selected = matches.findLast(index => index < this.#selected) ?? matches.at(-1) ?? this.#selected;
369
+ }
370
+ this.deps.requestRender();
371
+ }
372
+
373
+ #stepFiltered(delta: -1 | 1, userTurnsOnly: boolean): void {
374
+ const matches = this.#filterMatches().filter(index => !userTurnsOnly || this.#targets[index]!.isUserTurn);
375
+ const next =
376
+ delta < 0 ? matches.findLast(index => index < this.#selected) : matches.find(index => index > this.#selected);
377
+ if (next === undefined) return;
378
+ this.#selected = next;
379
+ this.deps.requestRender();
380
+ }
381
+
382
+ /**
383
+ * Visible main-path target indices whose rendered text contains every
384
+ * whitespace-separated query word as a whole word (case-insensitive: `ls`
385
+ * matches `ls -la` but not `tools`); all visible targets for an empty query.
386
+ * Matching the rendered rows keeps results honest: collapsed tool output
387
+ * only matches once Ctrl+O expands it.
388
+ */
389
+ #filterMatches(): number[] {
390
+ const words = (this.#filter ?? "").toLowerCase().split(/\s+/).filter(Boolean);
391
+ const patterns = words.map(
392
+ word => new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u"),
393
+ );
394
+ const matches: number[] = [];
395
+ for (let index = 0; index < this.#targets.length; index++) {
396
+ if (!this.#isMainSelectable(index)) continue;
397
+ const target = this.#targets[index]!;
398
+ const texts = this.#mainRows.slice(target.start, target.end).map(rows => {
399
+ let text = this.#rowText.get(rows);
400
+ if (text === undefined) {
401
+ text = Bun.stripANSI(rows.join("\n")).toLowerCase();
402
+ this.#rowText.set(rows, text);
403
+ }
404
+ return text;
405
+ });
406
+ if (patterns.every(pattern => texts.some(text => pattern.test(text)))) matches.push(index);
407
+ }
408
+ return matches;
409
+ }
410
+
285
411
  /** Up/Down: step within the active column; leaving a sibling column's top exits the strip. */
286
412
  #moveVertical(delta: -1 | 1): void {
287
413
  if (this.#activeVariant > 0) {
@@ -337,6 +463,7 @@ export class RewindSelectorComponent implements Component {
337
463
  const children = this.#builder.container.children;
338
464
  const prepared = this.#browser.prepareOutline(children, this.#targets, this.#selected, contentWidth);
339
465
  this.#mainVisible = prepared.visible;
466
+ this.#mainRows = prepared.childRows;
340
467
  if (prepared.selected !== this.#selected) {
341
468
  // The current target collapsed (e.g. expansion toggle): rest on the
342
469
  // nearest visible one and leave the strip.
@@ -345,6 +472,8 @@ export class RewindSelectorComponent implements Component {
345
472
  this.#siblingSelected = 0;
346
473
  }
347
474
 
475
+ if (this.#filter !== undefined) return this.#filterFrame(this.#filter, prepared.childRows, contentWidth);
476
+
348
477
  const columns = this.#stripColumns();
349
478
  const composed =
350
479
  columns.length > 0
@@ -359,14 +488,59 @@ export class RewindSelectorComponent implements Component {
359
488
  const position = this.#targets.length > 0 ? `${this.#selected + 1}/${this.#targets.length} ` : "";
360
489
  const lateral = columns.length > 0 ? "←/→ branches" : "←/→ user turns";
361
490
  return {
362
- header: [
363
- `${theme.icon.rewind} ${theme.bold("Rewind")}${theme.sep.dot}${theme.fg("dim", "pick the point to continue from")}`,
364
- ],
491
+ header: [this.#header()],
365
492
  body: {
366
493
  lines: composed.lines,
367
494
  anchor: this.#outlineAnchor(composed),
368
495
  },
369
- footer: [theme.fg("dim", `${position}↑/↓ step ${lateral} enter rewind ctrl+o expand esc cancel`)],
496
+ footer: [
497
+ theme.fg("dim", `${position}↑/↓ step ${lateral} f filter enter rewind ctrl+o expand esc cancel`),
498
+ ],
499
+ };
500
+ }
501
+
502
+ #header(): string {
503
+ return `${theme.icon.rewind} ${theme.bold("Rewind")}${theme.sep.dot}${theme.fg("dim", "pick the point to continue from")}`;
504
+ }
505
+
506
+ /** Only the matching items of the current path, concatenated; no branch strip. */
507
+ #filterFrame(
508
+ query: string,
509
+ childRows: readonly (readonly string[])[],
510
+ contentWidth: number,
511
+ ): TranscriptBrowserFrame {
512
+ const matches = this.#filterMatches();
513
+ const rows: (readonly string[])[] = [];
514
+ const targets: OutlineTarget[] = [];
515
+ for (const index of matches) {
516
+ const target = this.#targets[index]!;
517
+ const start = rows.length;
518
+ rows.push(...childRows.slice(target.start, target.end));
519
+ targets.push({ ...target, start, end: rows.length });
520
+ }
521
+ const selected = matches.indexOf(this.#selected);
522
+ const composed = composeOutlineColumn(rows, 0, rows.length, targets, selected, contentWidth, undefined);
523
+ const lines = matches.length > 0 ? composed.lines : [theme.fg("muted", ` No items match "${query}"`)];
524
+ const count =
525
+ matches.length === 0
526
+ ? theme.fg("error", "no matches")
527
+ : theme.fg("dim", `${selected >= 0 ? selected + 1 : "-"}/${matches.length}`);
528
+ return {
529
+ header: [this.#header()],
530
+ body: {
531
+ lines,
532
+ anchor:
533
+ composed.selStart >= 0
534
+ ? {
535
+ id: `rewind:filter:${query}:${this.#targets[this.#selected]?.turnId ?? this.#selected}`,
536
+ start: composed.selStart,
537
+ end: composed.selEnd,
538
+ }
539
+ : undefined,
540
+ },
541
+ footer: [
542
+ `${theme.fg("accent", "filter:")} ${query}${theme.fg("accent", "▏")} ${count} ${theme.fg("dim", "↑/↓ step ←/→ user turns enter rewind esc show all")}`,
543
+ ],
370
544
  };
371
545
  }
372
546
 
@@ -75,6 +75,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
75
75
  "Todos",
76
76
  "Grep & Browser",
77
77
  "Computer",
78
+ "IDA Pro",
78
79
  "GitHub",
79
80
  "Output Limits",
80
81
  "Execution",
@@ -131,6 +132,11 @@ export interface SettingsHost {
131
132
  entries: readonly SettingsDisplayEntry[];
132
133
  get(path: string): unknown;
133
134
  set(path: string, value: unknown): void;
135
+ /**
136
+ * Removes the value from the global config: a project or other layer, or an environment
137
+ * variable, that configures the setting still applies; otherwise the default does.
138
+ */
139
+ unset(path: string): void;
134
140
  normalizeProviderLimits(value: unknown): Record<string, number>;
135
141
  validateProviderLimits(value: unknown): Record<string, number>;
136
142
  }
@@ -1100,9 +1100,10 @@ export class SettingsSelectorComponent implements Component {
1100
1100
  this.#formatTextInputEditValue(def.path, this.#context.settings.get(def.path)),
1101
1101
  def.secret,
1102
1102
  value => {
1103
- // Empty string clears the setting; undefined-typed string settings
1104
- // store "" which the browser.ts expandPath ignores (no-op fallback).
1105
- this.#setSettingValue(def.path, value);
1103
+ // An empty field removes the persisted value, so the default (or an
1104
+ // environment fallback) applies again instead of a pinned "".
1105
+ if (value === "") this.#context.settings.unset(def.path);
1106
+ else this.#setSettingValue(def.path, value);
1106
1107
  this.#callbacks.onChange(def.path, this.#context.settings.get(def.path));
1107
1108
  wrappedDone(this.#formatTextInputValue(def, this.#context.settings.get(def.path)));
1108
1109
  },
@@ -91,6 +91,18 @@ export interface ComposerOptions {
91
91
  readonly now?: () => number;
92
92
  }
93
93
 
94
+ /** How {@link Composer.setRuntimeChildren} bills below-transcript roots against transcript retirement. */
95
+ export interface RuntimeChildrenOptions {
96
+ /**
97
+ * Below-transcript roots whose growth is momentary (inline dialogs, a tall
98
+ * multi-line editor). They are billed at their smallest height so expansion
99
+ * clips the live tail instead of retiring rows a later shrink could not
100
+ * reclaim (#11007). Every other root is billed at its current height, so
101
+ * settled rows it displaces retire to native scrollback.
102
+ */
103
+ readonly transient?: readonly Component[];
104
+ }
105
+
94
106
  /** Controls the first terminal paint for a composer that does not already own the terminal. */
95
107
  export interface ComposerStartOptions {
96
108
  readonly clearScrollback?: boolean;
@@ -253,15 +265,18 @@ export class Composer implements TerminalFrameProvider {
253
265
  #retiredHeaderStart = 0;
254
266
  #resizeRetiredHeaderStart: number | undefined;
255
267
  #lastNormalRows = 0;
256
- // Smallest below-transcript chrome height (editor + status + any transient
257
- // inline dialog) seen since mount. Retirement is billed against this
258
- // persistent baseline, never the transient peak, so a dialog or tall editor
259
- // that later shrinks never leaves committed transcript rows the live viewport
260
- // cannot reclaim (#11007). The baseline is terminal-height independent — the
261
- // editor and status floors do not scale with rows — so it is retained across
262
- // resizes rather than rediscovered from whatever chrome is expanded at the
263
- // moment the height changes.
264
- #retirementBelowFloor: number | undefined;
268
+ // Roots from `RuntimeChildrenOptions.transient`, and the smallest height
269
+ // they have rendered at since mount. Retirement bills transient roots at
270
+ // this floor, never their peak, so a dialog or tall editor that later
271
+ // shrinks never leaves committed transcript rows the live viewport cannot
272
+ // reclaim (#11007). Persistent roots (loader, todo/subagent HUDs) bill at
273
+ // their current height: they stay up for a whole turn, and billing them at
274
+ // an idle floor hid the settled rows they displaced until the turn ended.
275
+ // The floor is terminal-height independent — editor and dialog heights do
276
+ // not scale with rows — so it is retained across resizes rather than
277
+ // rediscovered from whatever chrome is expanded when the height changes.
278
+ #transientChrome: ReadonlySet<Component> = new Set();
279
+ #transientChromeFloor: number | undefined;
265
280
  #lastInterruptAt = 0;
266
281
  #started = false;
267
282
  #stopped = false;
@@ -346,60 +361,24 @@ export class Composer implements TerminalFrameProvider {
346
361
  const afterRoots = roots.slice(transcriptIndex + 1);
347
362
  const after: string[] = [];
348
363
  const afterSpans: ViewportClickSpan[] = [];
364
+ let transientRows = 0;
349
365
  for (const root of afterRoots) {
350
366
  const start = after.length;
351
- // Row targets usually nest one level down: chrome roots are plain
352
- // containers (the HUD lives inside `subagentContainer`), and
353
- // `Container.render` is a pure concatenation, so child spans tile
354
- // the root span exactly. Render those children once and share the
355
- // rows for composition and measurement — a second render per frame
356
- // would duplicate render-time side effects (image placement
357
- // registration). Roots with a custom render keep the composed
358
- // output as the source of truth and measure up to the last target.
359
- const plainContainer = root instanceof Container && root.render === Container.prototype.render;
360
- const targets = root instanceof Container ? root.children : [root];
361
- const resolves = targets.map(rowTargetCandidates);
362
- const lastTarget = resolves.findLastIndex(resolve => resolve !== undefined);
363
- if (plainContainer) {
364
- let offset = start;
365
- for (let index = 0; index < targets.length; index++) {
366
- const childLines = targets[index]!.render(width);
367
- after.push(...childLines);
368
- if (index > lastTarget) continue;
369
- const resolve = resolves[index];
370
- if (resolve !== undefined && childLines.length > 0) {
371
- afterSpans.push({ start: offset, end: offset + childLines.length, candidates: resolve });
372
- }
373
- offset += childLines.length;
374
- }
375
- continue;
376
- }
377
- after.push(...root.render(width));
378
- if (lastTarget === -1) continue;
379
- let offset = start;
380
- for (let index = 0; index <= lastTarget; index++) {
381
- const childLines = targets[index] === root ? after.length - start : targets[index]!.render(width).length;
382
- const resolve = resolves[index];
383
- if (resolve !== undefined && childLines > 0) {
384
- afterSpans.push({ start: offset, end: offset + childLines, candidates: resolve });
385
- }
386
- offset += childLines;
387
- }
367
+ this.#renderBelowRoot(root, width, after, afterSpans);
368
+ if (this.#transientChrome.has(root)) transientRows += after.length - start;
388
369
  }
389
370
  // Offer history under capacity pressure only: blocks stay live (and keep
390
371
  // reflowing to the current width) while the screen has room. A batch
391
372
  // leaves the mutable viewport in the same frame it is appended, so its
392
373
  // rows are never painted twice.
393
374
  //
394
- // Retirement is billed against the persistent below-transcript chrome
395
- // baseline, not the transient peak: a confirmation dialog or a tall
396
- // multi-line editor swapped in below the transcript clips the live tail
397
- // for its lifetime, but must not permanently commit transcript rows to
398
- // native history — otherwise a later shrink cannot refill the freed rows
399
- // and the editor drifts up above a band of blank rows (#11007).
400
- this.#retirementBelowFloor =
401
- this.#retirementBelowFloor === undefined ? after.length : Math.min(this.#retirementBelowFloor, after.length);
402
- const belowFloor = this.#retirementBelowFloor;
375
+ // Retirement bills transient roots at their floor, not their peak: a
376
+ // confirmation dialog or a tall multi-line editor clips the live tail for
377
+ // its lifetime, but must not permanently commit transcript rows to native
378
+ // history — otherwise a later shrink cannot refill the freed rows and the
379
+ // editor drifts up above a band of blank rows (#11007).
380
+ this.#transientChromeFloor = Math.min(this.#transientChromeFloor ?? transientRows, transientRows);
381
+ const belowFloor = after.length - transientRows + this.#transientChromeFloor;
403
382
  const history = this.#offerHistory(transcript, width, rows, preRoots.length + belowFloor);
404
383
  const headerVisible = !this.#headerRetired && this.#offeredHistory?.source !== "header";
405
384
  const headerRows = headerVisible ? this.#header.render(width) : [];
@@ -443,6 +422,52 @@ export class Composer implements TerminalFrameProvider {
443
422
  return { history, viewport: this.#paintHoverBand(mutable, spans) };
444
423
  }
445
424
 
425
+ /**
426
+ * Append one below-transcript root's rows to `after`, recording click spans
427
+ * for its row targets in `after` coordinates.
428
+ *
429
+ * Row targets usually nest one level down: chrome roots are plain
430
+ * containers (the HUD lives inside `subagentContainer`), and
431
+ * `Container.render` is a pure concatenation, so child spans tile the root
432
+ * span exactly. Render those children once and share the rows for
433
+ * composition and measurement — a second render per frame would duplicate
434
+ * render-time side effects (image placement registration). Roots with a
435
+ * custom render keep the composed output as the source of truth and measure
436
+ * up to the last target.
437
+ */
438
+ #renderBelowRoot(root: Component, width: number, after: string[], spans: ViewportClickSpan[]): void {
439
+ const start = after.length;
440
+ const plainContainer = root instanceof Container && root.render === Container.prototype.render;
441
+ const targets = root instanceof Container ? root.children : [root];
442
+ const resolves = targets.map(rowTargetCandidates);
443
+ const lastTarget = resolves.findLastIndex(resolve => resolve !== undefined);
444
+ if (plainContainer) {
445
+ let offset = start;
446
+ for (let index = 0; index < targets.length; index++) {
447
+ const childLines = targets[index]!.render(width);
448
+ after.push(...childLines);
449
+ if (index > lastTarget) continue;
450
+ const resolve = resolves[index];
451
+ if (resolve !== undefined && childLines.length > 0) {
452
+ spans.push({ start: offset, end: offset + childLines.length, candidates: resolve });
453
+ }
454
+ offset += childLines.length;
455
+ }
456
+ return;
457
+ }
458
+ after.push(...root.render(width));
459
+ if (lastTarget === -1) return;
460
+ let offset = start;
461
+ for (let index = 0; index <= lastTarget; index++) {
462
+ const childLines = targets[index] === root ? after.length - start : targets[index]!.render(width).length;
463
+ const resolve = resolves[index];
464
+ if (resolve !== undefined && childLines > 0) {
465
+ spans.push({ start: offset, end: offset + childLines, candidates: resolve });
466
+ }
467
+ offset += childLines;
468
+ }
469
+ }
470
+
446
471
  /**
447
472
  * Band the hovered click target's current rows. Id-anchored (not
448
473
  * line-anchored) so the band follows an agent whose rows shift while it
@@ -847,8 +872,10 @@ export class Composer implements TerminalFrameProvider {
847
872
  }
848
873
 
849
874
  /** Mount or replace session-aware root children while preserving the header and status hosts. */
850
- setRuntimeChildren(children: readonly Component[]): void {
875
+ setRuntimeChildren(children: readonly Component[], options: RuntimeChildrenOptions = {}): void {
851
876
  if (this.#stopped) return;
877
+ this.#transientChrome = new Set(options.transient);
878
+ this.#transientChromeFloor = undefined;
852
879
  this.ui.removeChild(this.#statusHost);
853
880
  if (this.#runtimeMounted) {
854
881
  for (const child of this.#runtimeChildren) this.ui.removeChild(child);
@@ -5,6 +5,7 @@ import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from "../stdin-buffer";
5
5
  import { Editor, type EditorTextDecorationContext, type EditorTheme } from "../components/editor";
6
6
  import { addKeyAliases, canonicalKeyId, getKeybindings } from "../keybindings";
7
7
  import { type KeyId, parseKey, parseKittySequence } from "../keys";
8
+ import { SpaceHoldGesture } from "../space-hold";
8
9
  import { TUI } from "../tui";
9
10
  import type { AppKeybinding } from "../app-keybindings";
10
11
  import { allowsModelMentions, allowsSkillTokens, SKILL_TOKEN_RE } from "./skill-tokens";
@@ -117,34 +118,6 @@ const ABSOLUTE_PATH_PREFIX_REGEX = new RegExp(`^${ABSOLUTE_PATH_PREFIX_SOURCE}`)
117
118
  */
118
119
  const INTERIOR_PATH_ANCHOR_REGEX = new RegExp(String.raw`(?<!\\)\s(?:${ABSOLUTE_PATH_PREFIX_SOURCE}|\.\.?[\\/])`);
119
120
 
120
- /** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
121
- * deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
122
- export const SPACE_REPEAT_MAX_GAP_MS = 120;
123
- /** Two consecutive inter-space gaps are "mechanical" (machine-driven auto-repeat) when both are
124
- * within {@link SPACE_REPEAT_MAX_GAP_MS} and differ by no more than this — an absolute jitter floor
125
- * or, for slower repeat rates, {@link SPACE_REPEAT_JITTER_RATIO} of the smaller gap. OS key-repeat
126
- * is metronomic; a human smashing the bar is fast but irregular, so its deltas never stay this
127
- * steady. */
128
- export const SPACE_REPEAT_JITTER_MS = 18;
129
- export const SPACE_REPEAT_JITTER_RATIO = 0.35;
130
- /** Consecutive mechanical (fast + steady) deltas that confirm the space bar is held and start
131
- * recording. Needs a sustained metronomic cadence, so jittery smashing and deliberate taps never
132
- * reach it. */
133
- export const SPACE_HOLD_MECHANICAL_RUN = 2;
134
- /** Idle gap (ms) after the last repeated space that counts as the space bar being released, ending
135
- * the push-to-talk recording. Must comfortably exceed the OS key-repeat interval. */
136
- export const SPACE_HOLD_RELEASE_MS = 250;
137
-
138
- /** Whether two consecutive inter-space gaps look machine-driven: both within the auto-repeat band
139
- * and steady enough (small absolute or proportional difference). OS key-repeat is metronomic, so
140
- * its successive deltas match closely; human smashing is fast but irregular and deliberate taps are
141
- * too slow, so neither passes. */
142
- function gapsAreMechanical(gap: number, prevGap: number): boolean {
143
- if (gap > SPACE_REPEAT_MAX_GAP_MS || prevGap > SPACE_REPEAT_MAX_GAP_MS) return false;
144
- const tolerance = Math.max(SPACE_REPEAT_JITTER_MS, Math.min(gap, prevGap) * SPACE_REPEAT_JITTER_RATIO);
145
- return Math.abs(gap - prevGap) <= tolerance;
146
- }
147
-
148
121
  function isPastedPathSeparator(char: string | undefined): boolean {
149
122
  return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
150
123
  }
@@ -902,15 +875,13 @@ export class CustomEditor extends Editor {
902
875
  /** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
903
876
  onLeftAtStart?: () => void;
904
877
 
905
- /** Fired when a sustained space-bar hold is recognized — the push-to-talk STT start. The
906
- * optimistically-typed spaces have already been deleted by the time this runs. */
907
- onSpaceHoldStart?: () => void;
908
- /** Fired when the held space bar is released (detected as an idle gap with no further repeated
909
- * spaces) — the push-to-talk STT stop. */
910
- onSpaceHoldEnd?: () => void;
911
- /** Gate for the space-hold gesture. Returns false to keep the space bar inserting spaces
912
- * normally; wired to `stt.enabled` so disabling STT restores plain space behavior. */
913
- sttHoldEnabled?: () => boolean;
878
+ /** Space-bar push-to-talk; set its `handler` to enable it. It is a text-composition gesture, so it
879
+ * stays out of Vim's Normal/Visual modes (where the space bar is the `l` motion) and away from an
880
+ * open autocomplete menu. */
881
+ readonly spaceHold = new SpaceHoldGesture(
882
+ count => this.deleteBeforeCursor(count),
883
+ () => this.vimMode === "insert" && !this.isShowingAutocomplete(),
884
+ );
914
885
 
915
886
  /** Custom key handlers from extensions and non-built-in app actions. */
916
887
  #customKeyHandlers = new Map<KeyId, () => void>();
@@ -928,18 +899,6 @@ export class CustomEditor extends Editor {
928
899
  /** Input chunks deferred behind an in-flight paste, drained in FIFO order once the paste
929
900
  * count returns to zero. */
930
901
  #pendingInput: string[] = [];
931
- /** Spaces actually inserted in the current run; tracked back out when a hold is recognized. */
932
- #spaceRunInserted = 0;
933
- /** Consecutive "mechanical" deltas (fast + steady); a sustained run of these confirms a held bar. */
934
- #mechanicalRun = 0;
935
- /** Inter-space gap (ms) of the previous space pair, compared against the next to judge steadiness. */
936
- #prevSpaceGap: number | undefined;
937
- /** Monotonic timestamp (ms) of the last space, to measure the gap to the next one. */
938
- #lastSpaceAt = Number.NEGATIVE_INFINITY;
939
- /** True while a recognized space-hold push-to-talk recording is in progress. */
940
- #spaceHoldActive = false;
941
- /** Idle timer that fires `onSpaceHoldEnd` once repeated spaces stop arriving. */
942
- #spaceHoldTimer: NodeJS.Timeout | undefined;
943
902
  #actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
944
903
  Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
945
904
  );
@@ -1004,93 +963,6 @@ export class CustomEditor extends Editor {
1004
963
  this.#rebuildCustomMatchKeys();
1005
964
  }
1006
965
 
1007
- #spaceHoldGestureEnabled(): boolean {
1008
- // Push-to-talk is a text-composition gesture, so it stays out of Vim's Normal/Visual modes
1009
- // where the space bar is the `l` motion.
1010
- if (this.vimMode !== "insert") return false;
1011
- return this.onSpaceHoldStart !== undefined && (this.sttHoldEnabled?.() ?? false) && !this.isShowingAutocomplete();
1012
- }
1013
-
1014
- /** Drive the space-hold push-to-talk state machine. Returns true when the gesture consumed the
1015
- * input so it must not reach normal editing. A held space bar emits OS auto-repeat: a *steady*
1016
- * stream of spaces at a fixed fast interval. We watch the inter-space deltas and only recognize a
1017
- * hold once {@link SPACE_HOLD_MECHANICAL_RUN} consecutive deltas are "mechanical" — both
1018
- * auto-repeat-fast and near-identical (see {@link gapsAreMechanical}). Smashing the bar is fast
1019
- * but jittery and deliberate taps are too slow, so neither escalates and both keep typing real
1020
- * spaces; the few spaces typed before a real hold is recognized are tracked back out. */
1021
- #handleSpaceHold(data: string, canonical: string | undefined): boolean {
1022
- const isSpace = canonical === "space";
1023
- if (this.#spaceHoldActive) {
1024
- if (isSpace) {
1025
- // Auto-repeat while held: swallow it and keep the release timer alive.
1026
- this.#armSpaceHoldReleaseTimer();
1027
- return true;
1028
- }
1029
- // Any non-space means the bar was released — stop recording, then let the key through.
1030
- this.#endSpaceHold();
1031
- return false;
1032
- }
1033
- if (!isSpace) {
1034
- this.#resetSpaceRun();
1035
- return false;
1036
- }
1037
- if (!this.#spaceHoldGestureEnabled()) return false;
1038
- const now = performance.now();
1039
- const gap = now - this.#lastSpaceAt;
1040
- const prevGap = this.#prevSpaceGap;
1041
- this.#lastSpaceAt = now;
1042
- this.#prevSpaceGap = gap;
1043
- if (prevGap === undefined || !gapsAreMechanical(gap, prevGap)) {
1044
- // First space, a deliberate tap, or jittery smashing: not a steady machine cadence yet, so
1045
- // type a real space and reset the mechanical run.
1046
- this.#mechanicalRun = 0;
1047
- this.#forwardInput(data);
1048
- this.#spaceRunInserted++;
1049
- return true;
1050
- }
1051
- // Steady fast repeat: swallow it. Once the cadence has held for SPACE_HOLD_MECHANICAL_RUN
1052
- // deltas it's a held bar — track back the few pre-burst spaces already typed and start.
1053
- if (++this.#mechanicalRun >= SPACE_HOLD_MECHANICAL_RUN) {
1054
- this.deleteBeforeCursor(this.#spaceRunInserted);
1055
- this.#resetSpaceRun();
1056
- this.#beginSpaceHold();
1057
- }
1058
- return true;
1059
- }
1060
-
1061
- #resetSpaceRun(): void {
1062
- this.#spaceRunInserted = 0;
1063
- this.#mechanicalRun = 0;
1064
- this.#prevSpaceGap = undefined;
1065
- this.#lastSpaceAt = Number.NEGATIVE_INFINITY;
1066
- }
1067
-
1068
- #beginSpaceHold(): void {
1069
- this.#spaceHoldActive = true;
1070
- this.#armSpaceHoldReleaseTimer();
1071
- this.onSpaceHoldStart?.();
1072
- }
1073
-
1074
- #armSpaceHoldReleaseTimer(): void {
1075
- if (this.#spaceHoldTimer) clearTimeout(this.#spaceHoldTimer);
1076
- this.#spaceHoldTimer = setTimeout(() => {
1077
- this.#spaceHoldTimer = undefined;
1078
- this.#endSpaceHold();
1079
- }, SPACE_HOLD_RELEASE_MS);
1080
- this.#spaceHoldTimer.unref?.();
1081
- }
1082
-
1083
- #endSpaceHold(): void {
1084
- if (!this.#spaceHoldActive) return;
1085
- this.#spaceHoldActive = false;
1086
- this.#resetSpaceRun();
1087
- if (this.#spaceHoldTimer) {
1088
- clearTimeout(this.#spaceHoldTimer);
1089
- this.#spaceHoldTimer = undefined;
1090
- }
1091
- this.onSpaceHoldEnd?.();
1092
- }
1093
-
1094
966
  /** Decrement {@link #pasteInFlight} once an async paste settles and, when the count returns
1095
967
  * to zero, drain {@link #pendingInput} through `handleInput` so requeueing still works if a
1096
968
  * drained chunk triggers another async paste. Bound member so it can be passed straight to
@@ -1188,7 +1060,13 @@ export class CustomEditor extends Editor {
1188
1060
  }
1189
1061
 
1190
1062
  // Space-hold push-to-talk: a sustained space bar starts/stops STT instead of typing spaces.
1191
- if (this.#handleSpaceHold(data, canonical)) return;
1063
+ switch (this.spaceHold.process(canonical === "space")) {
1064
+ case "type":
1065
+ this.#forwardInput(data);
1066
+ return;
1067
+ case "swallow":
1068
+ return;
1069
+ }
1192
1070
 
1193
1071
  // One union probe decides whether any per-action interception below can
1194
1072
  // match — plain typing then skips the ~20 per-action set lookups per key.