@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
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Key-hint formatting, kept free of the native addon: it imports only types
3
+ * plus the theme symbol mirror, so addon-free CLI paths (`omp --version`,
4
+ * help text in cli/command-help.ts) can format keys without loading
5
+ * `@oh-my-pi/pi-natives` through the keybindings registry.
6
+ */
7
+ import type { KeyId } from "./keybindings";
8
+ import type { ModifierName } from "./keys";
9
+ import { activeThemeSymbol } from "./theme/active-symbols";
10
+ import type { SymbolKey } from "./theme/symbols";
11
+
12
+ /**
13
+ * Key hint formatting: every key shown to the user renders through
14
+ * {@link formatKeyHint}, so a key reads the same everywhere in the UI.
15
+ *
16
+ * Keys resolve through the active theme's `key.*` symbols — words in the
17
+ * ascii preset, keycap glyphs in unicode, icons in nerd — falling back to the
18
+ * ascii words before a theme loads. Modifiers are platform-aware: macOS
19
+ * keycaps are labelled ⌃ ⌥ ⌘ (`key.*Mac`), while other platforms keep
20
+ * `Ctrl`/`Alt`/`Super`, since those glyphs name no key on a PC keyboard.
21
+ */
22
+
23
+ /**
24
+ * Platform override for key-hint rendering; `undefined` resolves to the host
25
+ * `process.platform`. Mirrors `setKittyProtocolActive` in the TUI keys module:
26
+ * a single seam that keeps hint output deterministic in tests without mutating
27
+ * the global `process.platform`.
28
+ */
29
+ let keyHintPlatformOverride: NodeJS.Platform | undefined;
30
+
31
+ /** Pin the platform used to render modifier labels (test seam). */
32
+ export function setKeyHintPlatform(platform: NodeJS.Platform | undefined): void {
33
+ keyHintPlatformOverride = platform;
34
+ }
35
+
36
+ /** Platform currently used for key-hint rendering. */
37
+ export function keyHintPlatform(): NodeJS.Platform {
38
+ return keyHintPlatformOverride ?? process.platform;
39
+ }
40
+
41
+ /** Modifier → [generic symbol, macOS symbol], in display order (⌃⌥⇧⌘, as macOS menus list them). */
42
+ const MODIFIER_SYMBOLS: Record<string, readonly [SymbolKey, SymbolKey] | undefined> = {
43
+ ctrl: ["key.ctrl", "key.ctrlMac"],
44
+ alt: ["key.alt", "key.altMac"],
45
+ shift: ["key.shift", "key.shift"],
46
+ super: ["key.super", "key.superMac"],
47
+ };
48
+ const KEY_SYMBOLS: Record<string, SymbolKey | undefined> = {
49
+ esc: "key.esc",
50
+ escape: "key.esc",
51
+ enter: "key.enter",
52
+ return: "key.enter",
53
+ space: "key.space",
54
+ tab: "key.tab",
55
+ backspace: "key.backspace",
56
+ delete: "key.delete",
57
+ up: "key.up",
58
+ down: "key.down",
59
+ left: "key.left",
60
+ right: "key.right",
61
+ };
62
+
63
+ /** Keys without a keycap glyph; spelled out in every preset. */
64
+ const KEY_WORDS: Record<string, string | undefined> = {
65
+ home: "Home",
66
+ end: "End",
67
+ pageup: "PgUp",
68
+ pagedown: "PgDn",
69
+ insert: "Ins",
70
+ };
71
+
72
+ function keySymbol(key: SymbolKey): string {
73
+ return activeThemeSymbol(key);
74
+ }
75
+
76
+ const HAS_LETTER = /[A-Za-z0-9]/;
77
+
78
+ /** A displayable key: a chord, or a bare modifier for "hold ⇧" style hints. */
79
+ export type KeyName = KeyId | ModifierName;
80
+
81
+ /**
82
+ * Format one key for display: `"ctrl+shift+c"` → `⌃⇧C` (macOS) / `Ctrl+⇧C`,
83
+ * `"escape"` → `⎋`, `"alt+up"` → `⌥↑` / `Alt+↑`, `"shift"` → `⇧`.
84
+ * A glyph modifier is followed by the preset's `key.joiner` (nothing in
85
+ * unicode, a space in nerd, whose icons blur together when adjacent); a word
86
+ * modifier takes a `+`. Modifiers
87
+ * render in canonical order whatever the binding says (`shift+ctrl+p` → `⌃⇧P`).
88
+ * A bare letter stays lowercase (`q`) since `Q` would read as ⇧Q; in a chord it
89
+ * is capitalized. Other keys are capitalized (`f5` → `F5`).
90
+ */
91
+ export function formatKeyHint(key: KeyName): string {
92
+ const mac = keyHintPlatform() === "darwin";
93
+ // A trailing `+` is the plus key itself (`+`, `ctrl++`), not a separator.
94
+ const parts = key.endsWith("+") ? [...key.slice(0, -1).split("+").slice(0, -1), "+"] : key.split("+");
95
+ const base = parts.pop()!;
96
+ let out = "";
97
+ for (const modifier in MODIFIER_SYMBOLS) {
98
+ if (!parts.includes(modifier)) continue;
99
+ const label = keySymbol(MODIFIER_SYMBOLS[modifier]![mac ? 1 : 0]);
100
+ out += HAS_LETTER.test(label) ? `${label}+` : label + keySymbol("key.joiner");
101
+ }
102
+ const lower = base.toLowerCase();
103
+ const modifier = MODIFIER_SYMBOLS[lower];
104
+ const symbol = KEY_SYMBOLS[lower];
105
+ if (modifier) out += keySymbol(modifier[mac ? 1 : 0]);
106
+ else if (symbol !== undefined) out += keySymbol(symbol);
107
+ else if (KEY_WORDS[lower] !== undefined) out += KEY_WORDS[lower];
108
+ else if (base.length === 1) out += parts.length === 0 ? base : base.toUpperCase();
109
+ else out += base[0]!.toUpperCase() + base.slice(1);
110
+ return out;
111
+ }
112
+
113
+ /** Format alternative keys as a slash-separated hint (`["f5", "alt+r"]` → `F5/⌥R`). */
114
+ export function formatKeyHints(keys: KeyName | readonly KeyName[]): string {
115
+ return typeof keys === "string" ? formatKeyHint(keys) : keys.map(formatKeyHint).join("/");
116
+ }
117
+
118
+ /** Format a double-tap gesture: `←←` with glyphs, `Left Left` with words. */
119
+ export function formatDoubleTap(key: KeyName): string {
120
+ const label = formatKeyHint(key);
121
+ return `${label}${HAS_LETTER.test(label) ? " " : keySymbol("key.joiner")}${label}`;
122
+ }
package/src/keys.ts CHANGED
@@ -177,7 +177,8 @@ type SpecialKey =
177
177
  | "f12";
178
178
 
179
179
  type BaseKey = Letter | Digit | KeySymbol | SpecialKey;
180
- type ModifierName = "ctrl" | "shift" | "alt" | "super";
180
+ /** Modifier key names as they appear in {@link KeyId} chords. */
181
+ export type ModifierName = "ctrl" | "shift" | "alt" | "super";
181
182
 
182
183
  type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName = ModifierName> = {
183
184
  [M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`;
@@ -31,6 +31,8 @@ import {
31
31
  } from "../index";
32
32
  import { getSelectListTheme, theme } from "../theme";
33
33
  import { sanitizeDisplayWarnings } from "../render/render-utils";
34
+ import { formatKeyHint } from "../app-keybindings";
35
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
34
36
  import { HookEditorComponent } from "./hook-editor";
35
37
  import { buildBrowserItems, ModelBrowser, type ModelBrowserSource, sortModelItems } from "./model-browser";
36
38
  import { bottomBorder, divider, dividerSplit, PanelRows, row, topBorder, topBorderSplit } from "../chrome/overlay-box";
@@ -514,7 +516,11 @@ export class AdvisorConfigOverlayComponent implements Component {
514
516
  this.#cb.notify(`Advisor config: ${err instanceof Error ? err.message : String(err)}`);
515
517
  });
516
518
  list.onCancel = () => this.#cb.close();
517
- this.#setScreen("list", list, "↑↓ move · Enter / click select · scroll preview on the right · Esc close");
519
+ this.#setScreen(
520
+ "list",
521
+ list,
522
+ `${editorKeys("tui.select.up", "tui.select.down")} move · ${editorKey("tui.select.confirm")} / click select · scroll preview on the right · ${editorKey("tui.select.cancel")} close`,
523
+ );
518
524
  }
519
525
 
520
526
  async #onListSelect(value: string): Promise<void> {
@@ -596,7 +602,11 @@ export class AdvisorConfigOverlayComponent implements Component {
596
602
  const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme());
597
603
  list.onSelect = item => this.#onDetailSelect(index, item.value);
598
604
  list.onCancel = () => this.#showList();
599
- this.#setScreen("detail", list, `Editing "${advisor.name}" · Enter / click edit field · Esc back`);
605
+ this.#setScreen(
606
+ "detail",
607
+ list,
608
+ `Editing "${advisor.name}" · ${editorKey("tui.select.confirm")} / click edit field · ${editorKey("tui.select.cancel")} back`,
609
+ );
600
610
  }
601
611
 
602
612
  #onDetailSelect(index: number, field: string): void {
@@ -647,7 +657,11 @@ export class AdvisorConfigOverlayComponent implements Component {
647
657
  this.#showDetail(index);
648
658
  };
649
659
  input.onEscape = () => this.#showDetail(index);
650
- this.#setScreen("name", input, "Type a name · Enter save · Esc cancel");
660
+ this.#setScreen(
661
+ "name",
662
+ input,
663
+ `Type a name · ${editorKey("tui.input.submit")} save · ${editorKey("tui.select.cancel")} cancel`,
664
+ );
651
665
  }
652
666
 
653
667
  #showModelPicker(index: number): void {
@@ -680,7 +694,11 @@ export class AdvisorConfigOverlayComponent implements Component {
680
694
  }
681
695
  };
682
696
  picker.onCancel = () => this.#showDetail(index);
683
- this.#setScreen("model", picker, "Type to search · Enter / click twice picks · Esc back");
697
+ this.#setScreen(
698
+ "model",
699
+ picker,
700
+ `Type to search · ${formatKeyHint("enter")} / click twice picks · ${editorKey("tui.select.cancel")} back`,
701
+ );
684
702
  }
685
703
 
686
704
  #showThinkingPicker(index: number, selector: string, efforts: readonly string[]): void {
@@ -694,7 +712,11 @@ export class AdvisorConfigOverlayComponent implements Component {
694
712
  this.#showDetail(index);
695
713
  };
696
714
  list.onCancel = () => this.#showModelPicker(index);
697
- this.#setScreen("thinking", list, `Thinking effort for ${selector} · Enter / click pick · Esc back`);
715
+ this.#setScreen(
716
+ "thinking",
717
+ list,
718
+ `Thinking effort for ${selector} · ${editorKey("tui.select.confirm")} / click pick · ${editorKey("tui.select.cancel")} back`,
719
+ );
698
720
  }
699
721
 
700
722
  #showToolsEditor(index: number, selected: Set<string>, cursor: number): void {
@@ -729,7 +751,7 @@ export class AdvisorConfigOverlayComponent implements Component {
729
751
  this.#setScreen(
730
752
  "tools",
731
753
  list,
732
- "Enter / click toggle · select Done or Esc to apply (empty = no tools; read/grep/glob = default)",
754
+ `${editorKey("tui.select.confirm")} / click toggle · select Done or ${editorKey("tui.select.cancel")} to apply (empty = no tools; read/grep/glob = default)`,
733
755
  );
734
756
  }
735
757
 
@@ -25,7 +25,7 @@ import {
25
25
  type AgentActivityRow,
26
26
  activityRowsFromProgress,
27
27
  } from "./agent-activity";
28
- import type { KeyId } from "../app-keybindings";
28
+ import { formatKeyHint, formatKeyHints, type KeyId } from "../app-keybindings";
29
29
  import type { MessageRenderer } from "../chat/extension-types";
30
30
  import type { AgentLifecycleLike, IrcBusLike } from "./agent-hub-types";
31
31
  import { type AgentRecordLike, type AgentHubRegistry, type AgentStatus, MAIN_AGENT_ID } from "./agent-hub-types";
@@ -767,7 +767,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
767
767
  row(
768
768
  theme.fg(
769
769
  "dim",
770
- "1:agents j/k:select Enter:transcript Space:follow f:filter s:scope /:search Esc:close",
770
+ `1:agents ${formatKeyHints(["j", "k"])}:select ${formatKeyHint("enter")}:transcript ${formatKeyHint("space")}:follow ${formatKeyHint("f")}:filter ${formatKeyHint("s")}:scope /:search ${formatKeyHint("escape")}:close`,
771
771
  ),
772
772
  width,
773
773
  ),
@@ -834,15 +834,18 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
834
834
  if (showingNarrowDetails) {
835
835
  return theme.fg(
836
836
  "dim",
837
- `${filter}1:agents 2:activity Tab:roster PgUp/PgDn:scroll Enter:open t:${nextView} Esc:roster`,
837
+ `${filter}1:agents 2:activity ${formatKeyHint("tab")}:roster ${formatKeyHints(["pageUp", "pageDown"])}:scroll ${formatKeyHint("enter")}:open ${formatKeyHint("t")}:${nextView} ${formatKeyHint("escape")}:roster`,
838
838
  );
839
839
  }
840
840
  if (availableWidth < 96) {
841
- return theme.fg("dim", `${filter}j/k:select Enter:open t:${nextView} Tab:details r/x:manage Esc:close`);
841
+ return theme.fg(
842
+ "dim",
843
+ `${filter}${formatKeyHints(["j", "k"])}:select ${formatKeyHint("enter")}:open ${formatKeyHint("t")}:${nextView} ${formatKeyHint("tab")}:details ${formatKeyHints(["r", "x"])}:manage ${formatKeyHint("escape")}:close`,
844
+ );
842
845
  }
843
846
  return theme.fg(
844
847
  "dim",
845
- `${filter}1:agents 2:activity j/k/wheel:select PgUp/PgDn:details Enter/click:open t:${nextView} r:revive x:kill Esc:close`,
848
+ `${filter}1:agents 2:activity ${formatKeyHints(["j", "k"])}/wheel:select ${formatKeyHints(["pageUp", "pageDown"])}:details ${formatKeyHint("enter")}/click:open ${formatKeyHint("t")}:${nextView} ${formatKeyHint("r")}:revive ${formatKeyHint("x")}:kill ${formatKeyHint("escape")}:close`,
846
849
  );
847
850
  }
848
851
 
@@ -20,7 +20,8 @@ import { Editor } from "../components/editor";
20
20
  import { matchesKey } from "../keys";
21
21
  import { routeSgrMouseInput } from "../mouse";
22
22
  import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
23
- import type { KeyId } from "../app-keybindings";
23
+ import { formatKeyHint, formatKeyHints, type KeyId } from "../app-keybindings";
24
+ import { editorKey } from "../chrome/keybinding-hints";
24
25
  import type { MessageRenderer } from "../chat/extension-types";
25
26
  import type { AgentLifecycleLike } from "./agent-hub-types";
26
27
  import type { AgentHubRegistry, AgentStatus } from "./agent-hub-types";
@@ -627,9 +628,10 @@ export class AgentTranscriptViewer implements Component {
627
628
  const lines: string[] = [];
628
629
  const statsLine = this.#statsLine();
629
630
  if (statsLine) lines.push(statsLine);
630
- const hint = this.#editor
631
- ? `Enter:send Esc:close ${this.#deps.expandKeys[0] ?? "ctrl+o"}:expand empty input → j/k:scroll g/G:top/bottom`
632
- : `Esc:close ${this.#deps.expandKeys[0] ?? "ctrl+o"}:expand j/k:scroll g/G:top/bottom`;
631
+ const keys =
632
+ `${formatKeyHint("escape")}:close ${formatKeyHint(this.#deps.expandKeys[0] ?? "ctrl+o")}:expand ` +
633
+ `${this.#editor ? "empty input → " : ""}${formatKeyHints(["j", "k"])}:scroll ${formatKeyHints(["g", "shift+g"])}:top/bottom`;
634
+ const hint = this.#editor ? `${editorKey("tui.input.submit")}:send ${keys}` : keys;
633
635
  lines.push(theme.fg("dim", hint));
634
636
  return lines;
635
637
  }
@@ -29,6 +29,8 @@ import type { AgentSource } from "../tools/task";
29
29
  import { shortenPath } from "../render/render-utils";
30
30
  import { getEditorTheme, theme } from "../theme";
31
31
  import { matchesAppFollowUp, matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
32
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
33
+ import { boundKeys, editorKey, editorKeys } from "../chrome/keybinding-hints";
32
34
  import {
33
35
  buildBrowserItems,
34
36
  ModelBrowser,
@@ -980,7 +982,10 @@ export class AgentsHubComponent implements Component {
980
982
  const { agent, property } = this.#assigning;
981
983
  const what = property === "model" ? "model override" : `${property} model`;
982
984
  return truncateToWidth(
983
- theme.fg("accent", ` Picking ${what} for ${theme.bold(agent.name)} — Enter assigns, Esc cancels`),
985
+ theme.fg(
986
+ "accent",
987
+ ` Picking ${what} for ${theme.bold(agent.name)} — ${formatKeyHint("enter")} assigns, ${editorKey("tui.select.cancel")} cancels`,
988
+ ),
984
989
  width,
985
990
  );
986
991
  }
@@ -1152,26 +1157,35 @@ export class AgentsHubComponent implements Component {
1152
1157
  }
1153
1158
 
1154
1159
  #footerHint(): string {
1160
+ const enter = formatKeyHint("enter");
1161
+ const cancel = editorKey("tui.select.cancel");
1162
+ const upDown = editorKeys("tui.select.up", "tui.select.down");
1155
1163
  if (this.#strip) {
1156
1164
  if (this.#strip.kind === "pattern") {
1157
1165
  const property = this.#strip.property;
1158
1166
  const values = property === "model" ? "a model pattern" : '"on", "off", or a model pattern';
1159
- return `Enter ${values} (role aliases like @smol and :level suffixes work; empty clears) · Esc back`;
1167
+ return `Enter ${values} (role aliases like @smol and :level suffixes work; empty clears) · ${cancel} back`;
1160
1168
  }
1161
- return this.#strip.property ? "←/→ choose · Enter apply · Esc back" : "←/→ choose · Enter open · Esc cancel";
1169
+ const choose = formatKeyHints(["left", "right"]);
1170
+ return this.#strip.property
1171
+ ? `${choose} choose · ${enter} apply · ${cancel} back`
1172
+ : `${choose} choose · ${enter} open · ${cancel} cancel`;
1162
1173
  }
1163
1174
  if (this.#assigning) {
1164
- return "Enter pick · ↑/↓ models · type to search · Esc cancel";
1175
+ return `${enter} pick · ${upDown} models · type to search · ${cancel} cancel`;
1165
1176
  }
1166
1177
  if (this.#createActive) {
1167
- if (this.#createSpec) return "Enter save · Tab scope · r regenerate · Esc cancel";
1178
+ const tab = formatKeyHint("tab");
1179
+ if (this.#createSpec)
1180
+ return `${enter} save · ${tab} scope · ${formatKeyHint("r")} regenerate · ${cancel} cancel`;
1168
1181
  if (this.#createGenerating) return "Generating…";
1169
- return "Ctrl+Q/Ctrl+Enter generate · Enter newline · Tab scope · Esc cancel";
1182
+ const generate = formatKeyHints(boundKeys("app.message.followUp", ["ctrl+q", "ctrl+enter"]));
1183
+ return `${generate} generate · ${enter} newline · ${tab} scope · ${cancel} cancel`;
1170
1184
  }
1171
1185
  if (this.#focus === "scope") {
1172
- return "↑/↓ scopes · →/Enter agents · Esc close";
1186
+ return `${upDown} scopes · ${formatKeyHints(["right", "enter"])} agents · ${cancel} close`;
1173
1187
  }
1174
- return "Enter configure · Space enable/disable · ↑/↓ rows · type to search · Ctrl+R reload · Esc close";
1188
+ return `${enter} configure · ${formatKeyHint("space")} enable/disable · ${upDown} rows · type to search · ${formatKeyHint("ctrl+r")} reload · ${cancel} close`;
1175
1189
  }
1176
1190
 
1177
1191
  #renderFooter(width: number): string {
@@ -11,7 +11,9 @@ import {
11
11
  visibleWidth,
12
12
  wrapTextWithAnsi,
13
13
  } from "../index";
14
- import { type KeybindingsManager } from "../app-keybindings";
14
+ import { appKey, editorKey } from "../chrome/keybinding-hints";
15
+ import { formatKeyHint, formatKeyHints, type KeybindingsManager } from "../app-keybindings";
16
+ import type { Keybinding } from "../keybindings";
15
17
  import { sanitizeText } from "@oh-my-pi/pi-utils";
16
18
  import { type Theme } from "../theme/theme";
17
19
  import type {
@@ -157,7 +159,6 @@ export class AnnotationOverlay implements Component {
157
159
  #textViewportDriven = false;
158
160
  #textRenderedRowBySource: readonly number[] = [];
159
161
  #staticRenderedDiffBodies = new WeakMap<ReviewDiffFile, RenderedDiffBody>();
160
- #externalEditorLabel: string;
161
162
 
162
163
  readonly #tui: TUI;
163
164
  readonly #theme: Theme;
@@ -253,7 +254,6 @@ export class AnnotationOverlay implements Component {
253
254
  this.#editor.cursorOverride = " ";
254
255
  this.#editor.setScrollbarVisible(true);
255
256
  this.#editor.onSubmit = value => this.#commitAnnotation(value);
256
- this.#externalEditorLabel = keybindings.getDisplayString("app.editor.external");
257
257
  this.#resetSourceCursor();
258
258
  }
259
259
 
@@ -328,6 +328,12 @@ export class AnnotationOverlay implements Component {
328
328
  else this.#handleActions(data);
329
329
  }
330
330
 
331
+ /** Primary key bound to `action` in this overlay's keybindings, formatted for footer hints. */
332
+ #key(action: Keybinding): string {
333
+ const [key] = this.#keybindings.getKeys(action);
334
+ return key ? formatKeyHint(key) : "";
335
+ }
336
+
331
337
  #finish(result: CodeReviewOverlayResult | TextReviewOverlayResult | undefined): void {
332
338
  if (this.#finished) return;
333
339
  this.#finished = true;
@@ -962,14 +968,11 @@ export class AnnotationOverlay implements Component {
962
968
  const options = chooser.entries
963
969
  .slice(start, start + optionLimit)
964
970
  .map((index, windowIndex) => this.#renderAnnotationChooserEntry(chooser, index, start + windowIndex, width));
971
+ const keysHint = `${this.#key("tui.select.up")}/${this.#key("tui.select.down")} choose · ${this.#key("tui.select.confirm")} edit · ${this.#key("tui.select.cancel")} cancel`;
965
972
  const lines =
966
973
  availableRows === 2
967
- ? [this.#theme.fg("dim", "Edit annotation · ↑↓ choose · enter edit · esc cancel"), ...options.slice(0, 1)]
968
- : [
969
- this.#theme.fg("dim", "Edit annotation · ↑↓ choose · enter edit · esc cancel"),
970
- ...options,
971
- this.#theme.fg("dim", "↑↓ choose · enter edit · esc cancel"),
972
- ];
974
+ ? [this.#theme.fg("dim", `Edit annotation · ${keysHint}`), ...options.slice(0, 1)]
975
+ : [this.#theme.fg("dim", `Edit annotation · ${keysHint}`), ...options, this.#theme.fg("dim", keysHint)];
973
976
  return Number.isFinite(availableRows) ? lines.slice(0, availableRows) : lines;
974
977
  }
975
978
 
@@ -1034,20 +1037,31 @@ export class AnnotationOverlay implements Component {
1034
1037
  width,
1035
1038
  Ellipsis.Unicode,
1036
1039
  );
1037
- const hints = ["enter save", "shift+enter newline", "esc cancel"];
1038
- if (this.#externalEditorLabel) hints.push(`${this.#externalEditorLabel} editor`);
1040
+ const hints = [
1041
+ `${editorKey("tui.input.submit")} save`,
1042
+ `${editorKey("tui.input.newLine")} newline`,
1043
+ `${this.#key("tui.select.cancel")} cancel`,
1044
+ ];
1045
+ const externalEditorKey = appKey(this.#keybindings, "app.editor.external");
1046
+ if (externalEditorKey) hints.push(`${externalEditorKey} editor`);
1039
1047
  this.#editor.focused = true;
1040
1048
  return [caption, ...this.#editor.render(width), this.#theme.fg("dim", hints.join(" · "))];
1041
1049
  }
1050
+ const upDown = `${this.#key("tui.select.up")}/${this.#key("tui.select.down")}`;
1051
+ const confirm = this.#key("tui.select.confirm");
1052
+ const editNote = `${formatKeyHint("e")} edit note`;
1042
1053
  const focusHelp =
1043
1054
  this.#focus === "files"
1044
- ? "↑↓ file · ⏎ diff · a/A file note · e edit note"
1055
+ ? `${upDown} file · ${confirm} diff · ${formatKeyHints(["a", "shift+a"])} file note · ${editNote}`
1045
1056
  : this.#focus === "diff"
1046
- ? this.#textSource
1047
- ? "↑↓ line · ⇧ faster · pgup/pgdn · g/G ends · a line note · A text note · e edit note"
1048
- : "↑↓ line · ⇧ faster · pgup/pgdn · g/G ends · a line note · A file note · e edit note"
1049
- : "↑↓ select · ⏎ confirm";
1050
- return [this.#theme.fg("dim", `${focusHelp} · u undo · tab regions · esc cancel`)];
1057
+ ? `${upDown} line · ${formatKeyHint("shift")} faster · ${this.#key("tui.select.pageUp")}/${this.#key("tui.select.pageDown")} · ${formatKeyHints(["g", "shift+g"])} ends · ${formatKeyHint("a")} line note · ${formatKeyHint("shift+a")} ${this.#textSource ? "text" : "file"} note · ${editNote}`
1058
+ : `${upDown} select · ${confirm} confirm`;
1059
+ return [
1060
+ this.#theme.fg(
1061
+ "dim",
1062
+ `${focusHelp} · ${formatKeyHint("u")} undo · ${formatKeyHint("tab")} regions · ${this.#key("tui.select.cancel")} cancel`,
1063
+ ),
1064
+ ];
1051
1065
  }
1052
1066
 
1053
1067
  #ensureCursorVisible(renderedRowBySource: readonly number[]): void {
@@ -71,7 +71,8 @@ import {
71
71
  } from "../keybinding-matchers";
72
72
  import { optionMarker } from "../tools/ask";
73
73
  import { CountdownTimer } from "../chrome/countdown-timer";
74
- import { editorKey } from "../chrome/keybinding-hints";
74
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
75
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
75
76
  import { OverlayPanel, PanelDivider, PanelRows } from "../chrome/overlay-box";
76
77
  import { handleTabSwitchKey } from "../chrome/selector-helpers";
77
78
 
@@ -309,17 +310,6 @@ function renderCachedPreview(cache: PreviewRenderCache, preview: string, width:
309
310
  return rendered;
310
311
  }
311
312
 
312
- function pageKeysLabel(): string {
313
- const pageUp = editorKey("tui.select.pageUp");
314
- const pageDown = editorKey("tui.select.pageDown");
315
- return `${pageUp === "pageup" ? "PgUp" : pageUp}/${pageDown === "pagedown" ? "PgDn" : pageDown}`;
316
- }
317
-
318
- function cancelKeyLabel(): string {
319
- const [key = ""] = editorKey("tui.select.cancel").split("/");
320
- return key === "escape" ? "Esc" : key;
321
- }
322
-
323
313
  function normalizedInlineInput(input: string): string {
324
314
  return replaceTabs(input).replace(/\s+/g, " ").trim();
325
315
  }
@@ -767,24 +757,29 @@ export class AskDialogComponent implements Component {
767
757
  }
768
758
 
769
759
  #footerHintText(indicator: string): string {
770
- const cancel = `${cancelKeyLabel()} cancel`;
760
+ const cancel = `${editorKey("tui.select.cancel")} cancel`;
761
+ const enter = formatKeyHint("enter");
762
+ const upDown = editorKeys("tui.select.up", "tui.select.down");
771
763
  const inputGuard = this.options.inputGuard;
772
764
  if (inputGuard?.isBlocked()) return `${inputGuard.hint}${this.#expandHint()} · ${cancel}`;
773
765
  if (this.#isSubmitTab()) {
774
766
  const scroll = indicator ? ` ${indicator} scroll ·` : "";
775
- return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`;
767
+ return `${enter} submit · ${upDown} scroll ·${scroll} ${cancel}`;
776
768
  }
777
769
  const question = this.#questions[this.#currentQuestionIndex()];
778
770
  // Enter advances in multi-question dialogs and submits single-question ones.
779
771
  const enterAction = this.#questions.length > 1 ? "next" : "submit";
780
- const action = question?.multi ? `Space toggle · Enter ${enterAction}` : "Enter select · n note";
781
- const tabs = this.#hasSubmitTab() ? " · Tab/←/→" : "";
772
+ const action = question?.multi
773
+ ? `${formatKeyHint("space")} toggle · ${enter} ${enterAction}`
774
+ : `${enter} select · ${formatKeyHint("n")} note`;
775
+ const tabs = this.#hasSubmitTab() ? ` · ${formatKeyHints(["tab", "left", "right"])}` : "";
782
776
  const expand = this.#expandHint();
783
777
  if (this.#questionCanPage && indicator) {
784
- return `${action} · ↑/↓${tabs} · ${cancel}${expand} · ${pageKeysLabel()} ${indicator}`;
778
+ const pageKeys = editorKeys("tui.select.pageUp", "tui.select.pageDown");
779
+ return `${action} · ${upDown}${tabs} · ${cancel}${expand} · ${pageKeys} ${indicator}`;
785
780
  }
786
781
  const scroll = indicator ? ` ${indicator} scroll ·` : "";
787
- return `${action} · ↑/↓ move${tabs} ·${scroll} ${cancel}${expand}`;
782
+ return `${action} · ${upDown} move${tabs} ·${scroll} ${cancel}${expand}`;
788
783
  }
789
784
 
790
785
  #questionRows(question: ExtensionAskDialogQuestion): QuestionRow[] {
@@ -1051,7 +1046,7 @@ export class AskDialogComponent implements Component {
1051
1046
  allLines.push(
1052
1047
  theme.fg(
1053
1048
  "warning",
1054
- `${unanswered} unanswered question${unanswered === 1 ? "" : "s"}; Enter still submits.`,
1049
+ `${unanswered} unanswered question${unanswered === 1 ? "" : "s"}; ${formatKeyHint("enter")} still submits.`,
1055
1050
  ),
1056
1051
  );
1057
1052
  allLines.push("");
@@ -1,6 +1,7 @@
1
1
  import { CancellableLoader, Container, Spacer, Text, type TUI } from "../index";
2
2
  import type { Theme } from "../theme/theme";
3
3
  import { DynamicBorder } from "../chrome/dynamic-border";
4
+ import { editorKey } from "../chrome/keybinding-hints";
4
5
 
5
6
  /** Loader wrapped with borders for hook UI */
6
7
  export class BorderedLoader extends Container {
@@ -18,7 +19,7 @@ export class BorderedLoader extends Container {
18
19
  );
19
20
  this.addChild(this.#loader);
20
21
  this.addChild(new Spacer(1));
21
- this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0));
22
+ this.addChild(new Text(theme.fg("muted", `${editorKey("tui.select.cancel")} cancel`), 1, 0));
22
23
  this.addChild(new Spacer(1));
23
24
  this.addChild(new DynamicBorder(borderColor));
24
25
  }
@@ -28,7 +28,8 @@ import {
28
28
  import type { SpaceHoldHandler } from "../space-hold";
29
29
  import { sanitizeErrorLine } from "../chrome/error-block";
30
30
  import { sanitizeDisplayLine, sanitizeDisplayText } from "./extensions/display-text";
31
- import { editorKey, rawKeyHint } from "../chrome/keybinding-hints";
31
+ import { editorKey, editorKeys, keyHint, rawKeyHint } from "../chrome/keybinding-hints";
32
+ import { formatKeyHint } from "../app-keybindings";
32
33
  import { bottomBorder, row, topBorder } from "../chrome/overlay-box";
33
34
  import { padToWidth } from "../render/utils";
34
35
  import { SplitPane } from "../components/layout/split-pane";
@@ -267,11 +268,11 @@ export class BtwHistoryPanel implements Component, Focusable {
267
268
  this.#focus = "answer";
268
269
  this.#followLatest = true;
269
270
  } else {
270
- composer.notice = "Follow-up was not started. Your draft is kept; Enter to retry.";
271
+ composer.notice = `Follow-up was not started. Your draft is kept; ${editorKey("tui.input.submit")} to retry.`;
271
272
  }
272
273
  } catch {
273
274
  if (this.#composer === composer) {
274
- composer.notice = "Could not start the follow-up. Your draft is kept; Enter to retry.";
275
+ composer.notice = `Could not start the follow-up. Your draft is kept; ${editorKey("tui.input.submit")} to retry.`;
275
276
  }
276
277
  } finally {
277
278
  this.#followUpPending = false;
@@ -507,15 +508,19 @@ export class BtwHistoryPanel implements Component, Focusable {
507
508
  const composer = this.#composer;
508
509
  const latest = record ? getBtwLatestTurn(record) : undefined;
509
510
  const actions = composer
510
- ? [rawKeyHint("Enter", this.#followUpPending ? "starting…" : "send"), rawKeyHint("Esc", "cancel")]
511
+ ? [
512
+ keyHint("tui.input.submit", this.#followUpPending ? "starting…" : "send"),
513
+ keyHint("tui.select.cancel", "cancel"),
514
+ ]
511
515
  : [
512
- rawKeyHint("Esc", latest?.status === "running" ? "cancel" : "close"),
513
- rawKeyHint("Tab/Ctrl+/", "switch pane"),
516
+ keyHint("tui.select.cancel", latest?.status === "running" ? "cancel" : "close"),
517
+ rawKeyHint(["tab", "ctrl+/"], "switch pane"),
514
518
  ];
515
519
  if (!composer) {
516
- if (record && this.#canFollowUp(record)) actions.push(rawKeyHint("f/Enter", "follow up"));
520
+ if (record && this.#canFollowUp(record)) actions.push(rawKeyHint(["f", "enter"], "follow up"));
517
521
  if (record && getBtwCopyText(record) !== undefined) {
518
- if (this.#isCopied(record)) actions.push(theme.fg("success", "✓ copied · c to copy again"));
522
+ if (this.#isCopied(record))
523
+ actions.push(theme.fg("success", `✓ copied · ${formatKeyHint("c")} to copy again`));
519
524
  else actions.push(rawKeyHint("c", inner < 40 ? "copy" : "copy answer"));
520
525
  }
521
526
  }
@@ -577,10 +582,8 @@ export class BtwHistoryPanel implements Component, Focusable {
577
582
  if (showNavigation) {
578
583
  lines.push(
579
584
  row(
580
- rawKeyHint(
581
- `${editorKey("tui.select.up")}/${editorKey("tui.select.down")}`,
582
- this.#focus === "list" ? "select" : "scroll",
583
- ),
585
+ theme.fg("dim", editorKeys("tui.select.up", "tui.select.down")) +
586
+ theme.fg("muted", ` ${this.#focus === "list" ? "select" : "scroll"}`),
584
587
  width,
585
588
  ),
586
589
  );
@@ -4,6 +4,8 @@ import { getMarkdownTheme, theme } from "../theme/theme";
4
4
  import { sanitizeErrorLine } from "../chrome/error-block";
5
5
  import { OverlayPanel } from "../chrome/overlay-box";
6
6
  import { StreamingPanelContent } from "../chrome/streaming-panel";
7
+ import { interruptKey } from "../chrome/keybinding-hints";
8
+ import { formatKeyHint } from "../app-keybindings";
7
9
 
8
10
  type BtwPanelState = "running" | "complete" | "branching" | "aborted" | "error";
9
11
 
@@ -129,15 +131,18 @@ export class BtwPanelComponent extends OverlayPanel {
129
131
  }
130
132
 
131
133
  #footerLine(): string {
134
+ // The main editor routes `app.interrupt` (Escape by default) to the panel.
135
+ const esc = interruptKey();
132
136
  switch (this.#state) {
133
137
  case "running":
134
- return theme.fg("muted", "Esc to cancel");
138
+ return theme.fg("muted", `${esc} to cancel`);
135
139
  case "complete": {
136
140
  const actions: string[] = [];
137
- if (this.isCopyable()) actions.push(this.#copied ? "c to copy again" : "c to copy");
138
- if (this.#canFollowUp?.()) actions.push("f to follow up");
139
- if (this.#canBranch?.() ?? this.isBranchable()) actions.push("b to branch");
140
- actions.push("Esc to close");
141
+ const copyKey = formatKeyHint("c");
142
+ if (this.isCopyable()) actions.push(this.#copied ? `${copyKey} to copy again` : `${copyKey} to copy`);
143
+ if (this.#canFollowUp?.()) actions.push(`${formatKeyHint("f")} to follow up`);
144
+ if (this.#canBranch?.() ?? this.isBranchable()) actions.push(`${formatKeyHint("b")} to branch`);
145
+ actions.push(`${esc} to close`);
141
146
  if (this.#copied) {
142
147
  return `${theme.fg("success", "✓ Copied to clipboard")}${theme.fg("muted", actions.length > 0 ? ` · ${actions.join(" · ")}` : "")}`;
143
148
  }
@@ -146,9 +151,9 @@ export class BtwPanelComponent extends OverlayPanel {
146
151
  case "branching":
147
152
  return theme.fg("muted", `${theme.status.pending} Branching to chat…`);
148
153
  case "aborted":
149
- return theme.fg("warning", `${theme.status.warning} Cancelled · Esc to close`);
154
+ return theme.fg("warning", `${theme.status.warning} Cancelled · ${esc} to close`);
150
155
  case "error":
151
- return theme.fg("error", `${theme.status.error} Error · Esc to close`);
156
+ return theme.fg("error", `${theme.status.error} Error · ${esc} to close`);
152
157
  }
153
158
  }
154
159