@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.
- package/CHANGELOG.md +24 -0
- package/THIRD-PARTY-NOTICES.txt +2 -2
- package/dist/types/chat/read-tool-group.d.ts +3 -3
- package/dist/types/chat/user-message.d.ts +4 -1
- package/dist/types/components/editor.d.ts +0 -2
- package/dist/types/components/input.d.ts +16 -0
- package/dist/types/overlays/btw-history-panel.d.ts +4 -1
- package/dist/types/overlays/settings-defs.d.ts +5 -0
- package/dist/types/prompt/composer.d.ts +12 -1
- package/dist/types/prompt/custom-editor.d.ts +5 -26
- package/dist/types/space-hold.d.ts +45 -0
- package/dist/types/status-line/host.d.ts +2 -0
- package/dist/types/tools/cfg-render.d.ts +37 -0
- package/dist/types/tools/cfg-url.d.ts +16 -0
- package/dist/types/tools/find.d.ts +2 -2
- package/dist/types/tools/output-meta.d.ts +2 -0
- package/dist/types/tools/proc-render.d.ts +1 -8
- package/dist/types/tools/read.d.ts +14 -3
- package/dist/types/tools/result-card.d.ts +20 -0
- package/dist/types/tools/url-scheme-host.d.ts +29 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/tools/xdev.d.ts +9 -1
- package/package.json +9 -9
- package/src/chat/chat-transcript-builder.ts +5 -1
- package/src/chat/read-tool-group.ts +8 -9
- package/src/chat/tool-execution.ts +7 -5
- package/src/chat/user-message.ts +16 -6
- package/src/components/editor.ts +2 -4
- package/src/components/input.ts +103 -30
- package/src/components/markdown.ts +18 -6
- package/src/overlays/annotation-overlay.ts +0 -1
- package/src/overlays/btw-history-panel.ts +4 -0
- package/src/overlays/settings-defs.ts +6 -0
- package/src/overlays/settings-selector.ts +4 -3
- package/src/prompt/composer.ts +83 -56
- package/src/prompt/custom-editor.ts +15 -137
- package/src/prompt/tips.txt +1 -0
- package/src/space-hold.ts +143 -0
- package/src/status-line/component.ts +6 -0
- package/src/status-line/host.ts +2 -0
- package/src/status-line/segments.ts +4 -0
- package/src/tools/cfg-render.ts +117 -0
- package/src/tools/cfg-url.ts +30 -0
- package/src/tools/find.ts +7 -6
- package/src/tools/output-meta.ts +2 -0
- package/src/tools/proc-render.ts +2 -26
- package/src/tools/read.ts +109 -68
- package/src/tools/result-card.ts +36 -0
- package/src/tools/url-scheme-host.ts +42 -0
- package/src/tools/write.ts +109 -66
- package/src/tools/xdev.ts +9 -1
- package/dist/types/chat/read-target.d.ts +0 -4
- 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
|
|
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
|
|
1303
|
-
if (
|
|
1304
|
-
context.resolveXdevMounted =
|
|
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
|
|
package/src/chat/user-message.ts
CHANGED
|
@@ -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
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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.#
|
|
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;
|
package/src/components/editor.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
package/src/components/input.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
this.#
|
|
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
|
},
|
package/src/prompt/composer.ts
CHANGED
|
@@ -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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
this.#
|
|
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);
|