@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,205 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+ import type { EditorTextAssistProvider } from "../components/editor";
3
+ import { maskNonProse } from "./markdown-prose";
4
+ import { isProseWord, lineContext, ProseSource } from "./prose-gate";
5
+
6
+ /** Values of the `spelling.autocomplete` setting. */
7
+ export const WORD_COMPLETION_METHODS = ["off", "auto", "ngram", "smollm", "apple"] as const;
8
+
9
+ /** Configured word-completion engine; `off` disables ghost text. */
10
+ export type WordCompletionMethod = (typeof WORD_COMPLETION_METHODS)[number];
11
+
12
+ /** Whether `value` is a {@link WordCompletionMethod}. */
13
+ export function isWordCompletionMethod(value: unknown): value is WordCompletionMethod {
14
+ return WORD_COMPLETION_METHODS.some(method => method === value);
15
+ }
16
+
17
+ /** An enabled word-completion engine. */
18
+ export type WordCompletionEngine = Exclude<WordCompletionMethod, "off">;
19
+
20
+ /** Asynchronous engine behind ghost-text word completion (coding-agent: the text-prediction daemon). */
21
+ export interface WordPredictionBackend {
22
+ /** Characters to paint after `prefix` typed after `before`, or `null` to show nothing. */
23
+ complete(before: string, prefix: string): Promise<string | null>;
24
+ /** Report a shown suggestion the user accepted (Tab/→) or typed past. Fire-and-forget. */
25
+ feedback(before: string, prefix: string, suggestion: string, accepted: boolean): void;
26
+ }
27
+
28
+ /** Resolves the backend for an engine; `undefined` means none is available (no ghost text). */
29
+ export type WordPredictionBackendResolver = (method: WordCompletionEngine) => WordPredictionBackend | undefined;
30
+
31
+ let hostResolver: WordPredictionBackendResolver | undefined;
32
+
33
+ /**
34
+ * Install the host's backend resolver for every editor, including ones built
35
+ * before the host finished starting. Pass `undefined` to detach.
36
+ */
37
+ export function setWordPredictionHost(resolver: WordPredictionBackendResolver | undefined): void {
38
+ hostResolver = resolver;
39
+ }
40
+
41
+ const WORD_SUFFIX = /[\p{L}\p{M}']+$/u;
42
+ const WORD_CONTINUES = /^[\p{L}\p{M}']/u;
43
+ const CACHE_LIMIT = 256;
44
+ /** Context sent with each query: the editor text before the word, capped to a recent tail. */
45
+ const BEFORE_LIMIT = 2_000;
46
+
47
+ interface WordQuery {
48
+ key: string;
49
+ before: string;
50
+ prefix: string;
51
+ }
52
+
53
+ /** Editor text before the word starting at `start`, truncated to its last {@link BEFORE_LIMIT} code units. */
54
+ function textBefore(lines: readonly string[], cursorLine: number, start: number): string {
55
+ let text = (lines[cursorLine] ?? "").slice(0, start);
56
+ for (let line = cursorLine - 1; line >= 0 && text.length < BEFORE_LIMIT; line--) {
57
+ text = `${lines[line] ?? ""}\n${text}`;
58
+ }
59
+ if (text.length <= BEFORE_LIMIT) return text;
60
+ let cut = text.length - BEFORE_LIMIT;
61
+ // Never start on the low half of a surrogate pair.
62
+ const unit = text.charCodeAt(cut);
63
+ if (unit >= 0xdc00 && unit <= 0xdfff) cut++;
64
+ return text.slice(cut);
65
+ }
66
+
67
+ /**
68
+ * Ghost-text word completion for the prose word ending at the cursor, served
69
+ * by an injected asynchronous {@link WordPredictionBackend}.
70
+ *
71
+ * Rendering is synchronous, so a cache miss schedules a single-flight backend
72
+ * request (one active, the newest queued) and repaints through `onUpdate` when
73
+ * the answer changes what is shown. The previous suggestion is projected: if
74
+ * its full word still extends what was typed, the remainder shows immediately
75
+ * and stays until the engine offers a different word.
76
+ */
77
+ export class WordCompletionProvider implements EditorTextAssistProvider {
78
+ #method: WordCompletionMethod = "off";
79
+ #generation = 0;
80
+ #prose = new ProseSource();
81
+ #cache = new Map<string, string | null>();
82
+ #activeKey: string | undefined;
83
+ #queued: WordQuery | undefined;
84
+ /** Last non-empty suggestion shown, for projection while typing through it. */
85
+ #lastShown: { before: string; word: string } | undefined;
86
+ /** What the last `getWordCompletion` returned, so a fetch repaints only when it changes that. */
87
+ #displayed: { key: string; suffix: string | null } | undefined;
88
+
89
+ /** Invoked when an asynchronous answer changes the rendered ghost text. */
90
+ onUpdate: (() => void) | undefined;
91
+
92
+ readonly #resolveBackend: WordPredictionBackendResolver;
93
+
94
+ /** `resolveBackend` defaults to the host installed with {@link setWordPredictionHost}. */
95
+ constructor(resolveBackend: WordPredictionBackendResolver = method => hostResolver?.(method)) {
96
+ this.#resolveBackend = resolveBackend;
97
+ }
98
+
99
+ /** Select the engine (`off` disables completion) and drop answers from the previous one. */
100
+ setMethod(method: WordCompletionMethod): void {
101
+ if (this.#method === method) return;
102
+ this.#method = method;
103
+ this.#generation++;
104
+ this.#cache.clear();
105
+ this.#queued = undefined;
106
+ this.#lastShown = undefined;
107
+ this.#displayed = undefined;
108
+ }
109
+
110
+ /** Return the ghost-text suffix for the prose word ending at the cursor, or `null`. */
111
+ getWordCompletion(lines: string[], cursorLine: number, cursorCol: number): string | null {
112
+ const query = this.#query(lines, cursorLine, cursorCol);
113
+ const backend = query && this.#backend();
114
+ if (!query || !backend) {
115
+ this.#displayed = undefined;
116
+ return null;
117
+ }
118
+ if (!this.#cache.has(query.key)) this.#schedule(backend, query);
119
+ // Typing through a shown ghost without Tab can answer null (the word fell
120
+ // under the show threshold, or SmolLM excludes words typed past); keep that
121
+ // ghost until the engine offers something else.
122
+ const suffix = this.#cache.get(query.key) ?? this.#project(query);
123
+ this.#displayed = { key: query.key, suffix };
124
+ if (suffix) this.#lastShown = { before: query.before, word: query.prefix + suffix };
125
+ return suffix;
126
+ }
127
+
128
+ /** Forward accept (Tab/→) or typed-past feedback for the suggestion shown at the cursor. */
129
+ wordCompletionFeedback(
130
+ lines: string[],
131
+ cursorLine: number,
132
+ cursorCol: number,
133
+ suggestion: string,
134
+ accepted: boolean,
135
+ ): void {
136
+ const query = this.#query(lines, cursorLine, cursorCol);
137
+ if (!query) return;
138
+ this.#backend()?.feedback(query.before, query.prefix, suggestion, accepted);
139
+ if (!accepted) this.#lastShown = undefined;
140
+ }
141
+
142
+ #backend(): WordPredictionBackend | undefined {
143
+ return this.#method === "off" ? undefined : this.#resolveBackend(this.#method);
144
+ }
145
+
146
+ #query(lines: readonly string[], cursorLine: number, cursorCol: number): WordQuery | undefined {
147
+ if (this.#method === "off") return undefined;
148
+ const line = lines[cursorLine] ?? "";
149
+ if (WORD_CONTINUES.test(line.slice(cursorCol))) return undefined;
150
+ // Single letters are asked too (`figure o|ut`); engines own their minimum prefix.
151
+ const match = WORD_SUFFIX.exec(line.slice(0, cursorCol));
152
+ if (!match) return undefined;
153
+ const start = cursorCol - match[0].length;
154
+ if (!isProseWord(line, maskNonProse(line), start, cursorCol)) return undefined;
155
+ if (!this.#prose.isProse(lineContext(lines, cursorLine), start, cursorCol)) return undefined;
156
+ const prefix = match[0];
157
+ const before = textBefore(lines, cursorLine, start);
158
+ // The method keeps a request still in flight for the previous engine from
159
+ // shadowing the same word state on the new one.
160
+ return { key: `${this.#method}\u0000${prefix}\u0000${before}`, before, prefix };
161
+ }
162
+
163
+ #project(query: WordQuery): string | null {
164
+ const shown = this.#lastShown;
165
+ if (!shown || shown.before !== query.before || shown.word.length <= query.prefix.length) return null;
166
+ if (!shown.word.toLocaleLowerCase().startsWith(query.prefix.toLocaleLowerCase())) return null;
167
+ return shown.word.slice(query.prefix.length);
168
+ }
169
+
170
+ #schedule(backend: WordPredictionBackend, query: WordQuery): void {
171
+ if (this.#activeKey === query.key || this.#queued?.key === query.key) return;
172
+ if (this.#activeKey !== undefined) {
173
+ this.#queued = query;
174
+ return;
175
+ }
176
+ void this.#run(backend, query);
177
+ }
178
+
179
+ /** Answer `query`, start the queued request, then repaint if the answer changes what is shown. */
180
+ async #run(backend: WordPredictionBackend, query: WordQuery): Promise<void> {
181
+ this.#activeKey = query.key;
182
+ const generation = this.#generation;
183
+ let suffix: string | null;
184
+ try {
185
+ suffix = (await backend.complete(query.before, query.prefix)) || null;
186
+ } catch (error) {
187
+ logger.debug("word completion request failed", { error: String(error) });
188
+ suffix = null;
189
+ }
190
+ if (this.#activeKey === query.key) this.#activeKey = undefined;
191
+ const current = generation === this.#generation;
192
+ if (current) {
193
+ if (this.#cache.size >= CACHE_LIMIT) this.#cache.clear();
194
+ this.#cache.set(query.key, suffix);
195
+ }
196
+ const queued = this.#queued;
197
+ this.#queued = undefined;
198
+ const next = queued && !this.#cache.has(queued.key) ? this.#backend() : undefined;
199
+ if (queued && next) void this.#run(next, queued);
200
+ const displayed = this.#displayed;
201
+ if (current && displayed?.key === query.key && displayed.suffix !== (suffix ?? this.#project(query))) {
202
+ this.onUpdate?.();
203
+ }
204
+ }
205
+ }
@@ -10,7 +10,7 @@ import * as path from "node:path";
10
10
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
11
11
  import type { Ellipsis } from "@oh-my-pi/pi-natives";
12
12
  import { pluralize, sanitizeText } from "@oh-my-pi/pi-utils";
13
- import { formatKeyHints, type KeyId } from "../app-keybindings";
13
+ import { formatKeyHint, type KeyId } from "../app-keybindings";
14
14
  import { getKeybindings } from "../keybindings";
15
15
  import type { Theme } from "../theme/theme";
16
16
  import type { Component } from "../tui";
@@ -201,10 +201,10 @@ const EXPAND_ACTION = "app.tools.expand";
201
201
  /** Fallback key when no binding is resolvable (e.g. outside an interactive session). */
202
202
  const DEFAULT_EXPAND_KEY: KeyId = "ctrl+o";
203
203
 
204
- /** Human-readable key currently bound to tool-output expansion, e.g. `Ctrl+O`. */
204
+ /** Human-readable primary key bound to tool-output expansion, e.g. `Ctrl+O`. */
205
205
  export function expandKeyHint(): string {
206
- const keys = getKeybindings().getKeys(EXPAND_ACTION);
207
- return formatKeyHints(keys.length > 0 ? keys : [DEFAULT_EXPAND_KEY]);
206
+ const [key = DEFAULT_EXPAND_KEY] = getKeybindings().getKeys(EXPAND_ACTION);
207
+ return formatKeyHint(key);
208
208
  }
209
209
 
210
210
  // =============================================================================
@@ -6,6 +6,7 @@ import { WizardStep } from "../../components/wizard-step";
6
6
  import type { ComposerShape } from "../../overlays/composer-shape-registry";
7
7
  import { renderComposerShapePreview } from "../../overlays/composer-shape-preview";
8
8
  import { getComposerShapeOptions } from "../../overlays/composer-shape-registry";
9
+ import { editorKey } from "../../chrome/keybinding-hints";
9
10
  import { getSelectListTheme, theme } from "../../theme/theme";
10
11
  import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
11
12
 
@@ -73,7 +74,10 @@ class ComposerSceneController implements SetupSceneController {
73
74
 
74
75
  render(width: number, maxLines?: number): readonly string[] {
75
76
  const intro = new Text(
76
- theme.fg("muted", "Select a layout; live preview updates below. Press Enter to confirm."),
77
+ theme.fg(
78
+ "muted",
79
+ `Select a layout; live preview updates below. Press ${editorKey("tui.select.confirm")} to confirm.`,
80
+ ),
77
81
  0,
78
82
  0,
79
83
  );
@@ -4,6 +4,7 @@ import { Text } from "../../components/text";
4
4
  import { WizardStep } from "../../components/wizard-step";
5
5
  import { buildBrowserItems, ModelBrowser, resolveRoleAssignments, sortModelItems } from "../../overlays/model-browser";
6
6
  import { BROWSER_FRAME_ROWS } from "../../overlays/model-picker";
7
+ import { formatKeyHint } from "../../app-keybindings";
7
8
  import { theme } from "../../theme/theme";
8
9
  import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
9
10
 
@@ -58,7 +59,8 @@ class ModelSceneController implements SetupSceneController {
58
59
 
59
60
  render(width: number, maxLines?: number): readonly string[] {
60
61
  const intro = new Text(
61
- this.#status ?? theme.fg("muted", "Type to search. Enter saves the highlighted model as your default."),
62
+ this.#status ??
63
+ theme.fg("muted", `Type to search. ${formatKeyHint("enter")} saves the highlighted model as your default.`),
62
64
  0,
63
65
  0,
64
66
  );
@@ -1,5 +1,6 @@
1
1
  import { type SgrMouseEvent } from "../../mouse";
2
2
  import { TabBar } from "../../components/tab-bar";
3
+ import { editorKey } from "../../chrome/keybinding-hints";
3
4
  import { getTabBarTheme } from "../../chrome/shared";
4
5
  import { SignInTab } from "./sign-in";
5
6
  import type { SetupScene, SetupSceneController, SetupSceneHost, SetupTab } from "./types";
@@ -13,7 +14,9 @@ import { WebSearchTab } from "./web-search";
13
14
  */
14
15
  class ProvidersSceneController implements SetupSceneController {
15
16
  title = "Set up your providers";
16
- subtitle = "Sign in and pick a web search provider. Press Esc when you're done.";
17
+ get subtitle(): string {
18
+ return `Sign in and pick a web search provider. Press ${editorKey("tui.select.cancel")} when you're done.`;
19
+ }
17
20
 
18
21
  #tabs: SetupTab[];
19
22
  #tabBar: TabBar;
@@ -6,6 +6,8 @@ import { Spacer } from "../../components/spacer";
6
6
  import { Text } from "../../components/text";
7
7
  import { WizardStep } from "../../components/wizard-step";
8
8
  import { Input } from "../../components/input";
9
+ import { formatKeyHint } from "../../app-keybindings";
10
+ import { editorKey } from "../../chrome/keybinding-hints";
9
11
  import { matchesKey } from "../../keys";
10
12
  import { type SgrMouseEvent } from "../../mouse";
11
13
  import { wrapTextWithAnsi } from "../../utils";
@@ -19,7 +21,7 @@ function loginUrlLink(url: string): string {
19
21
  }
20
22
 
21
23
  function loginCopyHint(): string {
22
- return theme.fg("dim", "(clipboard copy attempted; Alt+C retries)");
24
+ return theme.fg("dim", `(clipboard copy attempted; ${formatKeyHint("alt+c")} retries)`);
23
25
  }
24
26
 
25
27
  class CopyablePromptInput implements Component, Focusable {
@@ -290,7 +292,7 @@ export class SignInTab implements SetupTab {
290
292
  const message = error instanceof Error ? error.message : String(error);
291
293
  this.#statusLines = [
292
294
  theme.fg("error", `Login failed: ${message}`),
293
- theme.fg("dim", "Choose another provider or press Esc to continue."),
295
+ theme.fg("dim", `Choose another provider or press ${editorKey("tui.select.cancel")} to continue.`),
294
296
  ];
295
297
  this.#authUrl = undefined;
296
298
  this.#authLaunchUrl = undefined;
@@ -2,6 +2,7 @@ import { centerLine, visibleWidth } from "../../utils";
2
2
  import { padToWidth } from "../../render/utils";
3
3
  import { gradientEscape, gradientLogo, PI_LOGO, type ShineConfig } from "../../prompt/welcome";
4
4
  import { theme } from "../../theme/theme";
5
+ import { formatKeyHint } from "../../app-keybindings";
5
6
 
6
7
  export const SETUP_SPLASH_MS = 2600;
7
8
  export const SETUP_TICK_MS = 33;
@@ -22,7 +23,10 @@ const RESET = "\x1b[0m";
22
23
  const MIN_SCENE_WIDTH = 56;
23
24
  const MIN_SCENE_HEIGHT = 22;
24
25
 
25
- const SKIP_HINT = "press enter to skip";
26
+ /** Skip affordance; built at render time so it follows the live symbol preset. */
27
+ function skipHint(): string {
28
+ return `press ${formatKeyHint("enter")} to skip`;
29
+ }
26
30
 
27
31
  /** Density ramp for the rippling water, lightest → heaviest. */
28
32
  const WATER_RAMP = [
@@ -166,12 +170,13 @@ export function renderSetupSplash(width: number, height: number, elapsedMs: numb
166
170
  }
167
171
  });
168
172
  // 4. skip hint on a cleared strip at the bottom so it stays legible over the water
169
- const hintWidth = visibleWidth(SKIP_HINT);
173
+ const hint = skipHint();
174
+ const hintWidth = visibleWidth(hint);
170
175
  const hintStart = Math.floor((w - hintWidth) / 2);
171
176
  const hintRow = h - 1;
172
177
  for (let x = hintStart - 1; x <= hintStart + hintWidth; x++) put(x, hintRow, " ");
173
178
  let col = hintStart;
174
- for (const ch of SKIP_HINT) put(col++, hintRow, ch === " " ? " " : theme.fg("dim", ch));
179
+ for (const ch of hint) put(col++, hintRow, ch === " " ? " " : theme.fg("dim", ch));
175
180
 
176
181
  return cells.map(row => row.join(""));
177
182
  }
@@ -187,6 +192,6 @@ function renderCompactSplash(width: number, height: number, phase: number, shine
187
192
  lines.push(width > 0 ? padToWidth(item !== undefined ? centerLine(item, width) : "", width) : "");
188
193
  }
189
194
  if (height > 2)
190
- lines[height - 2] = width > 0 ? padToWidth(centerLine(theme.fg("dim", SKIP_HINT), width), width) : "";
195
+ lines[height - 2] = width > 0 ? padToWidth(centerLine(theme.fg("dim", skipHint()), width), width) : "";
191
196
  return lines;
192
197
  }
@@ -5,6 +5,7 @@ import { type SelectItem, SelectList } from "../../components/select-list";
5
5
  import { Text } from "../../components/text";
6
6
  import { WizardStep } from "../../components/wizard-step";
7
7
  import { Container } from "../../tui";
8
+ import { editorKey } from "../../chrome/keybinding-hints";
8
9
  import {
9
10
  enableAutoTheme,
10
11
  getAvailableThemes,
@@ -59,7 +60,10 @@ function renderMockEditor(width: number): string[] {
59
60
  const top = theme.fg("borderAccent", `${box.topLeft}${horizontal}${box.topRight}`);
60
61
  const bottom = theme.fg("borderMuted", `${box.bottomLeft}${horizontal}${box.bottomRight}`);
61
62
  const prompt = `${theme.fg("accent", ">")} ${theme.fg("text", "Ask anything, edit files, run tools")}${theme.inverse(" ")}`;
62
- const hint = theme.fg("dim", "enter send · shift+enter newline · / commands");
63
+ const hint = theme.fg(
64
+ "dim",
65
+ `${editorKey("tui.input.submit")} send · ${editorKey("tui.input.newLine")} newline · / commands`,
66
+ );
63
67
  return [
64
68
  top,
65
69
  `${theme.fg("borderAccent", box.vertical)}${innerWidth > 0 ? padToWidth(prompt, innerWidth) : ""}${theme.fg("borderAccent", box.vertical)}`,
@@ -83,7 +87,9 @@ function renderThemePreview(width: number): string[] {
83
87
 
84
88
  class ThemeSceneController implements SetupSceneController {
85
89
  title = "Pick a theme";
86
- subtitle = "Move through the list to preview; Enter saves the highlighted choice.";
90
+ get subtitle(): string {
91
+ return `Move through the list to preview; ${editorKey("tui.select.confirm")} saves the highlighted choice.`;
92
+ }
87
93
  #mode: ThemeMode = "curated";
88
94
  #selectList: SelectList;
89
95
  #loadingAllThemes = false;
@@ -135,13 +141,20 @@ class ThemeSceneController implements SetupSceneController {
135
141
  render(width: number, maxLines?: number): readonly string[] {
136
142
  const intro = new Container();
137
143
  intro.addChild(
138
- new Text(theme.fg("muted", "Theme changes preview live. Nothing is saved until you press Enter."), 0, 0),
144
+ new Text(
145
+ theme.fg(
146
+ "muted",
147
+ `Theme changes preview live. Nothing is saved until you press ${editorKey("tui.select.confirm")}.`,
148
+ ),
149
+ 0,
150
+ 0,
151
+ ),
139
152
  );
140
153
  intro.addChild(
141
154
  new Text(
142
155
  this.#mode === "all"
143
- ? theme.fg("dim", "Browsing all themes · Esc returns to curated choices")
144
- : theme.fg("dim", "Esc skips this step"),
156
+ ? theme.fg("dim", `Browsing all themes · ${editorKey("tui.select.cancel")} returns to curated choices`)
157
+ : theme.fg("dim", `${editorKey("tui.select.cancel")} skips this step`),
145
158
  0,
146
159
  0,
147
160
  ),
@@ -1,4 +1,6 @@
1
1
  import { type Component, type OverlayFocusOwner } from "../tui";
2
+ import { formatKeyHint } from "../app-keybindings";
3
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
2
4
  import { matchesKey } from "../keys";
3
5
  import { centerLine, padding } from "../utils";
4
6
  import { padToWidth } from "../render/utils";
@@ -200,10 +202,9 @@ export class SetupWizardComponent implements Component, OverlayFocusOwner {
200
202
  header.push("");
201
203
  this.#bodyRowStart = header.length;
202
204
 
203
- const footer = [
204
- "",
205
- centerLine(theme.fg("dim", "↑/↓ select · enter confirm · esc skip · ctrl+c exit setup"), width),
206
- ];
205
+ const navKeys = editorKeys("tui.select.up", "tui.select.down");
206
+ const footerHint = `${navKeys} select · ${editorKey("tui.select.confirm")} confirm · ${editorKey("tui.select.cancel")} skip · ${formatKeyHint("ctrl+c")} exit setup`;
207
+ const footer = ["", centerLine(theme.fg("dim", footerHint), width)];
207
208
  const maxBodyLines = Math.max(0, height - header.length - footer.length);
208
209
  const body = this.#activeScene?.render(contentWidth, maxBodyLines).slice(0, maxBodyLines) ?? [];
209
210
  const lines = [...header, ...body.map(line => indentLine(line, width, SCENE_MARGIN_X))];
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Active theme's symbol lookup without importing `./theme` (which pulls the
3
+ * native addon through `@oh-my-pi/pi-natives`). Key-hint formatting runs on
4
+ * CLI paths that must stay addon-free (`omp --version`, help), so it reads the
5
+ * active theme through this mirror; `./theme` publishes every assignment here.
6
+ */
7
+ import { SYMBOL_PRESETS, type SymbolKey } from "./symbols";
8
+ import type { Theme } from "./theme-class";
9
+
10
+ let active: Theme | undefined;
11
+
12
+ /** @internal Called by `./theme` whenever the active theme changes. */
13
+ export function setActiveSymbolTheme(value: Theme | undefined): void {
14
+ active = value;
15
+ }
16
+
17
+ /** Symbol from the active theme, or the ascii preset before any theme loads. */
18
+ export function activeThemeSymbol(key: SymbolKey): string {
19
+ return active ? active.symbol(key) : SYMBOL_PRESETS.ascii[key];
20
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "Glyph Protocol `glyf` payloads for every private-use icon the TUI emits, extracted from JetBrainsMono NFM Regular (Version 2.304; Nerd Fonts 3.5.1): simple glyphs only, hinting stripped, outlines lifted by +300 so y=0 is the descender line of the aw×lh layout box. Powerline glyphs carry stretch=true. Generated offline; regenerate from the same font when icons are added.",
2
+ "$comment": "Generated by packages/tui/scripts/gen-glyphs.ts from JetBrainsMono NFM Regular (Version 2.304; ttfautohint (v1.8.4.7-5d5b);Nerd Fonts 3.5.1). Do not edit; run `bun run gen:glyphs`.",
3
3
  "upm": 1000,
4
4
  "aw": 600,
5
5
  "lh": 1320,
@@ -595,6 +595,9 @@
595
595
  "f0068": {
596
596
  "glyf": "AAUAAAFoAlgDwQAJABIAHAAgADAAABMHNyciFzc0BxcFNzQHFycHNycTNAcXJwc3JzAXAzcnBzcXFhQHAQYiLwEmNDcBNjK6RigoAkhIKCgBDkgoKEhGKCiOKChIRigoRq5GPkZcQAoK/rYKGAhCCgoBTAoYA1spSEYoKAFHSPEpAUhHKChHRwFWAUdIKSlIRij+9UU9RV9BCBkJ/rQKCkEIGAoBTAo="
597
597
  },
598
+ "f006e": {
599
+ "glyf": "AAIAAAGzAlgDdQAOABoAAAEhIg8BFxYzITI2NRE0JgMHJwcnNyc3FzcXBwIm/ogYDoiIDhgBeBQeHmAiWloiWloiWloiWAN1FsvLFh0VAV4VHf7FI1paI1paI1paI1o="
600
+ },
598
601
  "f00e3": {
599
602
  "glyf": "AAIAAAF4AlgDsAAJABkAAAEnJiIHARcBNjQBIgYVFAcGIx4BMzI2NTQmAk4qCBwI/uRYARoK/kYoOBQUFhRGJDRKNgN8LAgI/uRWARoKGv7gNigcEhAeIko0KDY="
600
603
  },
@@ -661,6 +664,12 @@
661
664
  "f030c": {
662
665
  "glyf": "AAwAAAHCAlgDZgADAAcACwAPABMAFwAbAB8AIwAnACsAOwAAASM1MxUjNTMnIzUzFSM1MxUjNTMlIzUzFSM1OwIVIzUzFSMXMxUjNTMVIyUhIgYVERQWMyEyNjURNCYB/jw8PDxaPDw8PPDw/vI8PDw8Hjw8PDxaPDw8PAEO/iAYJCQYAeAYJCQC0DyWPB48ljy0PJY8ljw8ljwePJY8liQY/tQYJCQYASwYJA=="
663
666
  },
667
+ "f0311": {
668
+ "glyf": "AAEAAAHWAlgDUgAKAAABFSE3JwcXNychNQIY/mByLL6+LHIB4AMyfnAuvr4ucL4="
669
+ },
670
+ "f0312": {
671
+ "glyf": "AAIAAAHoAlgDQAADAAwAAAEzESMHFyEVIQcXNycCIDg48mj+agGWaCqqqgHoAVgqZjhoKKys"
672
+ },
664
673
  "f0331": {
665
674
  "glyf": "AAIAAAE+AlgD6gAIABYAAAEyNjQmIgYUFhcuASMRMhYXPgEzESIGASwqOjpUOjoqPJpWVpo8PphWVpgDIjtTOjpTO3U4Pf6SPjg4PgFuPQ=="
666
675
  },
@@ -700,6 +709,18 @@
700
709
  "f0616": {
701
710
  "glyf": "AAIAAAFoAlgDwAAIABEAABM1IzcnBzUjFQE3FTM1IxUzB+h2ljCURAGAlETodpYBaEOWL5Z36QFQlnfpQ5Y="
702
711
  },
712
+ "f0633": {
713
+ "glyf": "AAYAAAFoAlgDwAAoADEANQA+AEcAUAAAEzIWHQEzNTQ2MhYUBisBFTMyFhQGIiY9ASMVFAYiJjQ2OwE1IyImNDYBFBYyNjQmKwEnIxUzByIGFBYyNj0BETQmIgYUFjsBITI2NCYiBh0BeDBIeEZiSEgwPDwwSEhiRnhIYkZGMjw8MkZGAV4iNCIiGjw8eHjwGiIiMiQkMiIiGjwBLBgkIjQiA8BGMT09MUZGYkd5R2JHRzE9PTFHR2JHeUdiRv4gGSMjMiS0eTskMiMjGT0BLBkjIzMjIzMjIxk9"
714
+ },
715
+ "f0634": {
716
+ "glyf": "AAEAAAHjAlgDRQAHAAABByInByciAQJYNQH29jUBASwCGTb09DYBLA=="
717
+ },
718
+ "f0635": {
719
+ "glyf": "AAIAAAGJAlgDnwAHAAsAABEzEzMVIwMjJTMVI8zsoMzqogFw6OgDn/4tQwHTQ0M="
720
+ },
721
+ "f0636": {
722
+ "glyf": "AAIAAAGkAlgDhAAGAA0AAAE1MycHMxUTASMVITUjAYZAmpxCWgEslv7UlgHgtJqatAGk/tS0tA=="
723
+ },
703
724
  "f0638": {
704
725
  "glyf": "AAP/+wGDAlwDpQAFABsAIQAAAScRNzYmBRMeARcyPwE+ATc0JwMuASMiDwEOASU0JisBFwI0KEYKE/28jgccEAwL1A4UAQSQBR4PCwzTFRQB2SIYKWMDaBH+/qgVLGL+qRATAQVYBxwQCwsBWBAUBFgKLFgYIu8="
705
726
  },
@@ -748,9 +769,15 @@
748
769
  "f0d57": {
749
770
  "glyf": "AAQAAAFoAlgDwAAQACAAJAAoAAABBxc1PgI0LgEnFR4BFAYHJTQ2NzUOAhUUFhcnNy4BASMRNxE1JxUBaHh4RG4+Pm5EUGRmTv7UZFBEbj5uVhQkRFQBDjw8PAKUd3ldByo/Rz8qBzwHMj4xB1cfMQg8Byo/JDBNDxUjCi4BSP63O/62Hj1b"
750
771
  },
772
+ "f0e7e": {
773
+ "glyf": "AAIAAAGzAlgDdQAOABoAABMhMj8BJyYjISIGFREUFhM3FzcXBxcHJwcnNzIBeBQUhoYUFP6IFB4eYCRYWiRaWiRaWCRYAbMWy8sWHRX+ohUdATsjWlojWlojWlojWg=="
774
+ },
751
775
  "f0eab": {
752
776
  "glyf": "AAUAAAFoAlgDwAALACAAJAAqAC4AAAEzFTMVIxUjNSM1MwEhMhYdASYnNSERMxYXIyImNRE0NhchFSEVIRUGByMVMxUjAbo+YGA+Xl7+hgG4HCQeIv5I4AQS9hwkJFoBPP7EATwoIvKengJkXz5fXz4BuyUb9hEF4P5HIh4lGwG5GyV+QD8jBhY/QA=="
753
777
  },
778
+ "f1050": {
779
+ "glyf": "AAEAAAIwAlgC+AANAAARMxUhNTMVFAYjISImNUQB0EQoHP4wHCgC+IWFhRwnJxw="
780
+ },
754
781
  "f12b7": {
755
782
  "glyf": "AAMAAAILAlgDHQALACMAOwAAETMVIxUzFSMVMxUjATMVIxUzMhYdARQGKwE1MzUjIiY9ATQ2OwEyFh0BIzUjFTM1MxUUBisBIiY9ATQ2pGxsbGykARJsbDQYICAYbGw0GCAg8DYYIDg2NjggGDYWICADHTc3Njc3ARI3NyAWNxcgNzcgFjcXICAXGxukGxsXICEWpBcg"
756
783
  },
@@ -99,7 +99,6 @@ export type SymbolKey =
99
99
  | "icon.advisorClosed"
100
100
  | "icon.time"
101
101
  | "icon.omp"
102
- | "icon.esc"
103
102
  | "icon.ghost"
104
103
  | "icon.agents"
105
104
  | "icon.job"
@@ -285,7 +284,28 @@ export type SymbolKey =
285
284
  | "tool.goal"
286
285
  | "tool.irc"
287
286
  | "tool.delete"
288
- | "tool.move";
287
+ | "tool.move"
288
+ // Keyboard keys (rendered by formatKeyHint). `*Mac` variants replace the
289
+ // generic modifier on macOS, whose keycaps are labelled with ⌃ ⌥ ⌘.
290
+ | "key.ctrl"
291
+ | "key.ctrlMac"
292
+ | "key.shift"
293
+ | "key.alt"
294
+ | "key.altMac"
295
+ | "key.super"
296
+ | "key.superMac"
297
+ // Gap after a glyph modifier (`⌃⇧C`); nerd icons render too small to abut
298
+ | "key.joiner"
299
+ | "key.enter"
300
+ | "key.esc"
301
+ | "key.tab"
302
+ | "key.backspace"
303
+ | "key.delete"
304
+ | "key.space"
305
+ | "key.up"
306
+ | "key.down"
307
+ | "key.left"
308
+ | "key.right";
289
309
 
290
310
  export type SymbolMap = Record<SymbolKey, string>;
291
311
  /**
@@ -459,7 +479,6 @@ const UNICODE_SYMBOLS: SymbolMap = {
459
479
  "icon.advisorClosed": "🙈",
460
480
  "icon.time": "⏱",
461
481
  "icon.omp": "π",
462
- "icon.esc": "⎋",
463
482
  "icon.ghost": "👻",
464
483
  "icon.agents": "👥",
465
484
  "icon.job": "⚙",
@@ -646,6 +665,25 @@ const UNICODE_SYMBOLS: SymbolMap = {
646
665
  "tool.irc": "✉",
647
666
  "tool.delete": "🗑",
648
667
  "tool.move": "➜",
668
+ // Keys: word modifiers off macOS (⌃/⌥/⌘ name no key on a PC keyboard)
669
+ "key.ctrl": "Ctrl",
670
+ "key.ctrlMac": "⌃",
671
+ "key.shift": "⇧",
672
+ "key.alt": "Alt",
673
+ "key.altMac": "⌥",
674
+ "key.super": "Super",
675
+ "key.superMac": "⌘",
676
+ "key.joiner": "",
677
+ "key.enter": "⏎",
678
+ "key.esc": "⎋",
679
+ "key.tab": "⇥",
680
+ "key.backspace": "⌫",
681
+ "key.delete": "⌦",
682
+ "key.space": "␣",
683
+ "key.up": "↑",
684
+ "key.down": "↓",
685
+ "key.left": "←",
686
+ "key.right": "→",
649
687
  };
650
688
 
651
689
  const NERD_SYMBOLS: SymbolMap = {
@@ -822,8 +860,6 @@ const NERD_SYMBOLS: SymbolMap = {
822
860
  // U+F03FF before (2ec52b8bdd) and reverted because it renders the wrong glyph;
823
861
  // do not swap it again.
824
862
  "icon.omp": "\u{f0d57}",
825
- // pick: 󱊷 (nf-md-keyboard_esc) | alt: ⎋
826
- "icon.esc": "\u{f12b7}",
827
863
  // pick: 󰊠 (nf-md-ghost) | alt: 👻
828
864
  "icon.ghost": "\u{f02a0}",
829
865
  // pick:  | alt: 
@@ -1098,6 +1134,35 @@ const NERD_SYMBOLS: SymbolMap = {
1098
1134
  "tool.irc": "\uF086",
1099
1135
  "tool.delete": "\uf12d",
1100
1136
  "tool.move": "\uf061",
1137
+ // Keys: word modifiers off macOS; arrows stay Unicode (nerd arrows add nothing)
1138
+ "key.ctrl": "Ctrl",
1139
+ // pick: 󰘴 (nf-md-apple_keyboard_control) | alt: ⌃
1140
+ "key.ctrlMac": "\u{f0634}",
1141
+ // pick: 󰘶 (nf-md-apple_keyboard_shift) | alt: ⇧
1142
+ "key.shift": "\u{f0636}",
1143
+ "key.alt": "Alt",
1144
+ // pick: 󰘵 (nf-md-apple_keyboard_option) | alt: ⌥
1145
+ "key.altMac": "\u{f0635}",
1146
+ "key.super": "Super",
1147
+ // pick: 󰘳 (nf-md-apple_keyboard_command) | alt: ⌘
1148
+ "key.superMac": "\u{f0633}",
1149
+ "key.joiner": " ",
1150
+ // pick: 󰌑 (nf-md-keyboard_return) | alt: ⏎
1151
+ "key.enter": "\u{f0311}",
1152
+ // pick: 󱊷 (nf-md-keyboard_esc) | alt: ⎋
1153
+ "key.esc": "\u{f12b7}",
1154
+ // pick: 󰌒 (nf-md-keyboard_tab) | alt: ⇥
1155
+ "key.tab": "\u{f0312}",
1156
+ // pick: 󰁮 (nf-md-backspace) | alt: ⌫
1157
+ "key.backspace": "\u{f006e}",
1158
+ // pick: 󰹾 (nf-md-backspace_reverse) | alt: ⌦
1159
+ "key.delete": "\u{f0e7e}",
1160
+ // pick: 󱁐 (nf-md-keyboard_space) | alt: ␣
1161
+ "key.space": "\u{f1050}",
1162
+ "key.up": "↑",
1163
+ "key.down": "↓",
1164
+ "key.left": "←",
1165
+ "key.right": "→",
1101
1166
  };
1102
1167
 
1103
1168
  const ASCII_SYMBOLS: SymbolMap = {
@@ -1192,7 +1257,6 @@ const ASCII_SYMBOLS: SymbolMap = {
1192
1257
  "icon.advisorClosed": "(adv)",
1193
1258
  "icon.time": "t:",
1194
1259
  "icon.omp": "pi",
1195
- "icon.esc": "esc",
1196
1260
  "icon.ghost": "@",
1197
1261
  "icon.agents": "AG",
1198
1262
  "icon.job": "bg",
@@ -1374,6 +1438,24 @@ const ASCII_SYMBOLS: SymbolMap = {
1374
1438
  "tool.irc": "#",
1375
1439
  "tool.delete": "rm",
1376
1440
  "tool.move": "mv",
1441
+ "key.ctrl": "Ctrl",
1442
+ "key.ctrlMac": "Ctrl",
1443
+ "key.shift": "Shift",
1444
+ "key.alt": "Alt",
1445
+ "key.altMac": "Option",
1446
+ "key.super": "Super",
1447
+ "key.superMac": "Cmd",
1448
+ "key.joiner": "+",
1449
+ "key.enter": "Enter",
1450
+ "key.esc": "Esc",
1451
+ "key.tab": "Tab",
1452
+ "key.backspace": "Backspace",
1453
+ "key.delete": "Delete",
1454
+ "key.space": "Space",
1455
+ "key.up": "Up",
1456
+ "key.down": "Down",
1457
+ "key.left": "Left",
1458
+ "key.right": "Right",
1377
1459
  };
1378
1460
 
1379
1461
  export const SYMBOL_PRESETS: Record<SymbolPreset, SymbolMap> = {