@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
@@ -19,7 +19,8 @@ import { MODEL_KINDS, modelKind, type ModelKind } from "@oh-my-pi/pi-catalog/typ
19
19
  import type { Component, TUI } from "../tui";
20
20
  import { extractPrintableText, matchesKey } from "../keys";
21
21
  import { fuzzyFilter } from "../fuzzy";
22
- import { getKeybindings } from "../keybindings";
22
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
23
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
23
24
  import { Input } from "../components/input";
24
25
  import { routeSgrMouseInput, type SgrMouseEvent } from "../mouse";
25
26
  import { truncateToWidth, visibleWidth } from "../utils";
@@ -2013,7 +2014,10 @@ export class ModelHubComponent implements Component {
2013
2014
  MODEL_KIND_TABS.map(kind => ({ label: kind })),
2014
2015
  Math.max(0, active),
2015
2016
  );
2016
- return truncateToWidth(` ${theme.fg("dim", "Kind:")} ${track} ${theme.fg("dim", "Alt+←/→")}`, width);
2017
+ return truncateToWidth(
2018
+ ` ${theme.fg("dim", "Kind:")} ${track} ${theme.fg("dim", formatKeyHints(["alt+left", "alt+right"]))}`,
2019
+ width,
2020
+ );
2017
2021
  }
2018
2022
 
2019
2023
  #renderRoleTabs(width: number): string {
@@ -2022,7 +2026,10 @@ export class ModelHubComponent implements Component {
2022
2026
  ROLE_TABS.map(tab => ({ label: tab === "kind" ? "kinds" : tab })),
2023
2027
  Math.max(0, active),
2024
2028
  );
2025
- return truncateToWidth(` ${theme.fg("dim", "Roles:")} ${track} ${theme.fg("dim", "Alt+←/→")}`, width);
2029
+ return truncateToWidth(
2030
+ ` ${theme.fg("dim", "Roles:")} ${track} ${theme.fg("dim", formatKeyHints(["alt+left", "alt+right"]))}`,
2031
+ width,
2032
+ );
2026
2033
  }
2027
2034
 
2028
2035
  #statusRow(width: number): string {
@@ -2030,9 +2037,11 @@ export class ModelHubComponent implements Component {
2030
2037
  return truncateToWidth(theme.fg("accent", " Applying model…"), width);
2031
2038
  }
2032
2039
  if (this.#assigning !== null) {
2040
+ const enter = formatKeyHint("enter");
2041
+ const cancel = editorKey("tui.select.cancel");
2033
2042
  if (this.#assigning.kind === "fallbackKey") {
2034
2043
  return truncateToWidth(
2035
- theme.fg("accent", " New fallback chain — Enter picks the model it protects, Esc cancels"),
2044
+ theme.fg("accent", ` New fallback chain — ${enter} picks the model it protects, ${cancel} cancels`),
2036
2045
  width,
2037
2046
  );
2038
2047
  }
@@ -2041,12 +2050,15 @@ export class ModelHubComponent implements Component {
2041
2050
  if (this.#assigning.kind === "fallback") {
2042
2051
  const verb = this.#assigning.index === null ? "Adding fallback for" : "Replacing fallback of";
2043
2052
  return truncateToWidth(
2044
- theme.fg("accent", ` ${verb} ${theme.bold(label)} — Enter picks the fallback model, Esc cancels`),
2053
+ theme.fg(
2054
+ "accent",
2055
+ ` ${verb} ${theme.bold(label)} — ${enter} picks the fallback model, ${cancel} cancels`,
2056
+ ),
2045
2057
  width,
2046
2058
  );
2047
2059
  }
2048
2060
  return truncateToWidth(
2049
- theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`),
2061
+ theme.fg("accent", ` Assigning ${theme.bold(label)} — ${enter} assigns, ${cancel} cancels`),
2050
2062
  width,
2051
2063
  );
2052
2064
  }
@@ -2058,7 +2070,7 @@ export class ModelHubComponent implements Component {
2058
2070
  text = `Recently used models${scopedSuffix}`;
2059
2071
  break;
2060
2072
  case "roles":
2061
- text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection";
2073
+ text = `Model roles — ${formatKeyHint("f")} adds a retry fallback, cleared roles fall back to auto-selection`;
2062
2074
  break;
2063
2075
  case "provider":
2064
2076
  if (entry.locked) {
@@ -2213,7 +2225,7 @@ export class ModelHubComponent implements Component {
2213
2225
  // segment track the ctrl+p status uses; the selected role's chip fills.
2214
2226
  while (lines.length < rows - 1) lines.push("");
2215
2227
  if (rows >= 2) {
2216
- const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
2228
+ const cycleKey = editorKey("app.model.cycleForward") || formatKeyHint("ctrl+p");
2217
2229
  if (cycleOrder.length > 0) {
2218
2230
  const selectedRow = this.#rolesRows[this.#roleIndex];
2219
2231
  const selectedRole =
@@ -2226,7 +2238,7 @@ export class ModelHubComponent implements Component {
2226
2238
  lines[rows - 1] = truncateToWidth(` ${theme.fg("dim", `${cycleKey} cycle:`)} ${track}`, width);
2227
2239
  } else {
2228
2240
  lines[rows - 1] = truncateToWidth(
2229
- theme.fg("dim", ` ${cycleKey} cycle is empty — press c on a role to add it`),
2241
+ theme.fg("dim", ` ${cycleKey} cycle is empty — press ${formatKeyHint("c")} on a role to add it`),
2230
2242
  width,
2231
2243
  );
2232
2244
  }
@@ -2253,7 +2265,12 @@ export class ModelHubComponent implements Component {
2253
2265
  }
2254
2266
  if (entry.oauth) {
2255
2267
  this.#lockedLoginLine = lines.length + 1; // +1 for the status row offset handled by caller
2256
- lines.push(truncateToWidth(theme.fg("accent", ` ${theme.nav.cursor} Log in with OAuth (Enter)`), width));
2268
+ lines.push(
2269
+ truncateToWidth(
2270
+ theme.fg("accent", ` ${theme.nav.cursor} Log in with OAuth (${formatKeyHint("enter")})`),
2271
+ width,
2272
+ ),
2273
+ );
2257
2274
  }
2258
2275
  lines.push("");
2259
2276
  const catalogCount = entry.catalogCount ?? 0;
@@ -2271,32 +2288,40 @@ export class ModelHubComponent implements Component {
2271
2288
  }
2272
2289
 
2273
2290
  #footerHint(): string {
2291
+ const enter = formatKeyHint("enter");
2292
+ const cancel = editorKey("tui.select.cancel");
2293
+ const upDown = editorKeys("tui.select.up", "tui.select.down");
2294
+ const left = formatKeyHint("left");
2295
+ const leftRight = formatKeyHints(["left", "right"]);
2296
+ const enterRight = formatKeyHints(["enter", "right"]);
2297
+ const altLeftRight = formatKeyHints(["alt+left", "alt+right"]);
2274
2298
  const strip = this.#strip;
2275
2299
  if (strip) {
2276
2300
  if (strip.kind === "roleName") {
2277
- return "Enter create + pick model · Esc cancel";
2301
+ return `${enter} create + pick model · ${cancel} cancel`;
2278
2302
  }
2279
- if (strip.kind === "role") return "←/→ choose · Enter assign/clear · Esc cancel";
2280
- if (strip.kind === "scope") return "←/→ save scope · Enter choose · Esc cancel";
2281
- return "←/→ thinking level · Enter apply · Esc keep";
2303
+ if (strip.kind === "role") return `${leftRight} choose · ${enter} assign/clear · ${cancel} cancel`;
2304
+ if (strip.kind === "scope") return `${leftRight} save scope · ${enter} choose · ${cancel} cancel`;
2305
+ return `${leftRight} thinking level · ${enter} apply · ${cancel} keep`;
2282
2306
  }
2283
2307
  if (this.#assigning !== null) {
2284
2308
  if (this.#focus === "scope") {
2285
- return "Enter/→ models · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
2309
+ return `${enterRight} models · ${upDown} providers · type to search · ${altLeftRight} kind · ${cancel} cancel`;
2286
2310
  }
2311
+ const browse = `${upDown} models · ${left} providers · type to search · ${altLeftRight} kind · ${cancel} cancel`;
2287
2312
  switch (this.#assigning.kind) {
2288
2313
  case "fallback":
2289
- return "Enter pick fallback · ↑/↓ models · ← providers · type to search · Alt+←/→ kind · Esc cancel";
2314
+ return `${enter} pick fallback · ${browse}`;
2290
2315
  case "fallbackKey":
2291
- return "Enter pick the protected model · ↑/↓ models · ← providers · type to search · Alt+←/→ kind · Esc cancel";
2316
+ return `${enter} pick the protected model · ${browse}`;
2292
2317
  default:
2293
- return "Enter assign · ↑/↓ models · ← providers · type to search · Alt+←/→ kind · Esc cancel";
2318
+ return `${enter} assign · ${browse}`;
2294
2319
  }
2295
2320
  }
2296
2321
  const entry = this.#activeEntry();
2297
2322
  if (entry.kind === "roles") {
2298
2323
  if (this.#focus !== "list") {
2299
- return "↑/↓ providers · Enter/→ roles · Alt+←/→ tabs · Esc close";
2324
+ return `${upDown} providers · ${enterRight} roles · ${altLeftRight} tabs · ${cancel} close`;
2300
2325
  }
2301
2326
  const row = this.#rolesRows[this.#roleIndex];
2302
2327
  if (row?.kind === "fallback") {
@@ -2304,25 +2329,27 @@ export class ModelHubComponent implements Component {
2304
2329
  // inherit and unknown models have no ladder to offer, so the
2305
2330
  // action would be inert there.
2306
2331
  const editable = this.#resolveFallbackEntry(row.role, row.chainIndex) !== undefined;
2307
- const thinking = editable ? " · t thinking" : "";
2308
- return `↑/↓ rows · Enter replace · f add another · x remove${thinking} · [/] reorder · ← providers`;
2332
+ const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
2333
+ return `${upDown} rows · ${enter} replace · ${formatKeyHint("f")} add another · ${formatKeyHint("x")} remove${thinking} · [/] reorder · ${left} providers`;
2309
2334
  }
2310
2335
  if (row?.kind === "chainKey") {
2311
- return "↑/↓ rows · Enter/f add fallback · x clear chain · ← providers";
2336
+ return `${upDown} rows · ${formatKeyHints(["enter", "f"])} add fallback · ${formatKeyHint("x")} clear chain · ${left} providers`;
2312
2337
  }
2313
2338
  if (row?.kind === "newFallback") {
2314
- return "↑/↓ rows · Enter new model/provider fallback chain · ← providers";
2339
+ return `${upDown} rows · ${enter} new model/provider fallback chain · ${left} providers`;
2315
2340
  }
2316
- return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new";
2341
+ return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear · ${formatKeyHint("t")} thinking · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new`;
2317
2342
  }
2318
2343
  if (entry.kind === "provider" && entry.locked) {
2319
- return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
2344
+ return entry.oauth
2345
+ ? `${enter} log in · ${upDown} providers · ${cancel} close`
2346
+ : `${upDown} providers · ${cancel} close`;
2320
2347
  }
2321
- const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
2348
+ const refresh = entry.kind === "provider" ? ` · ${formatKeyHint("f5")} refresh` : "";
2322
2349
  if (this.#focus === "scope") {
2323
- return `Enter/→ models · ↑/↓ providers · type to search · Alt+←/→ kind${refresh} · Esc close`;
2350
+ return `${enterRight} models · ${upDown} providers · type to search · ${altLeftRight} kind${refresh} · ${cancel} close`;
2324
2351
  }
2325
- return `Enter assign roles · ↑/↓ models · ← providers · type to search · Alt+←/→ kind${refresh} · Esc close`;
2352
+ return `${enter} assign roles · ${upDown} models · ${left} providers · type to search · ${altLeftRight} kind${refresh} · ${cancel} close`;
2326
2353
  }
2327
2354
 
2328
2355
  #renderFooter(width: number): string {
@@ -15,6 +15,8 @@ import type { ConfiguredThinkingLevel } from "../thinking";
15
15
  import type { ScopedModelItem } from "./model-hub";
16
16
  import { bottomBorder, row, topBorder } from "../chrome/overlay-box";
17
17
  import { resolveSegmentPalette } from "../chrome/segment-track";
18
+ import { formatKeyHint } from "../app-keybindings";
19
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
18
20
 
19
21
  /** Configured role resolved to a concrete model. */
20
22
  export interface ResolvedRoleModel {
@@ -58,10 +60,8 @@ export interface ModelPickerOptions {
58
60
  quickRoleOrder?: ReadonlyArray<string>;
59
61
  /** Active quick role, highlighted when the search begins with `@`. */
60
62
  currentQuickRole?: string;
61
- /** Keys that toggle task-subagent mode while the picker is open; typically the alt+p binding. */
63
+ /** Keys that toggle task-subagent mode while the picker is open; the first is shown in the footer. */
62
64
  taskModeKeys?: readonly KeyId[];
63
- /** Human-readable label for the toggle key, shown in footer hints (e.g. "alt+p"). */
64
- taskModeKeyLabel?: string;
65
65
  /** `provider/id` highlighted and preselected in task mode (current Task subagent model). */
66
66
  taskSelector?: string;
67
67
  }
@@ -78,9 +78,21 @@ const HEIGHT_FRACTION = 0.4;
78
78
  const STATUS_HINT = "Session-only switch — role models stay unchanged";
79
79
  const QUICK_ROLE_STATUS_HINT = "Quick role switch — applies its model and thinking for this session";
80
80
  const TASK_STATUS_HINT = "Task subagent switch — spawned task agents use this model (session-only)";
81
- const FOOTER_HINT = "↑/↓ models · Enter use for this session · type to search · @ quick roles · Esc close";
82
- const QUICK_ROLE_FOOTER_HINT = "↑/↓ roles · Enter apply role model · type to search · Esc close";
83
- const TASK_FOOTER_HINT = "↑/↓ models · Enter use for Task subagents · type to search · Esc close";
81
+
82
+ /** Footer hint for the active mode; keys resolve at render time so theme/keybinding changes apply. */
83
+ function footerHint(mode: "session" | "role" | "task"): string {
84
+ const upDown = editorKeys("tui.select.up", "tui.select.down");
85
+ const enter = formatKeyHint("enter");
86
+ const close = `${editorKey("tui.select.cancel")} close`;
87
+ switch (mode) {
88
+ case "role":
89
+ return `${upDown} roles · ${enter} apply role model · type to search · ${close}`;
90
+ case "task":
91
+ return `${upDown} models · ${enter} use for Task subagents · type to search · ${close}`;
92
+ default:
93
+ return `${upDown} models · ${enter} use for this session · type to search · @ quick roles · ${close}`;
94
+ }
95
+ }
84
96
 
85
97
  /**
86
98
  * The alt+p picker component. Hosted as a non-fullscreen bottom-anchored
@@ -102,7 +114,7 @@ export class ModelPickerComponent implements Component {
102
114
  #roleMode = false;
103
115
  #taskMode = false;
104
116
  #taskMatchKeys = new Set<string>();
105
- #taskModeKeyLabel: string;
117
+ #taskModeKey: KeyId | undefined;
106
118
  #taskSelector: string | undefined;
107
119
 
108
120
  constructor(
@@ -120,8 +132,8 @@ export class ModelPickerComponent implements Component {
120
132
  this.#currentSelector = options.currentSelector;
121
133
  this.#currentQuickRoleSelector = options.currentQuickRole ? `@${options.currentQuickRole}` : undefined;
122
134
  this.#taskSelector = options.taskSelector;
123
- this.#taskModeKeyLabel = options.taskModeKeyLabel ?? "alt+p";
124
135
  if (callbacks.onPickTask) {
136
+ this.#taskModeKey = options.taskModeKeys?.[0];
125
137
  for (const key of options.taskModeKeys ?? []) addKeyAliases(this.#taskMatchKeys, key);
126
138
  }
127
139
  this.#quickRoleItems = this.#buildQuickRoleItems(
@@ -132,7 +144,10 @@ export class ModelPickerComponent implements Component {
132
144
  this.#browser = new ModelBrowser(settings, {
133
145
  currentContextTokens: options.currentContextTokens,
134
146
  markOverContext: true,
135
- emptyText: () => (this.#roleMode ? " No quick roles in the Ctrl+P cycle" : undefined),
147
+ emptyText: () =>
148
+ this.#roleMode
149
+ ? ` No quick roles in the ${editorKey("app.model.cycleForward") || formatKeyHint("ctrl+p")} cycle`
150
+ : undefined,
136
151
  });
137
152
  this.#browser.onActivate = item => {
138
153
  const quickRole = this.#quickRoles.get(item.selector);
@@ -267,9 +282,9 @@ export class ModelPickerComponent implements Component {
267
282
  : theme.fg("muted", ` ${this.#roleMode ? QUICK_ROLE_STATUS_HINT : STATUS_HINT}`);
268
283
 
269
284
  const borderColor: ThemeColor | undefined = this.#taskMode ? "error" : undefined;
270
- let footer = this.#taskMode ? TASK_FOOTER_HINT : this.#roleMode ? QUICK_ROLE_FOOTER_HINT : FOOTER_HINT;
271
- if (this.#taskMatchKeys.size > 0 && !this.#roleMode) {
272
- footer += ` · ${this.#taskModeKeyLabel} ${this.#taskMode ? "session model" : "task model"}`;
285
+ let footer = footerHint(this.#taskMode ? "task" : this.#roleMode ? "role" : "session");
286
+ if (this.#taskModeKey !== undefined && !this.#roleMode) {
287
+ footer += ` · ${formatKeyHint(this.#taskModeKey)} ${this.#taskMode ? "session model" : "task model"}`;
273
288
  }
274
289
 
275
290
  const out: string[] = [];
@@ -11,6 +11,8 @@ import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keyb
11
11
  import { formTheme } from "../chrome/form-theme";
12
12
  import { bottomBorder, row, topBorder } from "../chrome/overlay-box";
13
13
  import { TextFormField } from "../components/form";
14
+ import { formatKeyHint } from "../app-keybindings";
15
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
14
16
 
15
17
  export interface MoveOverlayResult {
16
18
  directory: string;
@@ -161,7 +163,9 @@ export class MoveOverlay implements Component, Focusable {
161
163
  }
162
164
 
163
165
  lines.push(row("", w));
164
- lines.push(row(theme.fg("dim", "Type to filter · ↑↓ navigate · Tab accept · Enter confirm · Esc cancel"), w));
166
+ const nav = editorKeys("tui.select.up", "tui.select.down");
167
+ const hint = `Type to filter · ${nav} navigate · ${formatKeyHint("tab")} accept · ${formatKeyHint("enter")} confirm · ${editorKey("tui.select.cancel")} cancel`;
168
+ lines.push(row(theme.fg("dim", hint), w));
165
169
  lines.push(bottomBorder(w));
166
170
  this.#renderMemo = { width: w, fieldLines, revision: this.#revision, lines };
167
171
  return lines;
@@ -3,6 +3,7 @@ import { replaceTabs } from "../render/render-utils";
3
3
  import { getMarkdownTheme, theme } from "../theme/theme";
4
4
  import { OverlayPanel } from "../chrome/overlay-box";
5
5
  import { StreamingPanelContent } from "../chrome/streaming-panel";
6
+ import { interruptKey } from "../chrome/keybinding-hints";
6
7
 
7
8
  export type OmfgPanelState =
8
9
  | "generating"
@@ -103,23 +104,25 @@ export class OmfgPanelComponent extends OverlayPanel {
103
104
  }
104
105
 
105
106
  #footerLine(): string {
107
+ // The composer's `app.interrupt` handler routes Esc to this panel.
108
+ const esc = interruptKey();
106
109
  switch (this.#state) {
107
110
  case "generating":
108
111
  case "validating":
109
112
  case "confirming":
110
113
  case "saving":
111
- return theme.fg("muted", "Esc cancel /omfg");
114
+ return theme.fg("muted", `${esc} cancel /omfg`);
112
115
  case "saved":
113
116
  return theme.fg(
114
117
  "success",
115
- `${theme.status.success} Registered live · ${replaceTabs(this.#savedPath ?? "saved")} · Esc dismiss`,
118
+ `${theme.status.success} Registered live · ${replaceTabs(this.#savedPath ?? "saved")} · ${esc} dismiss`,
116
119
  );
117
120
  case "rejected":
118
- return theme.fg("warning", `${theme.status.warning} Not saved · Esc dismiss`);
121
+ return theme.fg("warning", `${theme.status.warning} Not saved · ${esc} dismiss`);
119
122
  case "aborted":
120
- return theme.fg("warning", `${theme.status.warning} Cancelled · Esc dismiss`);
123
+ return theme.fg("warning", `${theme.status.warning} Cancelled · ${esc} dismiss`);
121
124
  case "error":
122
- return theme.fg("error", `${theme.status.error} Error · Esc dismiss`);
125
+ return theme.fg("error", `${theme.status.error} Error · ${esc} dismiss`);
123
126
  }
124
127
  }
125
128
 
@@ -17,6 +17,8 @@ import { formatCoarseDuration } from "../chrome/format";
17
17
  import { centerLine } from "../utils";
18
18
  import { theme } from "../theme/theme";
19
19
  import { matchesAppInterrupt } from "../keybinding-matchers";
20
+ import { formatKeyHint } from "../app-keybindings";
21
+ import { interruptKey } from "../chrome/keybinding-hints";
20
22
 
21
23
  /**
22
24
  * Slice of `InteractiveModeContext` the pause screen drives. Narrow so tests
@@ -50,7 +52,6 @@ const BODY_LINES = [
50
52
  "Main agent, subagents, and advisor hold at their next step.",
51
53
  "In-flight calls finish; nothing new starts until you resume.",
52
54
  ] as const;
53
- const RESUME_HINT = "esc · enter · space — resume";
54
55
 
55
56
  /** Live hold clock, seconds-precise: `0:07`, `12:34`, `1:02:03`. */
56
57
  function formatClock(ms: number): string {
@@ -69,6 +70,8 @@ function formatClock(ms: number): string {
69
70
  export function renderPauseScreen(width: number, height: number, elapsedMs: number, sessionName?: string): string[] {
70
71
  const compact = width < MIN_FULL_WIDTH || height < MIN_FULL_HEIGHT;
71
72
  const content: string[] = [];
73
+ // Resume keys mirror PauseScreenComponent#handleInput: `app.interrupt` (raw Escape when unbound), Enter, Space.
74
+ const esc = interruptKey();
72
75
 
73
76
  if (compact) {
74
77
  if (sessionName) {
@@ -78,7 +81,7 @@ export function renderPauseScreen(width: number, height: number, elapsedMs: numb
78
81
  content.push(centerLine(theme.bold(theme.fg("accent", `▌▌ ${TITLE}`)), width).trimEnd());
79
82
  content.push("");
80
83
  content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width).trimEnd());
81
- content.push(centerLine(theme.fg("dim", "esc to resume"), width).trimEnd());
84
+ content.push(centerLine(theme.fg("dim", `${esc} to resume`), width).trimEnd());
82
85
  } else {
83
86
  if (sessionName) {
84
87
  content.push(centerLine(theme.bold(sessionName), width).trimEnd());
@@ -99,7 +102,8 @@ export function renderPauseScreen(width: number, height: number, elapsedMs: numb
99
102
  content.push("");
100
103
  content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width).trimEnd());
101
104
  content.push("");
102
- content.push(centerLine(theme.fg("dim", RESUME_HINT), width).trimEnd());
105
+ const resumeHint = `${esc} · ${formatKeyHint("enter")} · ${formatKeyHint("space")} — resume`;
106
+ content.push(centerLine(theme.fg("dim", resumeHint), width).trimEnd());
103
107
  }
104
108
 
105
109
  const topPad = Math.max(0, Math.floor((height - content.length) / 2));
@@ -52,6 +52,8 @@ import {
52
52
  import { joinPlanSections, parsePlanSections, sectionDeletionSpan } from "./plan-toc";
53
53
  import { padToWidth } from "../render/utils";
54
54
  import { renderSegmentTrack } from "../chrome/segment-track";
55
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
56
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
55
57
 
56
58
  /** Title shown in the overlay's top border. */
57
59
  const OVERLAY_TITLE = "Plan Review";
@@ -166,9 +168,6 @@ export interface PlanReviewOverlayOptions {
166
168
  annotationState?: PlanReviewAnnotationState;
167
169
  }
168
170
 
169
- /** Default trailing footer hint when the caller supplies none. */
170
- const DEFAULT_HELP_SUFFIX = "esc cancel";
171
-
172
171
  export class PlanReviewOverlay implements Component {
173
172
  #mdTheme: MarkdownTheme;
174
173
  #scrollView: ScrollView;
@@ -185,7 +184,7 @@ export class PlanReviewOverlay implements Component {
185
184
 
186
185
  #options: string[];
187
186
  #disabled: Set<number>;
188
- #helpSuffix: string;
187
+ #helpSuffix: string | undefined;
189
188
  #externalEditorLabel: string | undefined;
190
189
  #promptTitle: string | undefined;
191
190
  #selectedIndex: number;
@@ -240,7 +239,7 @@ export class PlanReviewOverlay implements Component {
240
239
  this.#disabled = new Set(
241
240
  (options.disabledIndices ?? []).filter(i => Number.isInteger(i) && i >= 0 && i < this.#options.length),
242
241
  );
243
- this.#helpSuffix = options.helpText ?? DEFAULT_HELP_SUFFIX;
242
+ this.#helpSuffix = options.helpText;
244
243
  this.#externalEditorLabel = options.externalEditorLabel;
245
244
  this.#promptTitle = options.promptTitle;
246
245
  this.#selectedIndex = this.#coerceIndex(options.initialIndex ?? 0);
@@ -1069,22 +1068,39 @@ export class PlanReviewOverlay implements Component {
1069
1068
  #buildHelp(): string {
1070
1069
  const sep = " · ";
1071
1070
  const parts: string[] = [];
1071
+ const upDown = editorKeys("tui.select.up", "tui.select.down");
1072
+ const enter = formatKeyHint("enter");
1072
1073
  switch (this.#focus) {
1073
1074
  case "actions":
1074
- parts.push("↑↓ select", "⏎ confirm");
1075
- if (this.#slider) parts.push("◂▸ model");
1075
+ parts.push(`${upDown} select`, `${enter} confirm`);
1076
+ if (this.#slider) parts.push(`${formatKeyHints(["left", "right"])} model`);
1076
1077
  break;
1077
1078
  case "toc":
1078
- parts.push("↑↓ section", "⏎ open", "a annotate", "e edit", "d delete", "u undo");
1079
+ parts.push(
1080
+ `${upDown} section`,
1081
+ `${enter} open`,
1082
+ `${formatKeyHint("a")} annotate`,
1083
+ `${formatKeyHint("e")} edit`,
1084
+ `${formatKeyHint("d")} delete`,
1085
+ `${formatKeyHint("u")} undo`,
1086
+ );
1079
1087
  break;
1080
1088
  case "body":
1081
- parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends", "a annotate", "e edit", "u undo");
1089
+ parts.push(
1090
+ `${upDown} scroll`,
1091
+ `${formatKeyHint("shift")} faster`,
1092
+ formatKeyHints(["pageUp", "pageDown"]),
1093
+ `${formatKeyHints(["g", "shift+g"])} ends`,
1094
+ `${formatKeyHint("a")} annotate`,
1095
+ `${formatKeyHint("e")} edit`,
1096
+ `${formatKeyHint("u")} undo`,
1097
+ );
1082
1098
  break;
1083
1099
  }
1084
- if (this.callbacks.onCopyPlan) parts.push("c copy");
1085
- parts.push("tab regions");
1100
+ if (this.callbacks.onCopyPlan) parts.push(`${formatKeyHint("c")} copy`);
1101
+ parts.push(`${formatKeyHint("tab")} regions`);
1086
1102
  if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`);
1087
- parts.push(this.#helpSuffix);
1103
+ parts.push(this.#helpSuffix ?? `${editorKey("tui.select.cancel")} cancel`);
1088
1104
  return parts.join(sep);
1089
1105
  }
1090
1106
 
@@ -1295,7 +1311,13 @@ export class PlanReviewOverlay implements Component {
1295
1311
  lines.push(truncateToWidth(label, innerWidth, Ellipsis.Unicode));
1296
1312
  }
1297
1313
  if (!Number.isFinite(maxRows) || maxRows >= 2) {
1298
- lines.push(theme.fg("dim", "↑↓ choose · enter edit · esc cancel"));
1314
+ const upDown = editorKeys("tui.select.up", "tui.select.down");
1315
+ lines.push(
1316
+ theme.fg(
1317
+ "dim",
1318
+ `${upDown} choose · ${formatKeyHint("enter")} edit · ${editorKey("tui.select.cancel")} cancel`,
1319
+ ),
1320
+ );
1299
1321
  }
1300
1322
  return lines.slice(0, Math.max(0, Math.floor(maxRows)));
1301
1323
  }
@@ -1315,7 +1337,11 @@ export class PlanReviewOverlay implements Component {
1315
1337
  innerWidth,
1316
1338
  Ellipsis.Unicode,
1317
1339
  );
1318
- const hintParts = ["enter save", "shift+enter newline", "esc cancel"];
1340
+ const hintParts = [
1341
+ `${editorKey("tui.input.submit")} save`,
1342
+ `${editorKey("tui.input.newLine")} newline`,
1343
+ `${editorKey("tui.select.cancel")} cancel`,
1344
+ ];
1319
1345
  if (this.#editingAnnotation) hintParts.push("empty deletes");
1320
1346
  if (this.#externalEditorLabel) hintParts.push(`${this.#externalEditorLabel} editor`);
1321
1347
  this.#editor.setMaxHeight(Math.max(1, Math.min(MAX_ANNOTATION_EDITOR_ROWS, (process.stdout.rows || 40) - 12)));
@@ -1,6 +1,7 @@
1
1
  import { type Component, CURSOR_MARKER, type Focusable, Input, truncateToWidth, visibleWidth } from "../index";
2
2
  import { theme } from "../theme/theme";
3
3
  import { OverlayPanel, PanelRows } from "../chrome/overlay-box";
4
+ import { editorKey } from "../chrome/keybinding-hints";
4
5
 
5
6
  /** A confirmed destination chosen from {@link PlanSaveOverlay}. */
6
7
  export interface PlanSaveOverlayResult {
@@ -58,7 +59,8 @@ export class PlanSaveOverlay implements Component, Focusable {
58
59
  render(width: number): readonly string[] {
59
60
  const innerWidth = Math.max(0, width - 4);
60
61
  this.#input.focused = this.#focused;
61
- this.#body.setLines([this.#renderInput(innerWidth), theme.fg("dim", "Enter save and quit · Esc cancel")]);
62
+ const hint = `${editorKey("tui.input.submit")} save and quit · ${editorKey("tui.select.cancel")} cancel`;
63
+ this.#body.setLines([this.#renderInput(innerWidth), theme.fg("dim", hint)]);
62
64
  return this.#panel.render(width);
63
65
  }
64
66
 
@@ -23,6 +23,7 @@ import { OverlayPanel } from "../chrome/overlay-box";
23
23
  import { FormField, SelectFormField, TextFormField } from "../components/form";
24
24
  import { formTheme } from "../chrome/form-theme";
25
25
  import { SettingsFormField } from "../components/settings-list";
26
+ import { editorKey } from "../chrome/keybinding-hints";
26
27
 
27
28
  /** Setting metadata consumed by the plugin settings UI. */
28
29
  export type PluginSettingSchema = {
@@ -270,7 +271,16 @@ export class PluginListComponent extends OverlayPanel {
270
271
 
271
272
  this.addChild(this.#selectList);
272
273
  this.addChild(new Spacer(1));
273
- this.addChild(new Text(theme.fg("dim", "Enter to configure · Esc to go back"), 0, 0));
274
+ this.addChild(
275
+ new Text(
276
+ theme.fg(
277
+ "dim",
278
+ `${editorKey("tui.select.confirm")} to configure · ${editorKey("tui.select.cancel")} to go back`,
279
+ ),
280
+ 0,
281
+ 0,
282
+ ),
283
+ );
274
284
  }
275
285
 
276
286
  #renderItem(entry: PluginListEntry): SelectItem {
@@ -408,7 +418,7 @@ export class PluginDetailComponent extends OverlayPanel {
408
418
  items,
409
419
  maxVisible: Math.min(items.length, 10),
410
420
  settingsTheme: getSettingsListTheme(),
411
- hint: DETAIL_FOOTER_HINT,
421
+ hint: `${editorKey("tui.select.confirm")} to edit · ${editorKey("tui.select.cancel")} to go back`,
412
422
  onChange: (id, newValue) => {
413
423
  if (id === "__enabled__") {
414
424
  this.callbacks.onEnabledChange(newValue === "true");
@@ -540,7 +550,7 @@ export class MarketplacePluginDetailComponent extends OverlayPanel {
540
550
  maxVisible: Math.min(items.length, 10),
541
551
  settingsTheme: getSettingsListTheme(),
542
552
  summary,
543
- hint: DETAIL_FOOTER_HINT,
553
+ hint: `${editorKey("tui.select.confirm")} to edit · ${editorKey("tui.select.cancel")} to go back`,
544
554
  onChange: (id, newValue) => {
545
555
  if (id === "__enabled__") {
546
556
  const next = newValue === "true";
@@ -571,9 +581,6 @@ export class MarketplacePluginDetailComponent extends OverlayPanel {
571
581
  // Config Submenus
572
582
  // =============================================================================
573
583
 
574
- /** Shared footer hint for plugin detail lists. */
575
- const DETAIL_FOOTER_HINT = "Enter to edit · Esc to go back";
576
-
577
584
  /**
578
585
  * Thin OverlayPanel boundary around a shared form field. The panel title stays
579
586
  * the setting key; input, paste, focus, and mouse all forward to the field.
@@ -631,7 +638,7 @@ function createConfigEnumPanel(
631
638
  currentValue,
632
639
  maxVisible: 8,
633
640
  selectTheme: getSelectListTheme(),
634
- hint: "Enter to select · Esc to cancel",
641
+ hint: `${editorKey("tui.select.confirm")} to select · ${editorKey("tui.select.cancel")} to cancel`,
635
642
  onSubmit: onSelect,
636
643
  onCancel,
637
644
  requestRender,
@@ -660,7 +667,7 @@ function createConfigInputPanel(
660
667
  secret: schema.secret,
661
668
  initialValue: !schema.secret ? currentValue : undefined,
662
669
  empty: "cancel",
663
- hint: "Enter to save · Esc to cancel",
670
+ hint: `${editorKey("tui.input.submit")} to save · ${editorKey("tui.select.cancel")} to cancel`,
664
671
  onSubmit,
665
672
  onCancel,
666
673
  requestRender,
@@ -7,6 +7,8 @@ import { OverlayPanel } from "../chrome/overlay-box";
7
7
  import { MenuSelection } from "../components/menu-selection";
8
8
  import { centeredViewportRange } from "../components/scroll-viewport";
9
9
  import { formatUsageResetWindow } from "./usage-display";
10
+ import { formatKeyHint } from "../app-keybindings";
11
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
10
12
 
11
13
  const RESET_SELECTOR_MAX_VISIBLE = 10;
12
14
 
@@ -142,7 +144,10 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
142
144
  const pending = items.find(item => this.#menu.isPending(item));
143
145
  const hint = pending
144
146
  ? theme.fg("warning", oneLine(this.#confirmationMessage(pending)))
145
- : theme.fg("muted", "↑/↓ select · ↵ spend a reset · Esc cancel");
147
+ : theme.fg(
148
+ "muted",
149
+ `${editorKeys("tui.select.up", "tui.select.down")} select · ${formatKeyHint("enter")} spend a reset · ${editorKey("tui.select.cancel")} cancel`,
150
+ );
146
151
  this.#listContainer.addChild(new Text(hint, 0, 0));
147
152
 
148
153
  if (this.#statusMessage) {
@@ -153,7 +158,9 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
153
158
 
154
159
  #confirmationMessage(account: ResetUsageAccount): string {
155
160
  const subject = account.credit?.title ? `“${account.credit.title}”` : "1 saved reset";
156
- const messages = [`Press Enter again to spend ${subject} for ${account.label} (${account.providerLabel}).`];
161
+ const messages = [
162
+ `Press ${formatKeyHint("enter")} again to spend ${subject} for ${account.label} (${account.providerLabel}).`,
163
+ ];
157
164
  if (account.credit?.program === "juniper_tide") {
158
165
  messages.push("This resets Claude's 5h session limit only; weekly limits stay unchanged.");
159
166
  } else if (account.credit?.clears?.length) {
@@ -162,7 +169,7 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
162
169
  if (account.credit?.requiresLimit === false) {
163
170
  messages.push("Optional early use: this can be spent before the covered limit is reached.");
164
171
  }
165
- messages.push("Esc cancels.");
172
+ messages.push(`${editorKey("tui.select.cancel")} cancels.`);
166
173
  return messages.join(" ");
167
174
  }
168
175