@oh-my-pi/pi-tui 18.3.2 → 18.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/types/app-keybindings.d.ts +2 -17
  3. package/dist/types/autocomplete.d.ts +18 -3
  4. package/dist/types/chrome/keybinding-hints.d.ts +16 -10
  5. package/dist/types/components/editor.d.ts +11 -0
  6. package/dist/types/key-hint-format.d.ts +29 -0
  7. package/dist/types/keys.d.ts +2 -1
  8. package/dist/types/overlays/agent-hub.d.ts +1 -1
  9. package/dist/types/overlays/agent-transcript-viewer.d.ts +1 -1
  10. package/dist/types/overlays/model-picker.d.ts +1 -3
  11. package/dist/types/prompt/composer-hints.d.ts +42 -0
  12. package/dist/types/prompt/composer.d.ts +2 -1
  13. package/dist/types/prompt/custom-editor.d.ts +8 -2
  14. package/dist/types/prompt/macos-spelling.d.ts +7 -16
  15. package/dist/types/prompt/prompt-action-autocomplete.d.ts +4 -1
  16. package/dist/types/prompt/prose-gate.d.ts +24 -0
  17. package/dist/types/prompt/word-completion.d.ts +46 -0
  18. package/dist/types/render/render-utils.d.ts +1 -1
  19. package/dist/types/theme/active-symbols.d.ts +12 -0
  20. package/dist/types/theme/symbols.d.ts +1 -1
  21. package/dist/types/theme/theme-class.d.ts +0 -1
  22. package/dist/types/tools/eval.d.ts +1 -1
  23. package/dist/types/tools/mcp.d.ts +3 -1
  24. package/dist/types/tools/task-yield-assembly.d.ts +10 -2
  25. package/dist/types/tools/task.d.ts +4 -0
  26. package/package.json +11 -10
  27. package/src/app-keybindings.ts +11 -104
  28. package/src/apps/autoresearch-dashboard.ts +10 -3
  29. package/src/apps/debug/log-viewer.ts +3 -2
  30. package/src/apps/debug/raw-sse.ts +2 -1
  31. package/src/apps/git/git-tui.ts +9 -5
  32. package/src/apps/live-visualizer.ts +2 -1
  33. package/src/apps/ps-top.ts +13 -4
  34. package/src/autocomplete.ts +42 -7
  35. package/src/chat/compaction-summary-message.ts +2 -1
  36. package/src/chat/execution-shared.ts +4 -2
  37. package/src/chat/tool-execution.ts +3 -1
  38. package/src/chat/ttsr-notification.ts +4 -3
  39. package/src/chat/user-message.ts +2 -2
  40. package/src/chrome/keybinding-hints.ts +38 -13
  41. package/src/components/editor.ts +180 -58
  42. package/src/components/settings-list.ts +10 -3
  43. package/src/components/tab-bar.ts +2 -1
  44. package/src/debug-server.ts +4 -1
  45. package/src/hotkeys-markdown.ts +14 -17
  46. package/src/key-hint-format.ts +122 -0
  47. package/src/keys.ts +2 -1
  48. package/src/overlays/advisor-config.ts +28 -6
  49. package/src/overlays/agent-hub.ts +8 -5
  50. package/src/overlays/agent-transcript-viewer.ts +6 -4
  51. package/src/overlays/agents-hub.ts +22 -8
  52. package/src/overlays/annotation-overlay.ts +31 -17
  53. package/src/overlays/ask-dialog.ts +14 -19
  54. package/src/overlays/bordered-loader.ts +2 -1
  55. package/src/overlays/btw-history-panel.ts +15 -12
  56. package/src/overlays/btw-panel.ts +12 -7
  57. package/src/overlays/cleanse-panel.ts +9 -6
  58. package/src/overlays/codex-reset-fireworks.ts +5 -3
  59. package/src/overlays/copy-selector.ts +13 -5
  60. package/src/overlays/extensions/extension-dashboard.ts +6 -1
  61. package/src/overlays/history-search.ts +3 -2
  62. package/src/overlays/hook-editor.ts +7 -2
  63. package/src/overlays/hook-input.ts +2 -1
  64. package/src/overlays/hook-selector.ts +5 -1
  65. package/src/overlays/login-dialog.ts +17 -4
  66. package/src/overlays/logout-account-selector.ts +10 -1
  67. package/src/overlays/mcp-add-wizard.ts +32 -20
  68. package/src/overlays/model-hub.ts +55 -28
  69. package/src/overlays/model-picker.ts +27 -12
  70. package/src/overlays/move-overlay.ts +5 -1
  71. package/src/overlays/omfg-panel.ts +8 -5
  72. package/src/overlays/pause-screen.ts +7 -3
  73. package/src/overlays/plan-review-overlay.ts +40 -14
  74. package/src/overlays/plan-save-overlay.ts +3 -1
  75. package/src/overlays/plugin-settings.ts +15 -8
  76. package/src/overlays/reset-usage-selector.ts +10 -3
  77. package/src/overlays/rewind-selector.ts +11 -5
  78. package/src/overlays/session-info-overlay.ts +4 -3
  79. package/src/overlays/session-selector.ts +12 -2
  80. package/src/overlays/settings-selector.ts +18 -11
  81. package/src/overlays/tree-selector.ts +27 -4
  82. package/src/overlays/usage-dashboard.ts +8 -2
  83. package/src/prompt/composer-cache.ts +2 -1
  84. package/src/prompt/composer-hints.ts +75 -0
  85. package/src/prompt/composer.ts +3 -2
  86. package/src/prompt/custom-editor.ts +22 -4
  87. package/src/prompt/macos-spelling.ts +17 -158
  88. package/src/prompt/prompt-action-autocomplete.ts +9 -2
  89. package/src/prompt/prose-gate.ts +87 -0
  90. package/src/prompt/tips.txt +6 -6
  91. package/src/prompt/welcome.ts +35 -2
  92. package/src/prompt/word-completion.ts +205 -0
  93. package/src/render/render-utils.ts +4 -4
  94. package/src/setup/scenes/composer.ts +5 -1
  95. package/src/setup/scenes/model.ts +3 -1
  96. package/src/setup/scenes/providers.ts +4 -1
  97. package/src/setup/scenes/sign-in.ts +4 -2
  98. package/src/setup/scenes/splash.ts +9 -4
  99. package/src/setup/scenes/theme.ts +18 -5
  100. package/src/setup/wizard-overlay.ts +5 -4
  101. package/src/theme/active-symbols.ts +20 -0
  102. package/src/theme/glyph-bundle.json +28 -1
  103. package/src/theme/symbols.ts +88 -6
  104. package/src/theme/theme-class.ts +0 -1
  105. package/src/theme/theme.ts +2 -0
  106. package/src/tools/bash-interactive.ts +2 -1
  107. package/src/tools/eval.ts +3 -3
  108. package/src/tools/mcp.ts +3 -1
  109. package/src/tools/task-yield-assembly.ts +34 -8
  110. package/src/tools/task.ts +14 -5
  111. package/dist/types/overlays/tiny-title-download-progress.d.ts +0 -21
  112. package/src/overlays/tiny-title-download-progress.ts +0 -102
@@ -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
- #lastAction: "kill" | "yank" | "type-word" | null = null;
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: false,
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
- // Compute inline hint text (dim ghost text after cursor)
1269
- const inlineHint = this.#getInlineHint();
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 (after.length === 0 && inlineHint) {
1370
- const availWidth = Math.max(0, lineContentWidth - displayWidth);
1371
- const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
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
- displayText = before + marker + this.cursorOverride;
1423
- displayWidth += overrideWidth;
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
- displayText = before + marker + cursor;
1441
- displayWidth += cursorWidth;
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
- if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
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
- // At end of line, accept the inline ghost word completion (IME-style) like Tab.
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
- const line = this.#state.lines[this.#state.cursorLine] || "";
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, this.#state.cursorCol);
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("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : AUTOCOMPLETE_SELECT_LIST_LAYOUT;
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
- /** Insert the inline ghost word completion at the cursor, if any. Shared by Tab and right-arrow accept. */
4051
- #acceptWordCompletion(): boolean {
4052
- const wordCompletion = this.#getWordCompletion();
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
- const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
4055
- const after = currentLine.slice(this.#state.cursorCol);
4056
- this.#insertTextAtCursor(wordCompletion + (/^[\s.,;:!?"\])}]/.test(after) ? "" : " "));
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.#autocompletePrefix = suggestions.prefix;
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
- lines.push(truncateToWidth(this.#theme.hint(" Backspace to edit search · Esc to cancel"), width));
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 = sections.length >= 2 ? "PgUp/PgDn to jump sections · " : "";
642
- const hintText = this.#options.hint ?? `Enter/Space to change · ${jumpHint}Type to search · Esc to cancel`;
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("(tab to cycle)") });
223
+ chunks.push({ text: this.#theme.hint(`(${formatKeyHint("tab")} to cycle)`) });
223
224
  }
224
225
  return chunks;
225
226
  };
@@ -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);
@@ -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 platform = keyHintPlatform();
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
- "| `Arrow keys` | Move cursor / browse history (Up when empty) |",
52
- `| \`${alt}+Left/Right\` | Move by word |`,
53
- isMac ? `| \`Ctrl+A\` / \`Home\` / \`${cmd}+Left\` | Start of line |` : "| `Ctrl+A` / `Home` | Start of line |",
54
- isMac ? `| \`Ctrl+E\` / \`End\` / \`${cmd}+Right\` | End of line |` : "| `Ctrl+E` / `End` | End of line |",
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
- "| `Enter` | Send message |",
60
- `| \`Shift+Enter\` / \`${alt}+Enter\` | New line |`,
61
- `| \`Ctrl+W\` / \`${alt}+Backspace\` | Delete word backwards |`,
62
- "| `Ctrl+U` | Delete to start of line |",
63
- "| `Ctrl+K` | Delete to end of line |",
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
- "| `Tab` | Path completion / accept autocomplete |",
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
- "| Hold `Space` | Speech-to-text (push-to-talk): hold to record, release to transcribe |",
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 \`←\` (empty editor) | Open the agent hub |`,
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 |",