@oh-my-pi/pi-tui 18.3.0 → 18.3.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.
Files changed (53) 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/settings-defs.d.ts +5 -0
  9. package/dist/types/prompt/composer.d.ts +12 -1
  10. package/dist/types/prompt/custom-editor.d.ts +5 -26
  11. package/dist/types/space-hold.d.ts +45 -0
  12. package/dist/types/status-line/host.d.ts +2 -0
  13. package/dist/types/tools/cfg-render.d.ts +37 -0
  14. package/dist/types/tools/cfg-url.d.ts +16 -0
  15. package/dist/types/tools/find.d.ts +2 -2
  16. package/dist/types/tools/output-meta.d.ts +2 -0
  17. package/dist/types/tools/proc-render.d.ts +1 -8
  18. package/dist/types/tools/read.d.ts +14 -3
  19. package/dist/types/tools/result-card.d.ts +20 -0
  20. package/dist/types/tools/url-scheme-host.d.ts +29 -0
  21. package/dist/types/tools/write.d.ts +2 -0
  22. package/dist/types/tools/xdev.d.ts +9 -1
  23. package/package.json +9 -9
  24. package/src/chat/chat-transcript-builder.ts +5 -1
  25. package/src/chat/read-tool-group.ts +8 -9
  26. package/src/chat/tool-execution.ts +7 -5
  27. package/src/chat/user-message.ts +16 -6
  28. package/src/components/editor.ts +2 -4
  29. package/src/components/input.ts +103 -30
  30. package/src/components/markdown.ts +18 -6
  31. package/src/overlays/annotation-overlay.ts +0 -1
  32. package/src/overlays/btw-history-panel.ts +4 -0
  33. package/src/overlays/settings-defs.ts +6 -0
  34. package/src/overlays/settings-selector.ts +4 -3
  35. package/src/prompt/composer.ts +83 -56
  36. package/src/prompt/custom-editor.ts +15 -137
  37. package/src/prompt/tips.txt +1 -0
  38. package/src/space-hold.ts +143 -0
  39. package/src/status-line/component.ts +6 -0
  40. package/src/status-line/host.ts +2 -0
  41. package/src/status-line/segments.ts +4 -0
  42. package/src/tools/cfg-render.ts +117 -0
  43. package/src/tools/cfg-url.ts +30 -0
  44. package/src/tools/find.ts +7 -6
  45. package/src/tools/output-meta.ts +2 -0
  46. package/src/tools/proc-render.ts +2 -26
  47. package/src/tools/read.ts +109 -68
  48. package/src/tools/result-card.ts +36 -0
  49. package/src/tools/url-scheme-host.ts +42 -0
  50. package/src/tools/write.ts +109 -66
  51. package/src/tools/xdev.ts +9 -1
  52. package/dist/types/chat/read-target.d.ts +0 -4
  53. package/src/chat/read-target.ts +0 -7
@@ -1297,12 +1297,14 @@ export class ToolExecutionComponent extends Container {
1297
1297
  }
1298
1298
  context.renderDiff = renderDiff;
1299
1299
  } else if (this.#toolName === "write") {
1300
- // Device-dispatch previews resolve renderers from the canonical tool map.
1300
+ // Device-dispatch previews render through the host's canonical resolver,
1301
+ // which covers mounted devices and active top-level tools (the `write`
1302
+ // transport accepts both). Deciding the predicate here instead would
1303
+ // leave a `write xd://<top-level tool>` card on the generic fallback.
1301
1304
  const writeTool = this.#tool as { session?: { xdev?: XdevMountedState } } | undefined;
1302
- const xdev = writeTool?.session?.xdev;
1303
- if (xdev) {
1304
- context.resolveXdevMounted = (name: string) =>
1305
- xdev.mountedNames.has(name) ? xdev.tools.get(name) : undefined;
1305
+ const resolveXdevMounted = writeTool?.session?.xdev?.resolve;
1306
+ if (resolveXdevMounted) {
1307
+ context.resolveXdevMounted = resolveXdevMounted;
1306
1308
  }
1307
1309
  }
1308
1310
 
@@ -48,6 +48,8 @@ export interface UserBubbleOptions {
48
48
  imageLinks?: readonly (string | undefined)[];
49
49
  /** Agent-attributed input: dim, flat prose. */
50
50
  synthetic?: boolean;
51
+ /** Delivered into the response that was streaming; marked `*` at the bubble's top-left. */
52
+ liveSteered?: boolean;
51
53
  /** SKILL.md path for a skill chip by name; `undefined` leaves the chip unlinked. */
52
54
  skillPath?: (name: string) => string | undefined;
53
55
  }
@@ -102,7 +104,8 @@ export function userBubbleColor(
102
104
 
103
105
  /**
104
106
  * Component that renders a user message. Accepts an agent reaction badge
105
- * (see {@link ReactionTarget}) drawn right-aligned in the bubble's top padding row.
107
+ * (see {@link ReactionTarget}) drawn right-aligned in the bubble's top padding row;
108
+ * a live-steered message carries a `*` marker left-aligned in the same row.
106
109
  */
107
110
  export class UserMessageComponent extends Container implements ReactionTarget {
108
111
  // Memoized OSC 133 zone wrapping keyed on the underlying container render
@@ -112,6 +115,7 @@ export class UserMessageComponent extends Container implements ReactionTarget {
112
115
  #zoneSource: readonly string[] | undefined;
113
116
  #zoneLines: string[] | undefined;
114
117
  readonly #bgColor: (value: string) => string;
118
+ readonly #liveSteered: boolean;
115
119
  #reaction: string | undefined;
116
120
 
117
121
  constructor(text: string, options: UserBubbleOptions = {}) {
@@ -130,6 +134,7 @@ export class UserMessageComponent extends Container implements ReactionTarget {
130
134
  });
131
135
  const bgColor = (value: string) => theme.bg("userMessageBg", value);
132
136
  this.#bgColor = bgColor;
137
+ this.#liveSteered = options.liveSteered === true;
133
138
  const md = new Markdown(text, 1, 1, getMarkdownTheme(), {
134
139
  bgColor,
135
140
  color: userBubbleColor(options, composerTokenRegex(mentionLabels)),
@@ -144,10 +149,15 @@ export class UserMessageComponent extends Container implements ReactionTarget {
144
149
  this.#zoneLines = undefined;
145
150
  }
146
151
 
147
- /** The top padding row with the reaction badge right-aligned inside the horizontal padding. */
148
- #reactionRow(width: number): string {
149
- const emoji = this.#reaction!;
150
- return applyBackgroundToLine(padding(width - 1 - visibleWidth(emoji)) + emoji, width, this.#bgColor);
152
+ /**
153
+ * The top padding row: the live-steering marker left-aligned and the reaction
154
+ * badge right-aligned, both inside the horizontal padding.
155
+ */
156
+ #badgeRow(width: number): string {
157
+ const marker = this.#liveSteered ? theme.fg("accent", "*") : "";
158
+ const emoji = this.#reaction ?? "";
159
+ const gap = Math.max(0, width - 2 - visibleWidth(marker) - visibleWidth(emoji));
160
+ return applyBackgroundToLine(` ${marker}${padding(gap)}${emoji}`, width, this.#bgColor);
151
161
  }
152
162
 
153
163
  override render(width: number): readonly string[] {
@@ -159,7 +169,7 @@ export class UserMessageComponent extends Container implements ReactionTarget {
159
169
  return this.#zoneLines;
160
170
  }
161
171
  const wrapped = lines.slice();
162
- if (this.#reaction !== undefined) wrapped[0] = this.#reactionRow(width);
172
+ if (this.#reaction !== undefined || this.#liveSteered) wrapped[0] = this.#badgeRow(width);
163
173
  wrapped[0] = OSC133_ZONE_START + wrapped[0];
164
174
  wrapped[wrapped.length - 1] = wrapped[wrapped.length - 1] + OSC133_ZONE_CLOSE;
165
175
  this.#zoneSource = lines;
@@ -528,8 +528,6 @@ export class Editor implements Component, Focusable {
528
528
 
529
529
  /** When set, replaces the normal cursor glyph at end-of-text with this ANSI-styled string. */
530
530
  cursorOverride: string | undefined;
531
- /** Display width of the cursorOverride glyph (needed because override may contain ANSI escapes). */
532
- cursorOverrideWidth: number | undefined;
533
531
  /** Optional hook that decorates displayed user text after source-text layout.
534
532
  * Width-changing output is allowed on lines without the cursor; it is truncated
535
533
  * to the content width rather than reflowed. Cursor glyphs and inline hints are excluded. */
@@ -1304,7 +1302,7 @@ export class Editor implements Component, Focusable {
1304
1302
  if (hasCursor && !this.#useTerminalCursor) {
1305
1303
  const zeroWidthCursorBudget = visibleWidth(gutterText);
1306
1304
  const zeroWidthCursorReplacement = this.cursorOverride
1307
- ? { text: this.cursorOverride, width: this.cursorOverrideWidth ?? 1 }
1305
+ ? { text: this.cursorOverride, width: visibleWidth(this.cursorOverride) }
1308
1306
  : this.#getStyledInputCursor();
1309
1307
  if (showPromptGutter && zeroWidthCursorBudget > 0) {
1310
1308
  // Keep the leading prompt glyph visible when the gutter consumes the whole row.
@@ -1405,7 +1403,7 @@ export class Editor implements Component, Focusable {
1405
1403
  // displayWidth stays the same - we're replacing, not adding
1406
1404
  } else if (this.cursorOverride) {
1407
1405
  // Cursor override replaces the normal end-of-text cursor glyph
1408
- const overrideWidth = this.cursorOverrideWidth ?? 1;
1406
+ const overrideWidth = visibleWidth(this.cursorOverride);
1409
1407
  if (!isSideBordered && displayWidth + overrideWidth > lineContentWidth) {
1410
1408
  // Borderless editors have no spare padding cell for an end-of-line cursor glyph.
1411
1409
  // Preserve cursorOverride by replacing the tail of the line with it.
@@ -1,7 +1,8 @@
1
1
  import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
2
2
  import { getKeybindings } from "../keybindings";
3
- import { extractPrintableText } from "../keys";
3
+ import { extractPrintableText, matchesKey } from "../keys";
4
4
  import { KillRing } from "../kill-ring";
5
+ import { SpaceHoldGesture } from "../space-hold";
5
6
  import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
6
7
  import { cursorColumnWindow } from "./scroll-viewport";
7
8
  import {
@@ -17,6 +18,30 @@ import {
17
18
 
18
19
  const segmenter = getSegmenter();
19
20
 
21
+ /**
22
+ * Clean text entering the single-line value from outside the keyboard (pastes, dictation) —
23
+ * decode tmux's re-encoded control bytes (both extended-keys formats, e.g. Ctrl+J → "\n") back to
24
+ * literal bytes so the escape tail does not leak in, remove newlines/carriage returns, expand tabs,
25
+ * NFC-normalize, then strip any remaining control bytes. The decoder can synthesize Ctrl+A..Ctrl+Z
26
+ * (0x01..0x1A) from a paste, and a single-line value must hold none of them — newlines are already
27
+ * gone and tabs are already spaces by the time the C0/DEL strip runs.
28
+ *
29
+ * NFC normalization rationale: macOS Finder drag-drops file paths in NFD
30
+ * (Conjoining Jamo, U+1100..U+11FF). `Bun.stringWidth` counts each
31
+ * conjoining jamo as a separate cell — a Korean syllable like `화` is
32
+ * 1 char and 2 cells in NFC, but 2 chars and 3 cells in NFD (ᄒ=2 cells
33
+ * + ᅪ=1 cell). The terminal renders the NFD sequence as a single
34
+ * combined syllable (2 cells visible), so the width mismatch shows up
35
+ * as cursor drift past the visible filename — N×~1.5 cells for a path
36
+ * with N Korean syllables. NFC normalization at insert time stores the
37
+ * value in the same form everything else in the codebase assumes.
38
+ */
39
+ function toSingleLine(text: string): string {
40
+ return replaceTabs(decodeReencodedPasteControls(text).replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""))
41
+ .normalize("NFC")
42
+ .replace(/[\x00-\x1F\x7F]/g, "");
43
+ }
44
+
20
45
  interface InputState {
21
46
  value: string;
22
47
  cursor: number;
@@ -35,6 +60,10 @@ export class Input implements Component, Focusable {
35
60
  mask = false;
36
61
  onSubmit?: (value: string) => void;
37
62
  onEscape?: () => void;
63
+ /** Space-bar push-to-talk; set its `handler` to enable it. */
64
+ readonly spaceHold = new SpaceHoldGesture(count => this.deleteBeforeCursor(count));
65
+ /** When set, replaces the cursor glyph at end-of-text with this ANSI-styled string. */
66
+ cursorOverride: string | undefined;
38
67
 
39
68
  /** Focusable interface - set by TUI when focus changes */
40
69
  focused: boolean = false;
@@ -49,6 +78,9 @@ export class Input implements Component, Focusable {
49
78
  // Undo support
50
79
  #undoStack: InputState[] = [];
51
80
 
81
+ /** Code units of the current volatile speech-to-text preview (see {@link setVolatileText}). */
82
+ #volatileTextLen = 0;
83
+
52
84
  getValue(): string {
53
85
  return this.#value;
54
86
  }
@@ -93,6 +125,15 @@ export class Input implements Component, Focusable {
93
125
  return;
94
126
  }
95
127
 
128
+ // Space-hold push-to-talk: a sustained space bar starts/stops STT instead of typing spaces.
129
+ switch (this.spaceHold.process(matchesKey(data, "space"))) {
130
+ case "type":
131
+ this.#insertCharacter(" ");
132
+ return;
133
+ case "swallow":
134
+ return;
135
+ }
136
+
96
137
  const kb = getKeybindings();
97
138
 
98
139
  // Escape/Cancel
@@ -212,6 +253,52 @@ export class Input implements Component, Focusable {
212
253
  this.#handlePaste(text);
213
254
  }
214
255
 
256
+ /** Programmatically trigger submission (e.g. for voice submit). */
257
+ submit(): void {
258
+ this.onSubmit?.(this.#value);
259
+ }
260
+
261
+ /** Delete up to `count` characters immediately before the cursor. */
262
+ deleteBeforeCursor(count: number): void {
263
+ const removable = Math.min(count, this.#cursor);
264
+ if (removable <= 0) return;
265
+ this.#lastAction = null;
266
+ this.#pushUndo();
267
+ this.#replaceBeforeCursor(removable, "");
268
+ }
269
+
270
+ /** Show or replace a volatile speech-to-text preview at the cursor, outside undo history.
271
+ * Finalize it with {@link commitVolatileText} or drop it with {@link clearVolatileText}. */
272
+ setVolatileText(text: string): void {
273
+ const clean = toSingleLine(text);
274
+ this.#replaceBeforeCursor(this.#volatileTextLen, clean);
275
+ this.#volatileTextLen = clean.length;
276
+ }
277
+
278
+ /** Remove the current volatile preview without committing it. */
279
+ clearVolatileText(): void {
280
+ this.setVolatileText("");
281
+ }
282
+
283
+ /** Drop any volatile preview, then insert `text` as a single undoable edit. */
284
+ commitVolatileText(text: string): void {
285
+ this.clearVolatileText();
286
+ const clean = toSingleLine(text);
287
+ if (!clean) return;
288
+ this.#lastAction = null;
289
+ this.#pushUndo();
290
+ this.#replaceBeforeCursor(0, clean);
291
+ }
292
+
293
+ /** Replace up to `count` code units before the cursor with `text`, leaving the cursor after it. The
294
+ * range stops at the start of the value: a volatile preview's length can outrun the cursor once
295
+ * the caret moves while dictation is still streaming. */
296
+ #replaceBeforeCursor(count: number, text: string): void {
297
+ const start = Math.max(0, this.#cursor - count);
298
+ this.#value = this.#value.slice(0, start) + text + this.#value.slice(this.#cursor);
299
+ this.#cursor = start + text.length;
300
+ }
301
+
215
302
  #insertCharacter(text: string): void {
216
303
  const isWordChunk = [...segmenter.segment(text)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
217
304
  // Undo coalescing: consecutive word typing coalesces into one undo unit.
@@ -390,32 +477,7 @@ export class Input implements Component, Focusable {
390
477
  #handlePaste(pastedText: string): void {
391
478
  this.#lastAction = null;
392
479
  this.#pushUndo();
393
-
394
- // Clean the pasted text — decode tmux's re-encoded control bytes (both
395
- // extended-keys formats, e.g. Ctrl+J → "\n") back to literal bytes so the escape
396
- // tail does not leak in, remove newlines/carriage returns, expand tabs, NFC-normalize,
397
- // then strip any remaining control bytes. The decoder can synthesize Ctrl+A..Ctrl+Z
398
- // (0x01..0x1A) from a paste, and a single-line value must hold none of them — newlines
399
- // are already gone and tabs are already spaces by the time the C0/DEL strip runs.
400
- //
401
- // NFC normalization rationale: macOS Finder drag-drops file paths in NFD
402
- // (Conjoining Jamo, U+1100..U+11FF). `Bun.stringWidth` counts each
403
- // conjoining jamo as a separate cell — a Korean syllable like `화` is
404
- // 1 char and 2 cells in NFC, but 2 chars and 3 cells in NFD (ᄒ=2 cells
405
- // + ᅪ=1 cell). The terminal renders the NFD sequence as a single
406
- // combined syllable (2 cells visible), so the width mismatch shows up
407
- // as cursor drift past the visible filename — N×~1.5 cells for a path
408
- // with N Korean syllables. NFC normalization at paste time stores the
409
- // value in the same form everything else in the codebase assumes.
410
- const cleanText = replaceTabs(
411
- decodeReencodedPasteControls(pastedText).replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""),
412
- )
413
- .normalize("NFC")
414
- .replace(/[\x00-\x1F\x7F]/g, "");
415
-
416
- // Insert at cursor position
417
- this.#value = this.#value.slice(0, this.#cursor) + cleanText + this.#value.slice(this.#cursor);
418
- this.#cursor += cleanText.length;
480
+ this.#replaceBeforeCursor(0, toSingleLine(pastedText));
419
481
  }
420
482
 
421
483
  invalidate(): void {
@@ -440,7 +502,13 @@ export class Input implements Component, Focusable {
440
502
  visibleValue = "•".repeat(graphemes.length);
441
503
  cursorIndex = graphemes.filter(grapheme => grapheme.index < this.#cursor).length;
442
504
  }
443
- const displayValue = this.#cursor >= this.#value.length ? `${visibleValue} ` : visibleValue;
505
+ // At end of text the cursor sits on trailing padding, sized to fit a (possibly wide) cursor override.
506
+ const atEnd = this.#cursor >= this.#value.length;
507
+ const override =
508
+ atEnd && this.cursorOverride !== undefined
509
+ ? { text: this.cursorOverride, width: visibleWidth(this.cursorOverride) }
510
+ : undefined;
511
+ const displayValue = atEnd ? visibleValue + " ".repeat(override?.width ?? 1) : visibleValue;
444
512
 
445
513
  const window = cursorColumnWindow(displayValue, cursorIndex, availableWidth);
446
514
  const visibleText = window.text;
@@ -456,11 +524,10 @@ export class Input implements Component, Focusable {
456
524
 
457
525
  // Hardware cursor marker (zero-width, emitted before the cursor cell for IME positioning)
458
526
  const marker = this.focused ? CURSOR_MARKER : "";
459
- const cursorChar = this.#useTerminalCursor ? atCursor : `\x1b[7m${atCursor || " "}\x1b[27m`;
527
+ const { text: cursorChar, width: cursorWidth } = override ?? this.#cursorCell(atCursor);
460
528
 
461
529
  // Clamp only the trailing text (measured in terminal cells), keeping the cursor marker intact.
462
530
  const beforeWidth = visibleWidth(beforeCursor);
463
- const cursorWidth = this.#useTerminalCursor ? visibleWidth(atCursor) : visibleWidth(atCursor || " ");
464
531
  const remainingAfterWidth = Math.max(0, availableWidth - beforeWidth - cursorWidth);
465
532
  const clampedAfterCursor = sliceWithWidth(afterCursor, 0, remainingAfterWidth, true).text;
466
533
  const renderedNoMarker = beforeCursor + cursorChar + clampedAfterCursor;
@@ -471,4 +538,10 @@ export class Input implements Component, Focusable {
471
538
  const line = prompt + textWithCursor + pad;
472
539
  return [line];
473
540
  }
541
+
542
+ /** The rendered cursor cell: the grapheme under the cursor, inverted unless the terminal draws its own cursor. */
543
+ #cursorCell(atCursor: string): { text: string; width: number } {
544
+ if (this.#useTerminalCursor) return { text: atCursor, width: visibleWidth(atCursor) };
545
+ return { text: `\x1b[7m${atCursor || " "}\x1b[27m`, width: visibleWidth(atCursor || " ") };
546
+ }
474
547
  }
@@ -2882,6 +2882,14 @@ export class Markdown implements Component {
2882
2882
  };
2883
2883
  }
2884
2884
 
2885
+ #quoteStyle(text: string): string {
2886
+ return this.#theme.quote(this.#theme.italic(text));
2887
+ }
2888
+
2889
+ #getQuoteStylePrefix(): string {
2890
+ return this.#getStylePrefix(text => this.#quoteStyle(text));
2891
+ }
2892
+
2885
2893
  #renderToken(
2886
2894
  token: Token,
2887
2895
  width: number,
@@ -3004,7 +3012,7 @@ export class Markdown implements Component {
3004
3012
  case "blockquote": {
3005
3013
  const quoteInlineStyleContext: InlineStyleContext = {
3006
3014
  applyText: (text: string) => text,
3007
- stylePrefix: "",
3015
+ stylePrefix: this.#getQuoteStylePrefix(),
3008
3016
  };
3009
3017
  const quoteContentWidth = Math.max(1, width - 2);
3010
3018
  const quoteTokens = token.tokens || [];
@@ -3074,14 +3082,13 @@ export class Markdown implements Component {
3074
3082
  * `width` is the full content width; the border reserves two cells.
3075
3083
  */
3076
3084
  #applyQuoteBorder(renderedLines: RenderedLine[], width: number): RenderedLine[] {
3077
- const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
3078
- const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
3085
+ const quoteStylePrefix = this.#getQuoteStylePrefix();
3079
3086
  const applyQuoteStyle = (line: string): string => {
3080
3087
  if (!quoteStylePrefix) {
3081
- return quoteStyle(line);
3088
+ return this.#quoteStyle(line);
3082
3089
  }
3083
3090
  const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
3084
- return quoteStyle(lineWithReappliedStyle);
3091
+ return this.#quoteStyle(lineWithReappliedStyle);
3085
3092
  };
3086
3093
  const quoteContentWidth = Math.max(1, width - 2);
3087
3094
  const lines: RenderedLine[] = [];
@@ -3140,7 +3147,12 @@ export class Markdown implements Component {
3140
3147
 
3141
3148
  /** Render the inner content of an HTML `<blockquote>` with quote styling. */
3142
3149
  #renderHtmlBlockquote(inner: string, width: number): RenderedLine[] {
3143
- const cleaned = normalizeHtmlForTerminal(inner, createHtmlNormalizationState(), text => this.#theme.code(text));
3150
+ const quoteStylePrefix = this.#getQuoteStylePrefix();
3151
+ const cleaned = normalizeHtmlForTerminal(
3152
+ inner,
3153
+ createHtmlNormalizationState(),
3154
+ text => this.#theme.code(text) + quoteStylePrefix,
3155
+ );
3144
3156
  const innerLines = splitTerminalLines(cleaned).map(line => renderedLine(line.trimEnd()));
3145
3157
  while (innerLines.length > 0 && innerLines[innerLines.length - 1].text === "") innerLines.pop();
3146
3158
  return this.#applyQuoteBorder(innerLines, width);
@@ -251,7 +251,6 @@ export class AnnotationOverlay implements Component {
251
251
  // Keep the editor's CURSOR_MARKER for terminal cursor placement, but
252
252
  // replace its visible end-of-input caret with a stable blank cell.
253
253
  this.#editor.cursorOverride = " ";
254
- this.#editor.cursorOverrideWidth = 1;
255
254
  this.#editor.setScrollbarVisible(true);
256
255
  this.#editor.onSubmit = value => this.#commitAnnotation(value);
257
256
  this.#externalEditorLabel = keybindings.getDisplayString("app.editor.external");
@@ -25,6 +25,7 @@ import {
25
25
  matchesSelectPageUp,
26
26
  matchesSelectUp,
27
27
  } from "../keybinding-matchers";
28
+ import type { SpaceHoldHandler } from "../space-hold";
28
29
  import { sanitizeErrorLine } from "../chrome/error-block";
29
30
  import { sanitizeDisplayLine, sanitizeDisplayText } from "./extensions/display-text";
30
31
  import { editorKey, rawKeyHint } from "../chrome/keybinding-hints";
@@ -40,6 +41,8 @@ interface BtwHistoryPanelOptions {
40
41
  onCancel: (record: BtwHistoryRecord) => void;
41
42
  canFollowUp?: (record: BtwHistoryRecord) => boolean;
42
43
  onFollowUp?: (record: BtwHistoryRecord, question: string, signal: AbortSignal) => Promise<boolean>;
44
+ /** Space-bar push-to-talk for a follow-up composer: dictates into `input` while Space is held. */
45
+ spaceHold?: (input: Input) => SpaceHoldHandler;
43
46
  requestRender: () => void;
44
47
  getHeight: () => number;
45
48
  }
@@ -226,6 +229,7 @@ export class BtwHistoryPanel implements Component, Focusable {
226
229
  #openComposer(record: BtwHistoryRecord): void {
227
230
  const input = new Input();
228
231
  input.prompt = theme.fg("accent", "Follow up: ");
232
+ input.spaceHold.handler = this.#options.spaceHold?.(input);
229
233
  const composer: FollowUpComposer = { recordId: record.id, input, abortController: new AbortController() };
230
234
  input.onEscape = () => {
231
235
  composer.abortController.abort();
@@ -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);