@oh-my-pi/pi-tui 18.3.2 → 18.3.4
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 +22 -0
- package/dist/types/app-keybindings.d.ts +2 -17
- package/dist/types/autocomplete.d.ts +18 -3
- package/dist/types/chrome/keybinding-hints.d.ts +16 -10
- package/dist/types/components/editor.d.ts +11 -0
- package/dist/types/key-hint-format.d.ts +29 -0
- package/dist/types/keys.d.ts +2 -1
- package/dist/types/overlays/agent-hub.d.ts +1 -1
- package/dist/types/overlays/agent-transcript-viewer.d.ts +1 -1
- package/dist/types/overlays/model-picker.d.ts +1 -3
- package/dist/types/prompt/composer-hints.d.ts +42 -0
- package/dist/types/prompt/composer.d.ts +2 -1
- package/dist/types/prompt/custom-editor.d.ts +8 -2
- package/dist/types/prompt/macos-spelling.d.ts +7 -16
- package/dist/types/prompt/prompt-action-autocomplete.d.ts +4 -1
- package/dist/types/prompt/prose-gate.d.ts +24 -0
- package/dist/types/prompt/word-completion.d.ts +46 -0
- package/dist/types/render/render-utils.d.ts +1 -1
- package/dist/types/theme/active-symbols.d.ts +12 -0
- package/dist/types/theme/symbols.d.ts +1 -1
- package/dist/types/theme/theme-class.d.ts +0 -1
- package/dist/types/tools/eval.d.ts +1 -1
- package/dist/types/tools/mcp.d.ts +3 -1
- package/dist/types/tools/task-yield-assembly.d.ts +10 -2
- package/dist/types/tools/task.d.ts +4 -0
- package/package.json +11 -10
- package/src/app-keybindings.ts +11 -104
- package/src/apps/autoresearch-dashboard.ts +10 -3
- package/src/apps/debug/log-viewer.ts +3 -2
- package/src/apps/debug/raw-sse.ts +2 -1
- package/src/apps/git/git-tui.ts +9 -5
- package/src/apps/live-visualizer.ts +2 -1
- package/src/apps/ps-top.ts +13 -4
- package/src/autocomplete.ts +42 -7
- package/src/chat/compaction-summary-message.ts +2 -1
- package/src/chat/execution-shared.ts +4 -2
- package/src/chat/tool-execution.ts +3 -1
- package/src/chat/ttsr-notification.ts +4 -3
- package/src/chat/user-message.ts +2 -2
- package/src/chrome/keybinding-hints.ts +38 -13
- package/src/components/editor.ts +180 -58
- package/src/components/settings-list.ts +10 -3
- package/src/components/tab-bar.ts +2 -1
- package/src/debug-server.ts +4 -1
- package/src/hotkeys-markdown.ts +14 -17
- package/src/key-hint-format.ts +122 -0
- package/src/keys.ts +2 -1
- package/src/overlays/advisor-config.ts +28 -6
- package/src/overlays/agent-hub.ts +8 -5
- package/src/overlays/agent-transcript-viewer.ts +6 -4
- package/src/overlays/agents-hub.ts +22 -8
- package/src/overlays/annotation-overlay.ts +31 -17
- package/src/overlays/ask-dialog.ts +14 -19
- package/src/overlays/bordered-loader.ts +2 -1
- package/src/overlays/btw-history-panel.ts +15 -12
- package/src/overlays/btw-panel.ts +12 -7
- package/src/overlays/cleanse-panel.ts +9 -6
- package/src/overlays/codex-reset-fireworks.ts +5 -3
- package/src/overlays/copy-selector.ts +13 -5
- package/src/overlays/extensions/extension-dashboard.ts +6 -1
- package/src/overlays/history-search.ts +3 -2
- package/src/overlays/hook-editor.ts +7 -2
- package/src/overlays/hook-input.ts +2 -1
- package/src/overlays/hook-selector.ts +5 -1
- package/src/overlays/login-dialog.ts +17 -4
- package/src/overlays/logout-account-selector.ts +10 -1
- package/src/overlays/mcp-add-wizard.ts +32 -20
- package/src/overlays/model-hub.ts +55 -28
- package/src/overlays/model-picker.ts +27 -12
- package/src/overlays/move-overlay.ts +5 -1
- package/src/overlays/omfg-panel.ts +8 -5
- package/src/overlays/pause-screen.ts +7 -3
- package/src/overlays/plan-review-overlay.ts +40 -14
- package/src/overlays/plan-save-overlay.ts +3 -1
- package/src/overlays/plugin-settings.ts +15 -8
- package/src/overlays/reset-usage-selector.ts +10 -3
- package/src/overlays/rewind-selector.ts +11 -5
- package/src/overlays/session-info-overlay.ts +4 -3
- package/src/overlays/session-selector.ts +12 -2
- package/src/overlays/settings-selector.ts +18 -11
- package/src/overlays/tree-selector.ts +27 -4
- package/src/overlays/usage-dashboard.ts +8 -2
- package/src/prompt/composer-cache.ts +2 -1
- package/src/prompt/composer-hints.ts +75 -0
- package/src/prompt/composer.ts +3 -2
- package/src/prompt/custom-editor.ts +22 -4
- package/src/prompt/macos-spelling.ts +17 -158
- package/src/prompt/prompt-action-autocomplete.ts +9 -2
- package/src/prompt/prose-gate.ts +87 -0
- package/src/prompt/tips.txt +6 -6
- package/src/prompt/welcome.ts +35 -2
- package/src/prompt/word-completion.ts +205 -0
- package/src/render/render-utils.ts +4 -4
- package/src/setup/scenes/composer.ts +5 -1
- package/src/setup/scenes/model.ts +3 -1
- package/src/setup/scenes/providers.ts +4 -1
- package/src/setup/scenes/sign-in.ts +4 -2
- package/src/setup/scenes/splash.ts +9 -4
- package/src/setup/scenes/theme.ts +18 -5
- package/src/setup/wizard-overlay.ts +5 -4
- package/src/theme/active-symbols.ts +20 -0
- package/src/theme/glyph-bundle.json +28 -1
- package/src/theme/symbols.ts +88 -6
- package/src/theme/theme-class.ts +0 -1
- package/src/theme/theme.ts +2 -0
- package/src/tools/bash-interactive.ts +2 -1
- package/src/tools/eval.ts +3 -3
- package/src/tools/mcp.ts +3 -1
- package/src/tools/task-yield-assembly.ts +34 -8
- package/src/tools/task.ts +14 -5
- package/dist/types/overlays/tiny-title-download-progress.d.ts +0 -21
- package/src/overlays/tiny-title-download-progress.ts +0 -102
package/src/components/editor.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as logger from "@oh-my-pi/pi-utils/logger";
|
|
|
3
3
|
import {
|
|
4
4
|
type AutocompleteItem,
|
|
5
5
|
type AutocompleteProvider,
|
|
6
|
+
atCompletionMatches,
|
|
6
7
|
findLeadingSlashCommandStart,
|
|
7
8
|
findTrailingSlashCommandStart,
|
|
8
9
|
isDirectoryCompletionValue,
|
|
@@ -53,11 +54,23 @@ import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectL
|
|
|
53
54
|
|
|
54
55
|
const PASSTHROUGH_COLOR = (text: string): string => text;
|
|
55
56
|
const MENTION_CONTEXT_RE = /(?:^|\s)\^[^\s]*$/;
|
|
57
|
+
const AT_TOKEN_RE = /(?:^|\s)(@[^\s]*)$/;
|
|
56
58
|
|
|
57
59
|
const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
58
60
|
overflowSearch: false,
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* `@` file lists are narrowed in place (`setFilter(liveToken)`) while a fresh
|
|
65
|
+
* search runs, so a slow walk never leaves entries that contradict the typed
|
|
66
|
+
* token on screen. An emptied list means the refresh is still pending.
|
|
67
|
+
*/
|
|
68
|
+
const AT_FILE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
69
|
+
...AUTOCOMPLETE_SELECT_LIST_LAYOUT,
|
|
70
|
+
filterItems: (items, token) => items.filter(item => atCompletionMatches(token, item.value)),
|
|
71
|
+
noMatchText: "Searching…",
|
|
72
|
+
};
|
|
73
|
+
|
|
61
74
|
const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
62
75
|
minPrimaryColumnWidth: 12,
|
|
63
76
|
maxPrimaryColumnWidth: 32,
|
|
@@ -395,10 +408,21 @@ const VIM_NAV_KEYS: Record<string, string | undefined> = {
|
|
|
395
408
|
space: "l",
|
|
396
409
|
};
|
|
397
410
|
|
|
411
|
+
/** Minimum free cells between an end-of-line cursor and a right-aligned placeholder. */
|
|
412
|
+
const PLACEHOLDER_MIN_GAP = 2;
|
|
413
|
+
|
|
398
414
|
const DEFAULT_PAGE_SCROLL_LINES = 10;
|
|
399
415
|
|
|
400
416
|
const MAX_UNDO_STACK = 100;
|
|
401
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Typed characters that replace the provisional space a Tab word accept adds, so
|
|
420
|
+
* they attach to the accepted word: closing punctuation, and `-` for compounds
|
|
421
|
+
* (`cross-platform`; a spaced dash is still space, then `-`). Quotes are
|
|
422
|
+
* excluded: they may open a quote.
|
|
423
|
+
*/
|
|
424
|
+
const PROVISIONAL_SPACE_ABSORBERS = /^[-.,;:!?)\]}]/;
|
|
425
|
+
|
|
402
426
|
interface EditorState {
|
|
403
427
|
lines: string[];
|
|
404
428
|
cursorLine: number;
|
|
@@ -490,6 +514,18 @@ export interface EditorTextDecorationContext {
|
|
|
490
514
|
export interface EditorTextAssistProvider {
|
|
491
515
|
/** Return ghost-text suffix for the partial word at the cursor, or `null`. */
|
|
492
516
|
getWordCompletion?(lines: string[], cursorLine: number, cursorCol: number): string | null;
|
|
517
|
+
/**
|
|
518
|
+
* Report the fate of the ghost-text `suggestion` shown at the cursor:
|
|
519
|
+
* `accepted` (Tab/→) or typed past (a typed character diverged from it).
|
|
520
|
+
* Called with the editor state the suggestion was shown for.
|
|
521
|
+
*/
|
|
522
|
+
wordCompletionFeedback?(
|
|
523
|
+
lines: string[],
|
|
524
|
+
cursorLine: number,
|
|
525
|
+
cursorCol: number,
|
|
526
|
+
suggestion: string,
|
|
527
|
+
accepted: boolean,
|
|
528
|
+
): void;
|
|
493
529
|
/** Return a correction after one single-character insertion, or `null`. */
|
|
494
530
|
tryAutocorrect?(
|
|
495
531
|
lines: string[],
|
|
@@ -554,7 +590,8 @@ export class Editor implements Component, Focusable {
|
|
|
554
590
|
|
|
555
591
|
// Emacs-style kill ring
|
|
556
592
|
#killRing = new KillRing();
|
|
557
|
-
|
|
593
|
+
/** Previous edit, for kill/yank chaining, undo coalescing, and the provisional space after a Tab word accept. */
|
|
594
|
+
#lastAction: "kill" | "yank" | "type-word" | "accept-word" | null = null;
|
|
558
595
|
|
|
559
596
|
// Character jump mode
|
|
560
597
|
#jumpMode: "forward" | "backward" | null = null;
|
|
@@ -642,6 +679,11 @@ export class Editor implements Component, Focusable {
|
|
|
642
679
|
onLargePaste?: (text: string, lineCount: number, options: PasteOptions) => boolean;
|
|
643
680
|
onAutocompleteCancel?: () => void;
|
|
644
681
|
disableSubmit: boolean = false;
|
|
682
|
+
/** Placeholder painted right-aligned on the cursor row while the editor is empty and no
|
|
683
|
+
* autocomplete is open; hidden when it can't keep {@link PLACEHOLDER_MIN_GAP} cells from the
|
|
684
|
+
* cursor. The host styles it (ANSI allowed). Re-evaluated on every render, so hosts can derive
|
|
685
|
+
* it from live state. */
|
|
686
|
+
placeholder?: () => string | undefined;
|
|
645
687
|
|
|
646
688
|
// Custom top border (for status line integration). Either an eager `content`
|
|
647
689
|
// (set once, reused every frame) or a `provider` that recomputes lazily just
|
|
@@ -675,7 +717,7 @@ export class Editor implements Component, Focusable {
|
|
|
675
717
|
cursorCol: this.#state.cursorCol,
|
|
676
718
|
lineCount: lines.length,
|
|
677
719
|
selection: null,
|
|
678
|
-
placeholderActive:
|
|
720
|
+
placeholderActive: this.#getPlaceholder() !== undefined,
|
|
679
721
|
};
|
|
680
722
|
}
|
|
681
723
|
|
|
@@ -1265,9 +1307,22 @@ export class Editor implements Component, Focusable {
|
|
|
1265
1307
|
const emitCursorMarker = this.focused;
|
|
1266
1308
|
const lineContentWidth = contentAreaWidth;
|
|
1267
1309
|
|
|
1268
|
-
//
|
|
1269
|
-
|
|
1310
|
+
// Text painted after an end-of-line cursor with `avail` free cells: dim ghost text
|
|
1311
|
+
// against the cursor, or the placeholder flush right.
|
|
1312
|
+
const placeholder = this.#getPlaceholder();
|
|
1313
|
+
const inlineHint = placeholder ? null : this.#getInlineHint();
|
|
1270
1314
|
const hintStyle = this.#theme.hintStyle ?? ((t: string) => `\x1b[2m${t}\x1b[0m`);
|
|
1315
|
+
const hintTail = (avail: number): { text: string; width: number } | undefined => {
|
|
1316
|
+
if (placeholder) {
|
|
1317
|
+
const gap = avail - visibleWidth(placeholder);
|
|
1318
|
+
return gap >= PLACEHOLDER_MIN_GAP ? { text: padding(gap) + placeholder, width: avail } : undefined;
|
|
1319
|
+
}
|
|
1320
|
+
if (!inlineHint) return undefined;
|
|
1321
|
+
return {
|
|
1322
|
+
text: hintStyle(truncateToWidth(inlineHint, avail)),
|
|
1323
|
+
width: Math.min(visibleWidth(inlineHint), avail),
|
|
1324
|
+
};
|
|
1325
|
+
};
|
|
1271
1326
|
|
|
1272
1327
|
// Active Vim visual selection, if any. The cursor always sits inside it, so selected rows
|
|
1273
1328
|
// skip the normal cursor-glyph branches: the reverse-video span already marks the spot.
|
|
@@ -1360,17 +1415,16 @@ export class Editor implements Component, Focusable {
|
|
|
1360
1415
|
if (marker) {
|
|
1361
1416
|
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
1362
1417
|
const after = displayText.slice(layoutLine.cursorPos);
|
|
1418
|
+
const tail = after.length === 0 ? hintTail(Math.max(0, lineContentWidth - displayWidth)) : undefined;
|
|
1363
1419
|
if (this.#imeSafeCursorLayout && after.length === 0 && isSideBordered) {
|
|
1364
1420
|
// Terminal frontends render IME marked text locally before committed bytes
|
|
1365
1421
|
// reach the application. Keep the end-of-input cursor row empty to its
|
|
1366
1422
|
// right so that insertion cannot shift box chrome onto the next row.
|
|
1367
1423
|
displayText = before + marker;
|
|
1368
1424
|
imeSafeCursorTail = true;
|
|
1369
|
-
} else if (
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
displayText = before + marker + hintText;
|
|
1373
|
-
displayWidth += Math.min(visibleWidth(inlineHint), availWidth);
|
|
1425
|
+
} else if (tail) {
|
|
1426
|
+
displayText = before + marker + tail.text;
|
|
1427
|
+
displayWidth += tail.width;
|
|
1374
1428
|
} else if (after.length === 0 && !isSideBordered && displayWidth >= lineContentWidth) {
|
|
1375
1429
|
displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth);
|
|
1376
1430
|
} else {
|
|
@@ -1413,14 +1467,10 @@ export class Editor implements Component, Focusable {
|
|
|
1413
1467
|
});
|
|
1414
1468
|
displayText = widthLimitedCursor.text;
|
|
1415
1469
|
displayWidth = widthLimitedCursor.width;
|
|
1416
|
-
} else if (inlineHint) {
|
|
1417
|
-
const availWidth = Math.max(0, lineContentWidth - displayWidth - overrideWidth);
|
|
1418
|
-
const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
|
|
1419
|
-
displayText = before + marker + this.cursorOverride + hintText;
|
|
1420
|
-
displayWidth += overrideWidth + Math.min(visibleWidth(inlineHint), availWidth);
|
|
1421
1470
|
} else {
|
|
1422
|
-
|
|
1423
|
-
|
|
1471
|
+
const tail = hintTail(Math.max(0, lineContentWidth - displayWidth - overrideWidth));
|
|
1472
|
+
displayText = before + marker + this.cursorOverride + (tail?.text ?? "");
|
|
1473
|
+
displayWidth += overrideWidth + (tail?.width ?? 0);
|
|
1424
1474
|
}
|
|
1425
1475
|
} else {
|
|
1426
1476
|
// Cursor is at the end - add thin cursor glyph
|
|
@@ -1431,14 +1481,10 @@ export class Editor implements Component, Focusable {
|
|
|
1431
1481
|
const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth);
|
|
1432
1482
|
displayText = widthLimitedCursor.text;
|
|
1433
1483
|
displayWidth = widthLimitedCursor.width;
|
|
1434
|
-
} else if (inlineHint) {
|
|
1435
|
-
const availWidth = Math.max(0, lineContentWidth - displayWidth - cursorWidth);
|
|
1436
|
-
const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
|
|
1437
|
-
displayText = before + marker + cursor + hintText;
|
|
1438
|
-
displayWidth += cursorWidth + Math.min(visibleWidth(inlineHint), availWidth);
|
|
1439
1484
|
} else {
|
|
1440
|
-
|
|
1441
|
-
|
|
1485
|
+
const tail = hintTail(Math.max(0, lineContentWidth - displayWidth - cursorWidth));
|
|
1486
|
+
displayText = before + marker + cursor + (tail?.text ?? "");
|
|
1487
|
+
displayWidth += cursorWidth + (tail?.width ?? 0);
|
|
1442
1488
|
}
|
|
1443
1489
|
if (displayWidth > lineContentWidth && paddingX > 0) {
|
|
1444
1490
|
cursorPaddingOverflow = displayWidth - lineContentWidth;
|
|
@@ -1723,7 +1769,9 @@ export class Editor implements Component, Focusable {
|
|
|
1723
1769
|
// Check for stale autocomplete state due to buffer edits since last refresh.
|
|
1724
1770
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1725
1771
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1726
|
-
|
|
1772
|
+
// A narrowed `@` list can be empty while its refresh is pending; Enter
|
|
1773
|
+
// then submits instead of waiting on the search.
|
|
1774
|
+
if (!selected || !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
|
|
1727
1775
|
// Autocomplete is stale - cancel and fall through to normal submission
|
|
1728
1776
|
this.#cancelAutocomplete();
|
|
1729
1777
|
} else {
|
|
@@ -1944,14 +1992,8 @@ export class Editor implements Component, Focusable {
|
|
|
1944
1992
|
this.#moveCursor(1, 0); // Cursor movement (within text or history entry)
|
|
1945
1993
|
}
|
|
1946
1994
|
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorRight")) {
|
|
1947
|
-
// Right
|
|
1948
|
-
|
|
1949
|
-
if (
|
|
1950
|
-
this.#state.cursorCol >= (this.#state.lines[this.#state.cursorLine] ?? "").length &&
|
|
1951
|
-
this.#acceptWordCompletion()
|
|
1952
|
-
) {
|
|
1953
|
-
return;
|
|
1954
|
-
}
|
|
1995
|
+
// Right walks over the ghost word completion as if it were typed: no trailing space.
|
|
1996
|
+
if (this.#acceptWordCompletion({ space: false })) return;
|
|
1955
1997
|
this.#moveCursor(0, 1);
|
|
1956
1998
|
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorLeft")) {
|
|
1957
1999
|
// Left
|
|
@@ -2740,12 +2782,25 @@ export class Editor implements Component, Focusable {
|
|
|
2740
2782
|
// Undo coalescing: consecutive word typing collapses into one undo unit
|
|
2741
2783
|
// (mirrors Input); any other action resets the run via #lastAction.
|
|
2742
2784
|
const isWordChunk = [...segmenter.segment(char)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
|
|
2785
|
+
let line = this.#state.lines[this.#state.cursorLine] || "";
|
|
2786
|
+
// The space a Tab word accept added is provisional until the next keystroke:
|
|
2787
|
+
// a typed space is swallowed, closing punctuation or a hyphen replaces it,
|
|
2788
|
+
// anything else keeps it (a newline drops it too, see #addNewLine).
|
|
2789
|
+
const provisionalSpace = this.#hasProvisionalSpace();
|
|
2790
|
+
if (provisionalSpace && char === " ") {
|
|
2791
|
+
this.#lastAction = null;
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2743
2794
|
if (!isWordChunk || this.#lastAction !== "type-word") {
|
|
2744
2795
|
this.#recordUndoState();
|
|
2745
2796
|
}
|
|
2746
2797
|
this.#lastAction = isWordChunk ? "type-word" : null;
|
|
2747
|
-
|
|
2748
|
-
|
|
2798
|
+
this.#reportTypedPastWordCompletion(char);
|
|
2799
|
+
if (provisionalSpace && PROVISIONAL_SPACE_ABSORBERS.test(char)) {
|
|
2800
|
+
line = line.slice(0, this.#state.cursorCol - 1) + line.slice(this.#state.cursorCol);
|
|
2801
|
+
this.#state.lines[this.#state.cursorLine] = line;
|
|
2802
|
+
this.#setCursorCol(this.#state.cursorCol - 1);
|
|
2803
|
+
}
|
|
2749
2804
|
|
|
2750
2805
|
const before = line.slice(0, this.#state.cursorCol);
|
|
2751
2806
|
const after = line.slice(this.#state.cursorCol);
|
|
@@ -2965,13 +3020,17 @@ export class Editor implements Component, Focusable {
|
|
|
2965
3020
|
}
|
|
2966
3021
|
|
|
2967
3022
|
#addNewLine(): void {
|
|
3023
|
+
// A provisional space from a Tab word accept would become trailing whitespace.
|
|
3024
|
+
// Read before #resetKillSequence clears the accept marker.
|
|
3025
|
+
const dropSpace = this.#hasProvisionalSpace();
|
|
2968
3026
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
2969
3027
|
this.#resetKillSequence();
|
|
2970
3028
|
this.#recordUndoState();
|
|
2971
3029
|
|
|
2972
3030
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
3031
|
+
const splitCol = dropSpace ? this.#state.cursorCol - 1 : this.#state.cursorCol;
|
|
2973
3032
|
|
|
2974
|
-
const before = currentLine.slice(0,
|
|
3033
|
+
const before = currentLine.slice(0, splitCol);
|
|
2975
3034
|
const after = currentLine.slice(this.#state.cursorCol);
|
|
2976
3035
|
|
|
2977
3036
|
// Split current line
|
|
@@ -2985,6 +3044,12 @@ export class Editor implements Component, Focusable {
|
|
|
2985
3044
|
this.#notifyChange();
|
|
2986
3045
|
}
|
|
2987
3046
|
|
|
3047
|
+
/** Whether the character before the cursor is the still-provisional space a Tab word accept inserted. */
|
|
3048
|
+
#hasProvisionalSpace(): boolean {
|
|
3049
|
+
const line = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
3050
|
+
return this.#lastAction === "accept-word" && line[this.#state.cursorCol - 1] === " ";
|
|
3051
|
+
}
|
|
3052
|
+
|
|
2988
3053
|
#shouldSubmitOnBackslashEnter(data: string, kb: KeybindingsManager): boolean {
|
|
2989
3054
|
if (this.disableSubmit) return false;
|
|
2990
3055
|
if (!matchesKey(data, "enter")) return false;
|
|
@@ -4025,12 +4090,16 @@ export class Editor implements Component, Focusable {
|
|
|
4025
4090
|
prefix: string,
|
|
4026
4091
|
items: Array<{ value: string; label: string; description?: string }>,
|
|
4027
4092
|
): SelectList {
|
|
4028
|
-
const layout = prefix.startsWith("/")
|
|
4093
|
+
const layout = prefix.startsWith("/")
|
|
4094
|
+
? SLASH_COMMAND_SELECT_LIST_LAYOUT
|
|
4095
|
+
: prefix.startsWith("@")
|
|
4096
|
+
? AT_FILE_SELECT_LIST_LAYOUT
|
|
4097
|
+
: AUTOCOMPLETE_SELECT_LIST_LAYOUT;
|
|
4029
4098
|
return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList, layout);
|
|
4030
4099
|
}
|
|
4031
4100
|
|
|
4032
4101
|
async #handleTabCompletion(): Promise<void> {
|
|
4033
|
-
if (this.#acceptWordCompletion()) return;
|
|
4102
|
+
if (this.#acceptWordCompletion({ space: true })) return;
|
|
4034
4103
|
if (!this.#autocompleteProvider) return;
|
|
4035
4104
|
|
|
4036
4105
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -4047,13 +4116,24 @@ export class Editor implements Component, Focusable {
|
|
|
4047
4116
|
await this.#forceFileAutocomplete();
|
|
4048
4117
|
}
|
|
4049
4118
|
}
|
|
4050
|
-
/**
|
|
4051
|
-
|
|
4052
|
-
|
|
4119
|
+
/**
|
|
4120
|
+
* Insert the shown ghost word completion at the cursor, if any. Tab passes
|
|
4121
|
+
* `space: true` for a trailing space that stays provisional until the next
|
|
4122
|
+
* keystroke (see `#insertCharacter`); right arrow passes `false` so a suffix
|
|
4123
|
+
* can follow the word directly.
|
|
4124
|
+
*/
|
|
4125
|
+
#acceptWordCompletion({ space }: { space: boolean }): boolean {
|
|
4126
|
+
const wordCompletion = this.#getShownWordCompletion();
|
|
4053
4127
|
if (!wordCompletion) return false;
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4128
|
+
this.#textAssistProvider?.wordCompletionFeedback?.(
|
|
4129
|
+
this.#state.lines,
|
|
4130
|
+
this.#state.cursorLine,
|
|
4131
|
+
this.#state.cursorCol,
|
|
4132
|
+
wordCompletion,
|
|
4133
|
+
true,
|
|
4134
|
+
);
|
|
4135
|
+
this.#insertTextAtCursor(space ? `${wordCompletion} ` : wordCompletion);
|
|
4136
|
+
if (space) this.#lastAction = "accept-word";
|
|
4057
4137
|
return true;
|
|
4058
4138
|
}
|
|
4059
4139
|
|
|
@@ -4195,6 +4275,13 @@ export class Editor implements Component, Focusable {
|
|
|
4195
4275
|
const lines = [...this.#state.lines];
|
|
4196
4276
|
const cursorLine = this.#state.cursorLine;
|
|
4197
4277
|
const cursorCol = this.#state.cursorCol;
|
|
4278
|
+
const isCurrent = () =>
|
|
4279
|
+
!signal.aborted &&
|
|
4280
|
+
requestId === this.#autocompleteRequestId &&
|
|
4281
|
+
cursorLine === this.#state.cursorLine &&
|
|
4282
|
+
cursorCol === this.#state.cursorCol &&
|
|
4283
|
+
lines.length === this.#state.lines.length &&
|
|
4284
|
+
lines.every((line, index) => line === this.#state.lines[index]);
|
|
4198
4285
|
let suggestions: { items: AutocompleteItem[]; prefix: string } | null;
|
|
4199
4286
|
try {
|
|
4200
4287
|
if (request.kind === "force") {
|
|
@@ -4202,7 +4289,9 @@ export class Editor implements Component, Focusable {
|
|
|
4202
4289
|
if (!getForceFileSuggestions) return;
|
|
4203
4290
|
suggestions = await getForceFileSuggestions.call(provider, lines, cursorLine, cursorCol, signal);
|
|
4204
4291
|
} else {
|
|
4205
|
-
suggestions = await provider.getSuggestions(lines, cursorLine, cursorCol, signal
|
|
4292
|
+
suggestions = await provider.getSuggestions(lines, cursorLine, cursorCol, signal, partial => {
|
|
4293
|
+
if (partial.items.length > 0 && isCurrent()) this.#showAutocompleteSuggestions(partial, "regular");
|
|
4294
|
+
});
|
|
4206
4295
|
}
|
|
4207
4296
|
} catch (error) {
|
|
4208
4297
|
if (!signal.aborted && requestId === this.#autocompleteRequestId) {
|
|
@@ -4212,28 +4301,34 @@ export class Editor implements Component, Focusable {
|
|
|
4212
4301
|
}
|
|
4213
4302
|
return;
|
|
4214
4303
|
}
|
|
4215
|
-
if (
|
|
4216
|
-
signal.aborted ||
|
|
4217
|
-
requestId !== this.#autocompleteRequestId ||
|
|
4218
|
-
cursorLine !== this.#state.cursorLine ||
|
|
4219
|
-
cursorCol !== this.#state.cursorCol ||
|
|
4220
|
-
lines.length !== this.#state.lines.length ||
|
|
4221
|
-
lines.some((line, index) => line !== this.#state.lines[index])
|
|
4222
|
-
) {
|
|
4223
|
-
return;
|
|
4224
|
-
}
|
|
4304
|
+
if (!isCurrent()) return;
|
|
4225
4305
|
|
|
4226
4306
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
4227
|
-
this.#
|
|
4228
|
-
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
4229
|
-
this.#autocompleteState = request.kind === "force" ? "force" : "regular";
|
|
4230
|
-
this.onAutocompleteUpdate?.();
|
|
4307
|
+
this.#showAutocompleteSuggestions(suggestions, request.kind === "force" ? "force" : "regular");
|
|
4231
4308
|
return;
|
|
4232
4309
|
}
|
|
4233
4310
|
this.#cancelAutocomplete();
|
|
4234
4311
|
this.onAutocompleteUpdate?.();
|
|
4235
4312
|
}
|
|
4236
4313
|
|
|
4314
|
+
#showAutocompleteSuggestions(
|
|
4315
|
+
suggestions: { items: AutocompleteItem[]; prefix: string },
|
|
4316
|
+
state: "regular" | "force",
|
|
4317
|
+
): void {
|
|
4318
|
+
this.#autocompletePrefix = suggestions.prefix;
|
|
4319
|
+
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
4320
|
+
this.#autocompleteState = state;
|
|
4321
|
+
this.onAutocompleteUpdate?.();
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
/** Filter a shown `@` file list to the live `@` token until the debounced refresh replaces it. */
|
|
4325
|
+
#narrowAtFileList(): void {
|
|
4326
|
+
if (!this.#autocompleteList || !this.#autocompletePrefix.startsWith("@")) return;
|
|
4327
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
4328
|
+
const token = AT_TOKEN_RE.exec(currentLine.slice(0, this.#state.cursorCol))?.[1];
|
|
4329
|
+
if (token !== undefined) this.#autocompleteList.setFilter(token);
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4237
4332
|
#invalidateAutocompleteRequests(): void {
|
|
4238
4333
|
this.#autocompletePendingRequest = undefined;
|
|
4239
4334
|
this.#autocompleteRequestId++;
|
|
@@ -4243,6 +4338,7 @@ export class Editor implements Component, Focusable {
|
|
|
4243
4338
|
}
|
|
4244
4339
|
|
|
4245
4340
|
#debouncedUpdateAutocomplete(): void {
|
|
4341
|
+
if (this.#autocompleteState !== "assist") this.#narrowAtFileList();
|
|
4246
4342
|
if (this.#autocompleteTimeout) {
|
|
4247
4343
|
clearTimeout(this.#autocompleteTimeout);
|
|
4248
4344
|
}
|
|
@@ -4282,7 +4378,28 @@ export class Editor implements Component, Focusable {
|
|
|
4282
4378
|
|
|
4283
4379
|
return this.#getWordCompletion();
|
|
4284
4380
|
}
|
|
4381
|
+
#getPlaceholder(): string | undefined {
|
|
4382
|
+
if (this.#autocompleteState || !this.#isEditorEmpty()) return undefined;
|
|
4383
|
+
return this.placeholder?.() || undefined;
|
|
4384
|
+
}
|
|
4385
|
+
/** Typing `typed` where it diverges from the shown ghost completion rejects that ghost. */
|
|
4386
|
+
#reportTypedPastWordCompletion(typed: string): void {
|
|
4387
|
+
if (!this.#textAssistProvider?.wordCompletionFeedback) return;
|
|
4388
|
+
const ghost = this.#getShownWordCompletion();
|
|
4389
|
+
if (!ghost) return;
|
|
4390
|
+
const overlap = Math.min(ghost.length, typed.length);
|
|
4391
|
+
if (ghost.slice(0, overlap).toLocaleLowerCase() === typed.slice(0, overlap).toLocaleLowerCase()) return;
|
|
4392
|
+
this.#textAssistProvider.wordCompletionFeedback(
|
|
4393
|
+
this.#state.lines,
|
|
4394
|
+
this.#state.cursorLine,
|
|
4395
|
+
this.#state.cursorCol,
|
|
4396
|
+
ghost,
|
|
4397
|
+
false,
|
|
4398
|
+
);
|
|
4399
|
+
}
|
|
4400
|
+
/** Word completion for the cursor; only offered at end of line, the only place ghost text renders. */
|
|
4285
4401
|
#getWordCompletion(): string | null {
|
|
4402
|
+
if (this.#state.cursorCol < (this.#state.lines[this.#state.cursorLine] ?? "").length) return null;
|
|
4286
4403
|
return (
|
|
4287
4404
|
this.#textAssistProvider?.getWordCompletion?.(
|
|
4288
4405
|
this.#state.lines,
|
|
@@ -4291,4 +4408,9 @@ export class Editor implements Component, Focusable {
|
|
|
4291
4408
|
) ?? null
|
|
4292
4409
|
);
|
|
4293
4410
|
}
|
|
4411
|
+
/** Word completion currently painted as ghost text: none while an autocomplete hint holds the slot. */
|
|
4412
|
+
#getShownWordCompletion(): string | null {
|
|
4413
|
+
const ghost = this.#getWordCompletion();
|
|
4414
|
+
return ghost && this.#getInlineHint() === ghost ? ghost : null;
|
|
4415
|
+
}
|
|
4294
4416
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { formatKeyHint } from "../app-keybindings";
|
|
2
|
+
import { editorKey, editorKeys } from "../chrome/keybinding-hints";
|
|
1
3
|
import { fuzzyFilter } from "../fuzzy";
|
|
2
4
|
import { getKeybindings } from "../keybindings";
|
|
3
5
|
import { extractPrintableText } from "../keys";
|
|
@@ -547,7 +549,9 @@ export class SettingsList implements Component {
|
|
|
547
549
|
}
|
|
548
550
|
lines.push(this.#theme.hint(" No matching settings"));
|
|
549
551
|
lines.push("");
|
|
550
|
-
|
|
552
|
+
const editKey = editorKey("tui.editor.deleteCharBackward");
|
|
553
|
+
const cancelKey = editorKey("tui.select.cancel");
|
|
554
|
+
lines.push(truncateToWidth(this.#theme.hint(` ${editKey} to edit search · ${cancelKey} to cancel`), width));
|
|
551
555
|
return lines;
|
|
552
556
|
}
|
|
553
557
|
|
|
@@ -638,8 +642,11 @@ export class SettingsList implements Component {
|
|
|
638
642
|
// Add hint (suppressed entirely when the host owns the footer)
|
|
639
643
|
if (this.#options.hint !== "") {
|
|
640
644
|
lines.push("");
|
|
641
|
-
const jumpHint =
|
|
642
|
-
|
|
645
|
+
const jumpHint =
|
|
646
|
+
sections.length >= 2 ? `${editorKeys("tui.select.pageUp", "tui.select.pageDown")} to jump sections · ` : "";
|
|
647
|
+
const hintText =
|
|
648
|
+
this.#options.hint ??
|
|
649
|
+
`${editorKey("tui.select.confirm")}/${formatKeyHint("space")} to change · ${jumpHint}Type to search · ${editorKey("tui.select.cancel")} to cancel`;
|
|
643
650
|
lines.push(truncateToWidth(this.#theme.hint(` ${hintText}`), width));
|
|
644
651
|
}
|
|
645
652
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - Tab / Arrow Right: Next tab (wraps around)
|
|
9
9
|
* - Shift+Tab / Arrow Left: Previous tab (wraps around)
|
|
10
10
|
*/
|
|
11
|
+
import { formatKeyHint } from "../app-keybindings";
|
|
11
12
|
import { matchesKey } from "../keys";
|
|
12
13
|
import type { Component } from "../tui";
|
|
13
14
|
import { truncateToWidth, visibleWidth } from "../utils";
|
|
@@ -219,7 +220,7 @@ export class TabBar implements Component {
|
|
|
219
220
|
// Navigation hint
|
|
220
221
|
if (this.showHint) {
|
|
221
222
|
chunks.push({ text: " " });
|
|
222
|
-
chunks.push({ text: this.#theme.hint("
|
|
223
|
+
chunks.push({ text: this.#theme.hint(`(${formatKeyHint("tab")} to cycle)`) });
|
|
223
224
|
}
|
|
224
225
|
return chunks;
|
|
225
226
|
};
|
package/src/debug-server.ts
CHANGED
|
@@ -106,6 +106,9 @@ function modifiedSpecial(sequence: string, modifier: number): string {
|
|
|
106
106
|
if (ss3Final) return `\x1b[1;${modifier}${ss3Final[1]}`;
|
|
107
107
|
const tilde = sequence.match(/^\x1b\[(\d+)~$/);
|
|
108
108
|
if (tilde) return `\x1b[${tilde[1]};${modifier}~`;
|
|
109
|
+
// Enter/Space/Backspace/Escape have no legacy modified form: a plain byte
|
|
110
|
+
// would drop the modifier (Shift+Enter would submit), so use Kitty CSI u.
|
|
111
|
+
if (sequence.length === 1) return `\x1b[${sequence.charCodeAt(0)};${modifier}u`;
|
|
109
112
|
return sequence;
|
|
110
113
|
}
|
|
111
114
|
|
|
@@ -144,7 +147,7 @@ function encodeChord(token: string): string {
|
|
|
144
147
|
if (alt && encoded === special) encoded = `\x1b${encoded}`;
|
|
145
148
|
return encoded;
|
|
146
149
|
}
|
|
147
|
-
if (Array.from(rest).length !== 1) throw new Error(`unknown key ${rest}`);
|
|
150
|
+
if (Array.from(rest).length !== 1) throw new Error(`unknown key ${rest} (quote literal text instead, e.g. ',')`);
|
|
148
151
|
let character = rest;
|
|
149
152
|
if (shift && /^[a-z]$/i.test(character)) character = character.toUpperCase();
|
|
150
153
|
if (ctrl) character = ctrlCharacter(character);
|
package/src/hotkeys-markdown.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AppKeybinding,
|
|
3
|
+
formatKeyHint,
|
|
3
4
|
formatKeyHints,
|
|
4
5
|
type KeybindingsManager,
|
|
5
6
|
keyHintPlatform,
|
|
6
|
-
modifierLabel,
|
|
7
7
|
} from "./app-keybindings";
|
|
8
8
|
import { canonicalKeyId } from "./keybindings";
|
|
9
9
|
|
|
@@ -18,10 +18,7 @@ function hotkeyLabel(bindings: HotkeysMarkdownBindings, action: AppKeybinding):
|
|
|
18
18
|
|
|
19
19
|
/** Build the platform-aware Markdown reference for effective application hotkeys. */
|
|
20
20
|
export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string {
|
|
21
|
-
const
|
|
22
|
-
const isMac = platform === "darwin";
|
|
23
|
-
const alt = modifierLabel("alt", platform);
|
|
24
|
-
const cmd = modifierLabel("super", platform);
|
|
21
|
+
const isMac = keyHintPlatform() === "darwin";
|
|
25
22
|
// CustomEditor tests the chord that was actually pressed, so exit keys split by role: a key
|
|
26
23
|
// that also carries tui.editor.deleteCharForward (the readline `^D` overlap) forward-deletes
|
|
27
24
|
// while the prompt holds a draft, any other exit key quits immediately. Mixed bindings such as
|
|
@@ -48,26 +45,26 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
|
|
|
48
45
|
"**Navigation**",
|
|
49
46
|
"| Key | Action |",
|
|
50
47
|
"|-----|--------|",
|
|
51
|
-
"
|
|
52
|
-
`| \`${alt}
|
|
53
|
-
isMac ?
|
|
54
|
-
isMac ?
|
|
48
|
+
`| \`${formatKeyHints(["up", "down", "left", "right"])}\` | Move cursor / browse history (${formatKeyHint("up")} when empty) |`,
|
|
49
|
+
`| \`${formatKeyHints(["alt+left", "alt+right"])}\` | Move by word |`,
|
|
50
|
+
`| \`${formatKeyHints(isMac ? ["ctrl+a", "home", "super+left"] : ["ctrl+a", "home"])}\` | Start of line |`,
|
|
51
|
+
`| \`${formatKeyHints(isMac ? ["ctrl+e", "end", "super+right"] : ["ctrl+e", "end"])}\` | End of line |`,
|
|
55
52
|
"",
|
|
56
53
|
"**Editing**",
|
|
57
54
|
"| Key | Action |",
|
|
58
55
|
"|-----|--------|",
|
|
59
|
-
"
|
|
60
|
-
`|
|
|
61
|
-
`|
|
|
62
|
-
"
|
|
63
|
-
"
|
|
56
|
+
`| \`${formatKeyHint("enter")}\` | Send message |`,
|
|
57
|
+
`| \`${formatKeyHints(["shift+enter", "alt+enter"])}\` | New line |`,
|
|
58
|
+
`| \`${formatKeyHints(["ctrl+w", "alt+backspace"])}\` | Delete word backwards |`,
|
|
59
|
+
`| \`${formatKeyHint("ctrl+u")}\` | Delete to start of line |`,
|
|
60
|
+
`| \`${formatKeyHint("ctrl+k")}\` | Delete to end of line |`,
|
|
64
61
|
`| \`${hotkeyLabel(bindings, "app.clipboard.copyLine")}\` | Copy current line |`,
|
|
65
62
|
`| \`${hotkeyLabel(bindings, "app.clipboard.copyPrompt")}\` | Copy whole prompt |`,
|
|
66
63
|
"",
|
|
67
64
|
"**Other**",
|
|
68
65
|
"| Key | Action |",
|
|
69
66
|
"|-----|--------|",
|
|
70
|
-
"
|
|
67
|
+
`| \`${formatKeyHint("tab")}\` | Path completion / accept autocomplete |`,
|
|
71
68
|
`| \`${hotkeyLabel(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`,
|
|
72
69
|
`| \`${hotkeyLabel(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`,
|
|
73
70
|
...exitRows,
|
|
@@ -86,9 +83,9 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
|
|
|
86
83
|
`| \`${hotkeyLabel(bindings, "app.editor.external")}\` | Edit message in external editor |`,
|
|
87
84
|
`| \`${hotkeyLabel(bindings, "app.retry")}\` | Retry last failed assistant turn |`,
|
|
88
85
|
`| \`${hotkeyLabel(bindings, "app.clipboard.pasteImage")}\` | Paste image or text from clipboard |`,
|
|
89
|
-
|
|
86
|
+
`| Hold \`${formatKeyHint("space")}\` | Speech-to-text (push-to-talk): hold to record, release to transcribe |`,
|
|
90
87
|
`| \`${hotkeyLabel(bindings, "app.live.toggle")}\` | Start/stop live voice mode (/live) |`,
|
|
91
|
-
`| \`${hotkeyLabel(bindings, "app.agents.hub")}\` / \`${hotkeyLabel(bindings, "app.session.observe")}\` / double-tap
|
|
88
|
+
`| \`${hotkeyLabel(bindings, "app.agents.hub")}\` / \`${hotkeyLabel(bindings, "app.session.observe")}\` / double-tap \`${formatKeyHint("left")}\` (empty editor) | Open the agent hub |`,
|
|
92
89
|
"| `#<number>` | GitHub issue/PR reference (e.g. `#3164` → `pr://`/`issue://`) |",
|
|
93
90
|
"| `#` / `#<text>` | Prompt actions (copy / undo / move cursor) |",
|
|
94
91
|
"| `/` | Slash commands |",
|