@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.
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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.3.2",
4
+ "version": "18.3.4",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -37,17 +37,18 @@
37
37
  "lint": "oxlint .",
38
38
  "test": "bun test --parallel test/*.test.ts",
39
39
  "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
40
- "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
40
+ "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'",
41
+ "gen:glyphs": "bun scripts/gen-glyphs.ts"
41
42
  },
42
43
  "dependencies": {
43
- "@oh-my-pi/omptype": "18.3.2",
44
- "@oh-my-pi/pi-agent-core": "18.3.2",
45
- "@oh-my-pi/pi-ai": "18.3.2",
46
- "@oh-my-pi/pi-catalog": "18.3.2",
47
- "@oh-my-pi/pi-natives": "18.3.2",
48
- "@oh-my-pi/pi-utils": "18.3.2",
49
- "@oh-my-pi/pi-wire": "18.3.2",
50
- "@oh-my-pi/snapcompact": "18.3.2"
44
+ "@oh-my-pi/omptype": "18.3.4",
45
+ "@oh-my-pi/pi-agent-core": "18.3.4",
46
+ "@oh-my-pi/pi-ai": "18.3.4",
47
+ "@oh-my-pi/pi-catalog": "18.3.4",
48
+ "@oh-my-pi/pi-natives": "18.3.4",
49
+ "@oh-my-pi/pi-utils": "18.3.4",
50
+ "@oh-my-pi/pi-wire": "18.3.4",
51
+ "@oh-my-pi/snapcompact": "18.3.4"
51
52
  },
52
53
  "devDependencies": {
53
54
  "kitty-vt-wasm": "^0.2.0"
@@ -1,14 +1,14 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import {
4
- getActiveProfile,
5
- getAgentDir,
6
- getProfileRootDir,
7
- isEnoent,
8
- logger,
9
- stringifyYamlConfig,
10
- } from "@oh-my-pi/pi-utils";
3
+ // Subpaths, not the `@oh-my-pi/pi-utils` barrel: the barrel loads the native
4
+ // addon (file-lock), and key-hint formatting runs on addon-free CLI paths
5
+ // (`omp --version`, help) through cli/command-help.ts.
6
+ import { getActiveProfile, getAgentDir, getProfileRootDir } from "@oh-my-pi/pi-utils/dirs";
7
+ import { isEnoent } from "@oh-my-pi/pi-utils/fs-error";
8
+ import * as logger from "@oh-my-pi/pi-utils/logger";
9
+ import { stringifyYamlConfig } from "@oh-my-pi/pi-utils/yaml-config";
11
10
  import { JSONC, YAML } from "bun";
11
+ import { formatKeyHints } from "./key-hint-format";
12
12
  import {
13
13
  type Keybinding,
14
14
  type KeybindingDefinitions,
@@ -645,12 +645,9 @@ export class KeybindingsManager extends TuiKeybindingsManager {
645
645
  return this.getResolvedBindings();
646
646
  }
647
647
 
648
- /**
649
- * Get display string for a keybinding (e.g., "ctrl+c/escape").
650
- */
648
+ /** Display string for a keybinding's keys (see {@link formatKeyHints}); empty when unbound. */
651
649
  getDisplayString(keybinding: Keybinding): string {
652
- const keys = this.getKeys(keybinding);
653
- return formatKeyHints(keys.length === 0 ? [] : keys);
650
+ return formatKeyHints(this.getKeys(keybinding));
654
651
  }
655
652
 
656
653
  /**
@@ -664,97 +661,7 @@ export class KeybindingsManager extends TuiKeybindingsManager {
664
661
  }
665
662
  }
666
663
 
667
- /**
668
- * Key hint formatting utilities for UI labels.
669
- *
670
- * Modifier labels are platform-aware: macOS names the physical keys `Option`
671
- * (`alt`) and `Cmd` (`super`), so rendering `Alt`/`Super` there would name keys
672
- * absent from a Mac keyboard. Every other platform keeps `Alt`/`Super`.
673
- */
674
-
675
- /**
676
- * Platform override for key-hint rendering; `undefined` resolves to the host
677
- * `process.platform`. Mirrors `setKittyProtocolActive` in the TUI keys module:
678
- * a single seam that keeps hint output deterministic in tests without mutating
679
- * the global `process.platform`.
680
- */
681
- let keyHintPlatformOverride: NodeJS.Platform | undefined;
682
-
683
- /** Pin the platform used to render modifier labels (test seam). */
684
- export function setKeyHintPlatform(platform: NodeJS.Platform | undefined): void {
685
- keyHintPlatformOverride = platform;
686
- }
687
-
688
- /** Platform currently used for key-hint rendering. */
689
- export function keyHintPlatform(): NodeJS.Platform {
690
- return keyHintPlatformOverride ?? process.platform;
691
- }
692
-
693
- type Modifier = "ctrl" | "shift" | "alt" | "super";
694
-
695
- function isModifier(part: string): part is Modifier {
696
- return part === "ctrl" || part === "shift" || part === "alt" || part === "super";
697
- }
698
-
699
- /**
700
- * Human label for a modifier, using each platform's own key names. `ctrl` and
701
- * `shift` are the same everywhere; `alt`/`super` become `Option`/`Cmd` on macOS.
702
- */
703
- export function modifierLabel(mod: Modifier, platform: NodeJS.Platform = keyHintPlatform()): string {
704
- switch (mod) {
705
- case "ctrl":
706
- return "Ctrl";
707
- case "shift":
708
- return "Shift";
709
- case "alt":
710
- return platform === "darwin" ? "Option" : "Alt";
711
- case "super":
712
- return platform === "darwin" ? "Cmd" : "Super";
713
- }
714
- }
715
-
716
- const KEY_LABELS: Record<string, string> = {
717
- esc: "Esc",
718
- escape: "Esc",
719
- enter: "Enter",
720
- return: "Enter",
721
- space: "Space",
722
- tab: "Tab",
723
- backspace: "Backspace",
724
- delete: "Delete",
725
- home: "Home",
726
- end: "End",
727
- pageup: "PgUp",
728
- pagedown: "PgDn",
729
- up: "Up",
730
- down: "Down",
731
- left: "Left",
732
- right: "Right",
733
- };
734
-
735
- function formatKeyPart(part: string, platform: NodeJS.Platform): string {
736
- const lower = part.toLowerCase();
737
- if (isModifier(lower)) return modifierLabel(lower, platform);
738
- const label = KEY_LABELS[lower];
739
- if (label) return label;
740
- if (part.length === 1) return part.toUpperCase();
741
- return `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
742
- }
743
-
744
- /** Format a key chord with human-readable, platform-aware modifier labels. */
745
- export function formatKeyHint(key: KeyId): string {
746
- const platform = keyHintPlatform();
747
- return key
748
- .split("+")
749
- .map(part => formatKeyPart(part, platform))
750
- .join("+");
751
- }
752
-
753
- /** Format alternative key chords as slash-separated human-readable hints. */
754
- export function formatKeyHints(keys: KeyId | KeyId[]): string {
755
- const list = Array.isArray(keys) ? keys : [keys];
756
- return list.map(formatKeyHint).join("/");
757
- }
664
+ export * from "./key-hint-format";
758
665
 
759
666
  export type { Keybinding, KeybindingsConfig, KeyId };
760
667
  export { migrateKeybindingsConfigFile };
@@ -1,3 +1,4 @@
1
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
1
2
  import { matchesKey } from "../keys";
2
3
  import { replaceTabs, truncateToWidth, visibleWidth } from "../utils";
3
4
  import { ScrollView } from "../components/scroll-view";
@@ -186,7 +187,10 @@ function renderExpandedHeader(runtime: AutoresearchDashboardRuntime, width: numb
186
187
  const state = runtime.state;
187
188
  const status = renderModeStatus(runtime, state);
188
189
  const label = state.name ? ` autoresearch: ${replaceTabs(state.name)} ` : " autoresearch ";
189
- const hint = theme.fg("dim", ` ctrl+x collapse ctrl+shift+x overlay${status ? ` ${status}` : ""} `);
190
+ const hint = theme.fg(
191
+ "dim",
192
+ ` ${formatKeyHint("ctrl+x")} collapse ${formatKeyHint("ctrl+shift+x")} overlay${status ? ` ${status}` : ""} `,
193
+ );
190
194
  const fillWidth = Math.max(0, width - visibleWidth(label) - visibleWidth(hint));
191
195
  return truncateToWidth(theme.fg("accent", label) + theme.fg("borderMuted", "-".repeat(fillWidth)) + hint, width);
192
196
  }
@@ -256,7 +260,7 @@ function renderCollapsedLine(runtime: AutoresearchDashboardRuntime, state: Exper
256
260
  } else if (!runtime.autoresearchMode) {
257
261
  parts.push(theme.fg("dim", ` | ${renderModeStatus(runtime, state)}`));
258
262
  }
259
- parts.push(theme.fg("dim", " | ctrl+x expand"));
263
+ parts.push(theme.fg("dim", ` | ${formatKeyHint("ctrl+x")} expand`));
260
264
  return parts.join("");
261
265
  }
262
266
 
@@ -471,7 +475,10 @@ function renderOverlayRunningLine(
471
475
  }
472
476
 
473
477
  function renderOverlayFooter(width: number, theme: Theme): string {
474
- const hint = theme.fg("dim", " up/down j/k pageup pagedown g G esc ");
478
+ const hint = theme.fg(
479
+ "dim",
480
+ ` ${formatKeyHints(["up", "down"])} ${formatKeyHints(["j", "k"])} ${formatKeyHint("pageUp")} ${formatKeyHint("pageDown")} ${formatKeyHint("g")} ${formatKeyHint("shift+g")} ${formatKeyHint("escape")} `,
481
+ );
475
482
  const fill = Math.max(0, width - visibleWidth(hint));
476
483
  return theme.fg("borderMuted", "-".repeat(fill)) + hint;
477
484
  }
@@ -1,3 +1,4 @@
1
+ import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
1
2
  import type { Component } from "../../tui";
2
3
  import { extractPrintableText, matchesKey } from "../../keys";
3
4
  import { routeSgrMouseInput, type SgrMouseEvent } from "../../mouse";
@@ -715,7 +716,7 @@ export class DebugLogViewerComponent implements Component {
715
716
  }
716
717
 
717
718
  #controlsText(): string {
718
- return "Esc close · Ctrl+C copy · ↑/↓/wheel move · click toggle · Shift+↑/↓ select · ←/→ collapse/expand · Ctrl+A all · Ctrl+O older · Ctrl+P pid";
719
+ return `${formatKeyHint("escape")} close · ${formatKeyHint("ctrl+c")} copy · ${formatKeyHints(["up", "down"])}/wheel move · click toggle · ${formatKeyHints(["shift+up", "shift+down"])} select · ${formatKeyHints(["left", "right"])} collapse/expand · ${formatKeyHint("ctrl+a")} all · ${formatKeyHint("ctrl+o")} older · ${formatKeyHint("ctrl+p")} pid`;
719
720
  }
720
721
 
721
722
  #filterText(): string {
@@ -731,7 +732,7 @@ export class DebugLogViewerComponent implements Component {
731
732
  #statusText(): string {
732
733
  return this.#statusMessage
733
734
  ? theme.fg("success", this.#statusMessage)
734
- : theme.fg("dim", "Enter loads older when highlighted; printable keys update filter");
735
+ : theme.fg("dim", `${formatKeyHint("enter")} loads older when highlighted; printable keys update filter`);
735
736
  }
736
737
 
737
738
  async #handleLoadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise<void> {
@@ -1,3 +1,4 @@
1
+ import { formatKeyHint } from "../../app-keybindings";
1
2
  import type { Component } from "../../tui";
2
3
  import { matchesKey } from "../../keys";
3
4
  import { routeSgrMouseInput, type SgrMouseEvent } from "../../mouse";
@@ -252,7 +253,7 @@ export class RawSseViewerComponent implements Component {
252
253
  }
253
254
 
254
255
  #statusText(): string {
255
- const help = "Esc close · Ctrl+C copy raw · End follow tail · wheel scroll · click summary toggles follow";
256
+ const help = `${formatKeyHint("escape")} close · ${formatKeyHint("ctrl+c")} copy raw · ${formatKeyHint("end")} follow tail · wheel scroll · click summary toggles follow`;
256
257
  return this.#statusMessage
257
258
  ? `${theme.fg("success", this.#statusMessage)} ${theme.fg("dim", help)}`
258
259
  : theme.fg("dim", help);
@@ -24,6 +24,7 @@
24
24
  * every file underneath it.
25
25
  */
26
26
 
27
+ import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
27
28
  import { SplitPane } from "../../components/layout/split-pane";
28
29
  import { Stack } from "../../components/layout/stack";
29
30
  import { matchesKey } from "../../keys";
@@ -340,7 +341,10 @@ class GitTuiComponent implements Component {
340
341
  if (this.#pendingDiscard !== key) {
341
342
  this.#pendingDiscard = key;
342
343
  this.#setStatus(
343
- theme.fg("warning", `Discard changes to ${action.selection.label}? Press delete again to confirm`),
344
+ theme.fg(
345
+ "warning",
346
+ `Discard changes to ${action.selection.label}? Press ${formatKeyHint("delete")} again to confirm`,
347
+ ),
344
348
  );
345
349
  return;
346
350
  }
@@ -451,7 +455,7 @@ class GitTuiComponent implements Component {
451
455
  if (!hunk.patch) return;
452
456
  if (action === "discard" && this.#pendingDiscard !== hunk.patch) {
453
457
  this.#pendingDiscard = hunk.patch;
454
- this.#setStatus(theme.fg("warning", "Discard hunk? Press x (or click) again to confirm"));
458
+ this.#setStatus(theme.fg("warning", `Discard hunk? Press ${formatKeyHint("x")} (or click) again to confirm`));
455
459
  return;
456
460
  }
457
461
  this.#pendingDiscard = null;
@@ -487,7 +491,7 @@ class GitTuiComponent implements Component {
487
491
  }
488
492
  if (action === "discard" && this.#pendingDiscard !== patch) {
489
493
  this.#pendingDiscard = patch;
490
- this.#setStatus(theme.fg("warning", "Discard selected lines? Press x again to confirm"));
494
+ this.#setStatus(theme.fg("warning", `Discard selected lines? Press ${formatKeyHint("x")} again to confirm`));
491
495
  return;
492
496
  }
493
497
  this.#pendingDiscard = null;
@@ -768,8 +772,8 @@ class GitTuiComponent implements Component {
768
772
  theme.fg(
769
773
  "dim",
770
774
  this.#focus === "diff"
771
- ? "alt+↓/↑ hunk · ]/[ file · shift+↑/↓ select · s/u stage · x/del discard · v view · c commit · q quit"
772
- : "↑/↓ move · ←/→ fold · space stage · del discard · enter open · alt+↓/↑ hunk · c commit · t tree · q quit",
775
+ ? `${formatKeyHints(["alt+down", "alt+up"])} hunk · ${formatKeyHints(["]", "["])} file · ${formatKeyHints(["shift+up", "shift+down"])} select · ${formatKeyHints(["s", "u"])} stage · ${formatKeyHints(["x", "delete"])} discard · ${formatKeyHint("v")} view · ${formatKeyHint("c")} commit · ${formatKeyHint("q")} quit`
776
+ : `${formatKeyHints(["up", "down"])} move · ${formatKeyHints(["left", "right"])} fold · ${formatKeyHint("space")} stage · ${formatKeyHint("delete")} discard · ${formatKeyHint("enter")} open · ${formatKeyHints(["alt+down", "alt+up"])} hunk · ${formatKeyHint("c")} commit · ${formatKeyHint("t")} tree · ${formatKeyHint("q")} quit`,
773
777
  );
774
778
  const free = width - row.width - right.width - 1;
775
779
  const middleText = free > visibleWidth(middle) + 4 ? middle : truncateToWidth(middle, Math.max(0, free - 4));
@@ -1,3 +1,4 @@
1
+ import { formatKeyHint } from "../app-keybindings";
1
2
  import type { Component } from "../tui";
2
3
  import { OverlayPanel, PanelRows } from "../chrome/overlay-box";
3
4
  import { type KeyId, matchesKey } from "../keys";
@@ -197,7 +198,7 @@ export class LiveVisualizer implements Component {
197
198
  const frames = theme.spinnerFrames;
198
199
  const icon = this.#phase === "working" ? frames[this.#frame % frames.length] : PHASE_ICONS[this.#phase];
199
200
  const status = `${icon} ${this.#phase}`;
200
- const fullLabel = ` ${status} · space mute · esc end `;
201
+ const fullLabel = ` ${status} · ${formatKeyHint("space")} mute · ${formatKeyHint("escape")} end `;
201
202
  const shortLabel = ` ${status} `;
202
203
  const label =
203
204
  innerWidth >= visibleWidth(fullLabel) + 1
@@ -6,6 +6,7 @@
6
6
  * `x` kill, `r` restart, `a` toggle all scopes, `q`/`esc`/`ctrl+c` quit.
7
7
  * Sub-views (info, logs): `esc`/`q` back.
8
8
  */
9
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
9
10
  import { KeyValueList, type KeyValueRow } from "../components/key-value-list";
10
11
  import { ScrollView } from "../components/scroll-view";
11
12
  import { renderTableRow, type TableColumn } from "../components/table";
@@ -281,11 +282,16 @@ export class PsTopComponent implements Component {
281
282
  }
282
283
 
283
284
  #renderTable(width: number, height: number): string[] {
284
- const scopesLabel = `${this.#flat.length} process${this.#flat.length === 1 ? "" : "es"} in ${this.#reports.length} scope${this.#reports.length === 1 ? "" : "s"} ${chalk.dim(this.#all ? "(all)" : "(current)")}`;
285
+ const scopeKind = this.#all
286
+ ? "(all)"
287
+ : this.#target.dir !== undefined || this.#target.global !== undefined
288
+ ? "(target)"
289
+ : "(current + global)";
290
+ const scopesLabel = `${this.#flat.length} process${this.#flat.length === 1 ? "" : "es"} in ${this.#reports.length} scope${this.#reports.length === 1 ? "" : "s"} ${chalk.dim(scopeKind)}`;
285
291
  const header = this.#header(width, scopesLabel);
286
292
  const footer = this.#footer(
287
293
  width,
288
- "↑/↓ select · enter info · l logs · s stop · x kill · r restart · a all scopes · q quit",
294
+ `${formatKeyHints(["up", "down"])} select · ${formatKeyHint("enter")} info · ${formatKeyHint("l")} logs · ${formatKeyHint("s")} stop · ${formatKeyHint("x")} kill · ${formatKeyHint("r")} restart · ${formatKeyHint("a")} all scopes · ${formatKeyHint("q")} quit`,
289
295
  );
290
296
  const bodyHeight = height - 1 - footer.length;
291
297
 
@@ -350,7 +356,7 @@ export class PsTopComponent implements Component {
350
356
  #renderInfo(width: number, height: number): string[] {
351
357
  const info = this.#info;
352
358
  const header = this.#header(width, "process info");
353
- const footer = this.#footer(width, "esc back · q back");
359
+ const footer = this.#footer(width, `${formatKeyHint("escape")} back · ${formatKeyHint("q")} back`);
354
360
  const body = [""];
355
361
  if (info) {
356
362
  const daemon = info.daemon;
@@ -383,7 +389,10 @@ export class PsTopComponent implements Component {
383
389
  width,
384
390
  `logs ${chalk.bold(name)}${this.#logsState ? chalk.dim(` · ${this.#logsState}`) : ""}`,
385
391
  );
386
- const footer = this.#footer(width, "esc back · q back · view refreshes live");
392
+ const footer = this.#footer(
393
+ width,
394
+ `${formatKeyHint("escape")} back · ${formatKeyHint("q")} back · view refreshes live`,
395
+ );
387
396
  const bodyHeight = height - 1 - footer.length;
388
397
  this.#logsView.setLines(this.#logsLines.map(line => ` ${line}`));
389
398
  this.#logsView.setHeight(bodyHeight);
@@ -6,6 +6,14 @@ import { getProjectDir } from "@oh-my-pi/pi-utils";
6
6
 
7
7
  const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
8
8
 
9
+ /**
10
+ * How long an `@` fuzzy search may run before the immediate-directory prefix
11
+ * listing is reported through `onPartial`. Fuzzy walks of normal repos finish
12
+ * well under this, so they never flash an interim list; huge roots (a volume
13
+ * of sibling projects) take seconds and would otherwise show nothing new.
14
+ */
15
+ const AT_PARTIAL_DELAY_MS = 150;
16
+
9
17
  function buildAutocompleteFuzzyDiscoveryProfile(
10
18
  query: string,
11
19
  basePath: string,
@@ -149,6 +157,17 @@ export function subsequenceMatch(query: string, target: string): boolean {
149
157
  return qi === query.length;
150
158
  }
151
159
 
160
+ /**
161
+ * Whether an `@` file completion `value` still fits the live `@` token.
162
+ * The editor narrows a stale `@` list with this while a fresh search runs;
163
+ * mirrors the subsequence filter `getSuggestions` applies to fuzzy results.
164
+ */
165
+ export function atCompletionMatches(token: string, value: string): boolean {
166
+ const query = parsePathPrefix(token).rawPrefix.replaceAll("\\", "/").toLowerCase();
167
+ const target = parsePathPrefix(value).rawPrefix.replace(/"$/, "").toLowerCase();
168
+ return subsequenceMatch(query, target);
169
+ }
170
+
152
171
  /** Ranked-tier subsequence score (100/80/60/40−gaps·5); higher is better, 0 is no match. */
153
172
  export function subsequenceScore(query: string, target: string): number {
154
173
  if (query.length === 0) return 1;
@@ -205,12 +224,16 @@ export interface SlashCommand {
205
224
  }
206
225
 
207
226
  export interface AutocompleteProvider {
208
- /** Get autocomplete suggestions for current text/cursor position. Expensive providers SHOULD stop when `signal` aborts. */
227
+ /**
228
+ * Get autocomplete suggestions for current text/cursor position. Expensive providers SHOULD stop when `signal` aborts.
229
+ * Slow providers MAY report interim suggestions through `onPartial` before resolving; the resolved value supersedes them.
230
+ */
209
231
  getSuggestions(
210
232
  lines: string[],
211
233
  cursorLine: number,
212
234
  cursorCol: number,
213
235
  signal?: AbortSignal,
236
+ onPartial?: (suggestions: { items: AutocompleteItem[]; prefix: string }) => void,
214
237
  ): Promise<{
215
238
  items: AutocompleteItem[];
216
239
  prefix: string; // What we're matching against (e.g., "/" or "src/")
@@ -558,6 +581,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
558
581
  cursorLine: number,
559
582
  cursorCol: number,
560
583
  signal?: AbortSignal,
584
+ onPartial?: (suggestions: { items: AutocompleteItem[]; prefix: string }) => void,
561
585
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
562
586
  if (signal?.aborted) return null;
563
587
  const currentLine = lines[cursorLine] || "";
@@ -653,16 +677,27 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
653
677
  if (items.length === 0) return null;
654
678
  return { items, prefix: atPrefix };
655
679
  }
656
- const suggestions =
657
- rawPrefix.length > 0
658
- ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix, signal })
659
- : await this.#getFileSuggestions("@");
660
- if (suggestions.length === 0 && rawPrefix.length > 0) {
680
+ if (rawPrefix.length === 0) {
681
+ const items = await this.#getFileSuggestions("@");
682
+ return items.length > 0 ? { items, prefix: atPrefix } : null;
683
+ }
684
+ const fuzzy = this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix, signal });
685
+ if (onPartial) {
686
+ const settled = await Promise.race([
687
+ fuzzy.then(() => true),
688
+ Bun.sleep(AT_PARTIAL_DELAY_MS).then(() => false),
689
+ ]);
690
+ if (!settled) {
691
+ const listing = await this.#getFileSuggestions(atPrefix);
692
+ if (listing.length > 0 && !signal?.aborted) onPartial({ items: listing, prefix: atPrefix });
693
+ }
694
+ }
695
+ const suggestions = await fuzzy;
696
+ if (suggestions.length === 0) {
661
697
  const fallback = await this.#getFileSuggestions(atPrefix);
662
698
  if (fallback.length === 0) return null;
663
699
  return { items: fallback, prefix: atPrefix };
664
700
  }
665
- if (suggestions.length === 0) return null;
666
701
 
667
702
  return {
668
703
  items: suggestions,
@@ -4,6 +4,7 @@ import { type Component } from "../tui";
4
4
  import { Markdown } from "../components/markdown";
5
5
  import { formatNumber } from "@oh-my-pi/pi-utils";
6
6
  import { getMarkdownTheme, theme } from "../theme";
7
+ import { expandKeyHint } from "../render/render-utils";
7
8
  import type { BranchSummaryMessage, CompactionSummaryMessage, CustomMessage } from "./messages";
8
9
 
9
10
  /** Divider labels per compaction method; unknown/legacy methods fall back to "compacted". */
@@ -54,7 +55,7 @@ class DividerSummary implements Component {
54
55
  const rule = theme.tree.horizontal;
55
56
  const label = this.#label();
56
57
  // sep.dot ships pre-padded (" · "); trim so the hint joins with single spaces.
57
- const hint = `${theme.sep.dot.trim()} ctrl+o`;
58
+ const hint = `${theme.sep.dot.trim()} ${expandKeyHint()}`;
58
59
  const plainWidth = Bun.stringWidth(`${label} ${hint}`, { countAnsiEscapeCodes: false });
59
60
  // ` label hint ` framed by rules on both sides.
60
61
  const remaining = width - plainWidth - 2;
@@ -15,6 +15,8 @@ import type { OutputArtifactError } from "../tools/streaming-output";
15
15
  import { formatArtifactErrorNotice, formatTruncationMetaNotice, type TruncationMeta } from "../tools/output-meta";
16
16
  import { DynamicBorder } from "../chrome/dynamic-border";
17
17
  import { Ellipsis, truncateToWidth, visibleWidth } from "../utils";
18
+ import { interruptKey } from "../chrome/keybinding-hints";
19
+ import { expandKeyHint } from "../render/render-utils";
18
20
 
19
21
  /** Output rows shown while an execution is collapsed. */
20
22
  export const PREVIEW_LINES = 20;
@@ -59,7 +61,7 @@ export function buildExecutionFrame(
59
61
  ui,
60
62
  spinner => theme.fg(colorKey, spinner),
61
63
  text => theme.fg("muted", text),
62
- `Running… (esc to cancel)`,
64
+ `Running… (${interruptKey()} to cancel)`,
63
65
  getSymbolTheme().spinnerFrames,
64
66
  );
65
67
 
@@ -84,7 +86,7 @@ export function buildStatusFooter(opts: {
84
86
  const parts: string[] = [];
85
87
 
86
88
  if (opts.hiddenLineCount > 0 && !opts.suppressHiddenCount) {
87
- parts.push(theme.fg("dim", `… ${opts.hiddenLineCount} more lines (ctrl+o to expand)`));
89
+ parts.push(theme.fg("dim", `… ${opts.hiddenLineCount} more lines (${expandKeyHint()} to expand)`));
88
90
  }
89
91
  if (opts.status === "cancelled") {
90
92
  parts.push(theme.fg("warning", "(cancelled)"));
@@ -937,7 +937,9 @@ export class ToolExecutionComponent extends Container {
937
937
  const benignSkip = this.#isBenignSkip();
938
938
  const stateBgKey =
939
939
  this.#isPartial || benignSkip ? "toolPendingBg" : this.#result?.isError ? "toolErrorBg" : "toolSuccessBg";
940
- const stateBgFn = (t: string) => theme.bg(stateBgKey, t);
940
+ // bgFill, not bg: rows carry nested full resets (e.g. truncateToWidth's
941
+ // `\x1b[0m` before its ellipsis) that would otherwise punch holes in the tint.
942
+ const stateBgFn = (t: string) => theme.bgFill(stateBgKey, t);
941
943
 
942
944
  // A benign skip is a synthetic placeholder for a call that never executed,
943
945
  // so bypass any bespoke error frame and draw the neutral generic card —
@@ -2,6 +2,7 @@ import { Text } from "../components/text";
2
2
  import { type Component, Container } from "../tui";
3
3
  import { MessageNoticeComponent, type MessageNoticePresentation } from "../chrome/message-notice";
4
4
  import { theme } from "../theme";
5
+ import { expandKeyHint } from "../render/render-utils";
5
6
 
6
7
  /** Rule fields shown in rewind notifications. */
7
8
  export interface NotificationRule {
@@ -83,7 +84,7 @@ export class TtsrNotificationComponent extends Container {
83
84
 
84
85
  const body: Component[] = [new Text(theme.italic(displayText), 0, 0)];
85
86
  if (truncated) {
86
- body.push(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
87
+ body.push(new Text(theme.italic(` (${expandKeyHint()} to expand)`), 0, 0));
87
88
  }
88
89
  return { icon: theme.icon.warning, header, body };
89
90
  }
@@ -114,9 +115,9 @@ export class TtsrNotificationComponent extends Container {
114
115
 
115
116
  const hidden = this.#rules.length - visible.length;
116
117
  if (hidden > 0) {
117
- body.push(new Text(theme.italic(`… +${hidden} more (ctrl+o to expand)`), 0, 0));
118
+ body.push(new Text(theme.italic(`… +${hidden} more (${expandKeyHint()} to expand)`), 0, 0));
118
119
  } else if (elidedDetail) {
119
- body.push(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
120
+ body.push(new Text(theme.italic(` (${expandKeyHint()} to expand)`), 0, 0));
120
121
  }
121
122
  return { icon: theme.icon.warning, header, body };
122
123
  }
@@ -15,7 +15,7 @@ import {
15
15
  skillChipStyle,
16
16
  } from "../prompt/composer-attachments";
17
17
  import { MODEL_MENTION_TAG_RE } from "../prompt/model-mention-syntax";
18
- import { fileHyperlink } from "../render";
18
+ import { expandKeyHint, fileHyperlink } from "../render";
19
19
  import { imageReferenceHyperlink } from "../prompt/image-references";
20
20
  import { highlightMagicKeywords } from "../prompt/magic-keywords";
21
21
  import type { ReactionTarget } from "./reaction";
@@ -198,7 +198,7 @@ class SyntheticSummary implements Component {
198
198
  render(width: number): readonly string[] {
199
199
  width = Math.max(1, width);
200
200
  if (this.#cache?.width === width) return this.#cache.lines;
201
- const hint = `${theme.sep.dot.trim()} ctrl+o`;
201
+ const hint = `${theme.sep.dot.trim()} ${expandKeyHint()}`;
202
202
  const lines = [` ${theme.fg("dim", truncateSummary(`${this.#summary} ${hint}`, Math.max(10, width - 1)))}`];
203
203
  this.#cache = { width, lines };
204
204
  return lines;
@@ -1,21 +1,49 @@
1
1
  /**
2
2
  * Utilities for formatting keybinding hints in the UI.
3
3
  */
4
- import { getKeybindings, type Keybinding } from "../keybindings";
5
- import { type AppKeybinding, formatKeyHints, type KeybindingsManager } from "../app-keybindings";
4
+ import { getKeybindings, type KeyId, type Keybinding } from "../keybindings";
5
+ import {
6
+ type AppKeybinding,
7
+ formatKeyHint,
8
+ formatKeyHints,
9
+ type KeyName,
10
+ type KeybindingsManager,
11
+ } from "../app-keybindings";
6
12
  import { theme } from "../theme/index";
13
+
7
14
  /**
8
- * Get display string for an editor action.
15
+ * Primary (first) key bound to an editor action, formatted for footer hints;
16
+ * empty when unbound. Full alternatives: `formatKeyHints(getKeybindings().getKeys(action))`.
9
17
  */
10
18
  export function editorKey(action: Keybinding): string {
11
- return formatKeyHints(getKeybindings().getKeys(action));
19
+ const [key] = getKeybindings().getKeys(action);
20
+ return key ? formatKeyHint(key) : "";
21
+ }
22
+
23
+ /** Primary keys of several actions, slash-joined: `editorKeys("tui.select.up", "tui.select.down")` → `↑/↓`. */
24
+ export function editorKeys(...actions: Keybinding[]): string {
25
+ return actions.map(editorKey).join("/");
12
26
  }
13
27
 
14
28
  /**
15
- * Get display string for an app action.
29
+ * Keys bound to `action`, or `fallback` when the active registry has none — a
30
+ * TUI-only registry carries no `app.*` bindings. Mirrors the fallbacks in
31
+ * `keybinding-matchers.ts`, so hints name the keys the matcher accepts.
16
32
  */
33
+ export function boundKeys(action: Keybinding, fallback: readonly KeyId[]): readonly KeyId[] {
34
+ const keys = getKeybindings().getKeys(action);
35
+ return keys.length > 0 ? keys : fallback;
36
+ }
37
+
38
+ /** Primary interrupt key (`app.interrupt`, raw Escape when unbound, as `matchesAppInterrupt`). */
39
+ export function interruptKey(): string {
40
+ return formatKeyHint(boundKeys("app.interrupt", ["escape"])[0] ?? "escape");
41
+ }
42
+
43
+ /** Primary key bound to an app action (see {@link editorKey}); all keys: `getDisplayString`. */
17
44
  export function appKey(keybindings: KeybindingsManager, action: AppKeybinding): string {
18
- return formatKeyHints(keybindings.getKeys(action));
45
+ const [key] = keybindings.getKeys(action);
46
+ return key ? formatKeyHint(key) : "";
19
47
  }
20
48
 
21
49
  /**
@@ -44,12 +72,9 @@ export function appKeyHint(keybindings: KeybindingsManager, action: AppKeybindin
44
72
  }
45
73
 
46
74
  /**
47
- * Format a raw key string with description (for non-configurable keys like ↑↓).
48
- *
49
- * @param key - Raw key string
50
- * @param description - Description text
51
- * @returns Formatted string with dim key and muted description
75
+ * Format a hint for fixed (non-configurable) keys, e.g. `rawKeyHint(["up", "down"], "navigate")`.
76
+ * Alternatives render slash-separated (see {@link formatKeyHints}).
52
77
  */
53
- export function rawKeyHint(key: string, description: string): string {
54
- return theme.fg("dim", key) + theme.fg("muted", ` ${description}`);
78
+ export function rawKeyHint(keys: KeyName | readonly KeyName[], description: string): string {
79
+ return theme.fg("dim", formatKeyHints(keys)) + theme.fg("muted", ` ${description}`);
55
80
  }