@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.
- 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/rewind-selector.d.ts +4 -2
- 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/model-hub.ts +18 -6
- package/src/overlays/rewind-selector.ts +186 -12
- 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
|
@@ -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 {
|
|
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;
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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();
|
|
@@ -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 ·
|
|
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
|
-
|
|
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 {
|