@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
@@ -3,14 +3,13 @@ import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai";
3
3
  import { type Component } from "../tui";
4
4
  import { Container } from "../tui";
5
5
  import { Text } from "../components/text";
6
- import { XD_URL_PREFIX } from "../tools/xd-url";
7
6
  import { getLanguageFromPath, theme } from "../theme";
8
7
  import { parseLineRanges, selectorLineRanges } from "../tools/line-ranges";
9
8
  import { type ReadRenderArgs, type ReadToolDetails, readSourceFsPath, splitPathAndSel } from "../tools/read";
10
9
  import { PREVIEW_LIMITS, shortenPath } from "../render/render-utils";
11
10
  import { fileHyperlink, renderCodeCell } from "../render";
12
11
  import { canonicalizeMessage } from "./thinking-display";
13
- import { internalReadTargetPredicate } from "./read-target";
12
+ import { internalUrlSchemeSpec, splitUrlScheme } from "../tools/url-scheme-host";
14
13
  import type { ToolExecutionHandle } from "./tool-execution";
15
14
  import { formatUsageRow } from "../overlays/usage-row";
16
15
 
@@ -35,14 +34,16 @@ export function readArgsHaveTarget(args: unknown): boolean {
35
34
  /**
36
35
  * Whether a read collapses into the compact {@link ReadToolGroupComponent}
37
36
  * rather than a full tool execution. Filesystem/external targets always
38
- * collapse; other internal URLs (`skill://`, `agent://`, …) render full so
39
- * their resolved content is visible. `xd://` device reads are the exception —
40
- * they list devices/docs and read better in the compact grouped view.
37
+ * collapse; registered internal-URL schemes render full so their resolved
38
+ * content is visible, unless their spec declares `compactTranscript`
39
+ * (device listings/docs read better in the compact grouped view).
41
40
  */
42
41
  export function readArgsCollapseIntoGroup(args: unknown): boolean {
43
42
  const target = readArgsTarget(args);
44
43
  if (target === undefined) return false;
45
- return target.startsWith(XD_URL_PREFIX) || !internalReadTargetPredicate?.(target);
44
+ const url = splitUrlScheme(target);
45
+ const spec = url && internalUrlSchemeSpec(url.scheme);
46
+ return spec === undefined || spec.compactTranscript === true;
46
47
  }
47
48
 
48
49
  /**
@@ -137,8 +138,6 @@ const READ_STATUS_RANK: Record<ReadEntry["status"], number> = {
137
138
  error: 3,
138
139
  };
139
140
 
140
- const URL_LIKE_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
141
-
142
141
  function getDisplayReadTargets(details: ReadToolDetails | undefined): ReadDisplayPathSpec[] | undefined {
143
142
  if (!Array.isArray(details?.displayReadTargets)) return undefined;
144
143
  const links = Array.isArray(details.displayReadTargetLinks) ? details.displayReadTargetLinks : undefined;
@@ -237,7 +236,7 @@ function commaContinuesLineRangeSelector(input: string, partStart: number, comma
237
236
 
238
237
  function splitReadDisplayPathSpecs(rawPath: string): string[] {
239
238
  const normalized = rawPath.trim();
240
- if (!normalized || URL_LIKE_RE.test(normalized)) return [rawPath];
239
+ if (!normalized || splitUrlScheme(normalized)) return [rawPath];
241
240
 
242
241
  const parts: string[] = [];
243
242
  let braceDepth = 0;
@@ -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();
@@ -1630,6 +1630,13 @@ export class ModelHubComponent implements Component {
1630
1630
  return;
1631
1631
  }
1632
1632
 
1633
+ // Enter on the sidebar is a pane switch, like →: it lands on the model
1634
+ // rows instead of acting on a row the user cannot see is selected.
1635
+ if (this.#focus === "scope" && (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n")) {
1636
+ this.#focus = "list";
1637
+ return;
1638
+ }
1639
+
1633
1640
  const beforeQuery = this.#browser.query;
1634
1641
  const isPrintable = extractPrintableText(data) !== undefined;
1635
1642
  this.#browser.handleInput(data);
@@ -2274,19 +2281,22 @@ export class ModelHubComponent implements Component {
2274
2281
  return "←/→ thinking level · Enter apply · Esc keep";
2275
2282
  }
2276
2283
  if (this.#assigning !== null) {
2284
+ if (this.#focus === "scope") {
2285
+ return "Enter/→ models · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
2286
+ }
2277
2287
  switch (this.#assigning.kind) {
2278
2288
  case "fallback":
2279
- return "Enter pick fallback · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
2289
+ return "Enter pick fallback · ↑/↓ models · ← providers · type to search · Alt+←/→ kind · Esc cancel";
2280
2290
  case "fallbackKey":
2281
- return "Enter pick the protected model · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
2291
+ return "Enter pick the protected model · ↑/↓ models · ← providers · type to search · Alt+←/→ kind · Esc cancel";
2282
2292
  default:
2283
- return "Enter assign · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
2293
+ return "Enter assign · ↑/↓ models · ← providers · type to search · Alt+←/→ kind · Esc cancel";
2284
2294
  }
2285
2295
  }
2286
2296
  const entry = this.#activeEntry();
2287
2297
  if (entry.kind === "roles") {
2288
2298
  if (this.#focus !== "list") {
2289
- return "↑/↓ providers · → roles · Alt+←/→ tabs · Esc close";
2299
+ return "↑/↓ providers · Enter/→ roles · Alt+←/→ tabs · Esc close";
2290
2300
  }
2291
2301
  const row = this.#rolesRows[this.#roleIndex];
2292
2302
  if (row?.kind === "fallback") {
@@ -2308,9 +2318,11 @@ export class ModelHubComponent implements Component {
2308
2318
  if (entry.kind === "provider" && entry.locked) {
2309
2319
  return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
2310
2320
  }
2311
- const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers";
2312
2321
  const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
2313
- return `Enter assign roles · ${arrows} · type to search · Alt+←/→ kind${refresh} · Esc close`;
2322
+ if (this.#focus === "scope") {
2323
+ return `Enter/→ models · ↑/↓ providers · type to search · Alt+←/→ kind${refresh} · Esc close`;
2324
+ }
2325
+ return `Enter assign roles · ↑/↓ models · ← providers · type to search · Alt+←/→ kind${refresh} · Esc close`;
2314
2326
  }
2315
2327
 
2316
2328
  #renderFooter(width: number): string {