@oh-my-pi/pi-tui 18.3.1 → 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 (116) 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/overlays/rewind-selector.d.ts +4 -2
  12. package/dist/types/prompt/composer-hints.d.ts +42 -0
  13. package/dist/types/prompt/composer.d.ts +2 -1
  14. package/dist/types/prompt/custom-editor.d.ts +8 -2
  15. package/dist/types/prompt/macos-spelling.d.ts +7 -16
  16. package/dist/types/prompt/prompt-action-autocomplete.d.ts +4 -1
  17. package/dist/types/prompt/prose-gate.d.ts +24 -0
  18. package/dist/types/prompt/word-completion.d.ts +46 -0
  19. package/dist/types/render/render-utils.d.ts +1 -1
  20. package/dist/types/status-line/host.d.ts +1 -1
  21. package/dist/types/theme/active-symbols.d.ts +12 -0
  22. package/dist/types/theme/symbols.d.ts +1 -1
  23. package/dist/types/theme/theme-class.d.ts +0 -1
  24. package/dist/types/tools/eval.d.ts +1 -1
  25. package/dist/types/tools/mcp.d.ts +3 -1
  26. package/dist/types/tools/task-yield-assembly.d.ts +10 -2
  27. package/dist/types/tools/task.d.ts +4 -0
  28. package/package.json +11 -10
  29. package/src/app-keybindings.ts +11 -104
  30. package/src/apps/autoresearch-dashboard.ts +10 -3
  31. package/src/apps/debug/log-viewer.ts +3 -2
  32. package/src/apps/debug/raw-sse.ts +2 -1
  33. package/src/apps/git/git-tui.ts +9 -5
  34. package/src/apps/live-visualizer.ts +2 -1
  35. package/src/apps/ps-top.ts +13 -4
  36. package/src/autocomplete.ts +42 -7
  37. package/src/chat/compaction-summary-message.ts +2 -1
  38. package/src/chat/execution-shared.ts +4 -2
  39. package/src/chat/tool-execution.ts +3 -1
  40. package/src/chat/ttsr-notification.ts +4 -3
  41. package/src/chat/user-message.ts +2 -2
  42. package/src/chrome/keybinding-hints.ts +38 -13
  43. package/src/components/editor.ts +180 -58
  44. package/src/components/settings-list.ts +10 -3
  45. package/src/components/tab-bar.ts +2 -1
  46. package/src/debug-server.ts +4 -1
  47. package/src/hotkeys-markdown.ts +14 -17
  48. package/src/key-hint-format.ts +122 -0
  49. package/src/keys.ts +2 -1
  50. package/src/overlays/advisor-config.ts +28 -6
  51. package/src/overlays/agent-hub.ts +8 -5
  52. package/src/overlays/agent-transcript-viewer.ts +6 -4
  53. package/src/overlays/agents-hub.ts +22 -8
  54. package/src/overlays/annotation-overlay.ts +31 -17
  55. package/src/overlays/ask-dialog.ts +14 -19
  56. package/src/overlays/bordered-loader.ts +2 -1
  57. package/src/overlays/btw-history-panel.ts +15 -12
  58. package/src/overlays/btw-panel.ts +12 -7
  59. package/src/overlays/cleanse-panel.ts +9 -6
  60. package/src/overlays/codex-reset-fireworks.ts +5 -3
  61. package/src/overlays/copy-selector.ts +13 -5
  62. package/src/overlays/extensions/extension-dashboard.ts +6 -1
  63. package/src/overlays/history-search.ts +3 -2
  64. package/src/overlays/hook-editor.ts +7 -2
  65. package/src/overlays/hook-input.ts +2 -1
  66. package/src/overlays/hook-selector.ts +5 -1
  67. package/src/overlays/login-dialog.ts +17 -4
  68. package/src/overlays/logout-account-selector.ts +10 -1
  69. package/src/overlays/mcp-add-wizard.ts +32 -20
  70. package/src/overlays/model-hub.ts +66 -27
  71. package/src/overlays/model-picker.ts +27 -12
  72. package/src/overlays/move-overlay.ts +5 -1
  73. package/src/overlays/omfg-panel.ts +8 -5
  74. package/src/overlays/pause-screen.ts +7 -3
  75. package/src/overlays/plan-review-overlay.ts +40 -14
  76. package/src/overlays/plan-save-overlay.ts +3 -1
  77. package/src/overlays/plugin-settings.ts +15 -8
  78. package/src/overlays/reset-usage-selector.ts +10 -3
  79. package/src/overlays/rewind-selector.ts +193 -13
  80. package/src/overlays/session-info-overlay.ts +4 -3
  81. package/src/overlays/session-selector.ts +12 -2
  82. package/src/overlays/settings-selector.ts +18 -11
  83. package/src/overlays/tree-selector.ts +27 -4
  84. package/src/overlays/usage-dashboard.ts +8 -2
  85. package/src/prompt/composer-cache.ts +2 -1
  86. package/src/prompt/composer-hints.ts +75 -0
  87. package/src/prompt/composer.ts +3 -2
  88. package/src/prompt/custom-editor.ts +22 -4
  89. package/src/prompt/macos-spelling.ts +17 -158
  90. package/src/prompt/prompt-action-autocomplete.ts +9 -2
  91. package/src/prompt/prose-gate.ts +87 -0
  92. package/src/prompt/tips.txt +6 -6
  93. package/src/prompt/welcome.ts +35 -2
  94. package/src/prompt/word-completion.ts +205 -0
  95. package/src/render/render-utils.ts +4 -4
  96. package/src/setup/scenes/composer.ts +5 -1
  97. package/src/setup/scenes/model.ts +3 -1
  98. package/src/setup/scenes/providers.ts +4 -1
  99. package/src/setup/scenes/sign-in.ts +4 -2
  100. package/src/setup/scenes/splash.ts +9 -4
  101. package/src/setup/scenes/theme.ts +18 -5
  102. package/src/setup/wizard-overlay.ts +5 -4
  103. package/src/status-line/host.ts +1 -1
  104. package/src/status-line/segments.ts +2 -2
  105. package/src/theme/active-symbols.ts +20 -0
  106. package/src/theme/glyph-bundle.json +28 -1
  107. package/src/theme/symbols.ts +88 -6
  108. package/src/theme/theme-class.ts +0 -1
  109. package/src/theme/theme.ts +2 -0
  110. package/src/tools/bash-interactive.ts +2 -1
  111. package/src/tools/eval.ts +3 -3
  112. package/src/tools/mcp.ts +3 -1
  113. package/src/tools/task-yield-assembly.ts +34 -8
  114. package/src/tools/task.ts +14 -5
  115. package/dist/types/overlays/tiny-title-download-progress.d.ts +0 -21
  116. package/src/overlays/tiny-title-download-progress.ts +0 -102
@@ -19,12 +19,15 @@
19
19
  *
20
20
  * Keys: Up/Down step through rendered items in transcript order (within the
21
21
  * active column when a strip is open), Left/Right slide between branch
22
- * variants at a fork and jump between user turns elsewhere, Enter rewinds to
23
- * the outlined item, Esc cancels.
22
+ * variants at a fork and jump between user turns elsewhere, `f` opens a
23
+ * filter (typing narrows the current path to matching items; Esc leaves the
24
+ * filter with the selection kept), Enter rewinds to the outlined item, Esc
25
+ * cancels.
24
26
  */
25
27
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
26
28
  import {
27
29
  type Component,
30
+ extractPrintableText,
28
31
  matchesKey,
29
32
  padding,
30
33
  routeSgrMouseInput,
@@ -39,6 +42,9 @@ import { matchesAppToolsExpand, matchesSelectCancel, matchesSelectDown, matchesS
39
42
  import { ChatTranscriptBuilder } from "../chat/chat-transcript-builder";
40
43
  import { TranscriptBrowser, type TranscriptBrowserFrame } from "../chat/transcript-browser";
41
44
  import { padToWidth } from "../render/utils";
45
+ import { expandKeyHint } from "../render/render-utils";
46
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
47
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
42
48
  import {
43
49
  appendOutlineEntries,
44
50
  type ComposedColumn,
@@ -110,6 +116,13 @@ export class RewindSelectorComponent implements Component {
110
116
  #slide: { from: number; to: number; startedAt: number } | undefined;
111
117
  #slideTimer: NodeJS.Timeout | undefined;
112
118
 
119
+ /** Filter query while the filter prompt is open; undefined shows the full transcript. */
120
+ #filter: string | undefined;
121
+ /** Main transcript rows from the last frame; the filter matches what is on screen. */
122
+ #mainRows: readonly (readonly string[])[] = [];
123
+ /** Lowercased plain text per rendered child row array (row arrays are cached, so identity is stable). */
124
+ #rowText = new WeakMap<readonly string[], string>();
125
+
113
126
  constructor(
114
127
  entries: TranscriptEntry[],
115
128
  private readonly deps: RewindSelectorDeps,
@@ -235,19 +248,26 @@ export class RewindSelectorComponent implements Component {
235
248
  });
236
249
  return;
237
250
  }
251
+ if (this.#filter !== undefined) {
252
+ this.#handleFilterInput(data);
253
+ return;
254
+ }
238
255
  if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
239
256
  this.deps.onCancel();
240
257
  return;
241
258
  }
242
- if (matchesAppToolsExpand(data)) {
243
- this.#expanded = !this.#expanded;
244
- this.#builder.setExpanded(this.#expanded);
245
- for (const columns of this.#variantCache.values()) {
246
- for (const column of columns) column.builder.setExpanded(this.#expanded);
247
- }
259
+ if (matchesKey(data, "f")) {
260
+ this.#filter = "";
261
+ this.#activeVariant = 0;
262
+ this.#siblingSelected = 0;
263
+ this.#stopSlide();
248
264
  this.deps.requestRender();
249
265
  return;
250
266
  }
267
+ if (matchesAppToolsExpand(data)) {
268
+ this.#toggleExpanded();
269
+ return;
270
+ }
251
271
  if (matchesSelectUp(data)) {
252
272
  this.#moveVertical(-1);
253
273
  return;
@@ -282,6 +302,115 @@ export class RewindSelectorComponent implements Component {
282
302
  }
283
303
  }
284
304
 
305
+ #toggleExpanded(): void {
306
+ this.#expanded = !this.#expanded;
307
+ this.#builder.setExpanded(this.#expanded);
308
+ for (const columns of this.#variantCache.values()) {
309
+ for (const column of columns) column.builder.setExpanded(this.#expanded);
310
+ }
311
+ this.deps.requestRender();
312
+ }
313
+
314
+ // ========================================================================
315
+ // Filter
316
+ // ========================================================================
317
+
318
+ #handleFilterInput(data: string): void {
319
+ if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
320
+ this.#closeFilter();
321
+ return;
322
+ }
323
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
324
+ const target = this.#filterMatches().includes(this.#selected) ? this.#targets[this.#selected] : undefined;
325
+ if (target) this.deps.onSelect(target.entryId);
326
+ return;
327
+ }
328
+ if (matchesAppToolsExpand(data)) {
329
+ this.#toggleExpanded();
330
+ return;
331
+ }
332
+ if (matchesSelectUp(data) || matchesKey(data, "left")) {
333
+ const userTurnsOnly = !matchesSelectUp(data);
334
+ this.#stepFiltered(-1, userTurnsOnly);
335
+ return;
336
+ }
337
+ if (matchesSelectDown(data) || matchesKey(data, "right")) {
338
+ const userTurnsOnly = !matchesSelectDown(data);
339
+ this.#stepFiltered(1, userTurnsOnly);
340
+ return;
341
+ }
342
+ if (matchesKey(data, "backspace")) {
343
+ if (this.#filter!.length === 0) {
344
+ this.#closeFilter();
345
+ return;
346
+ }
347
+ this.#setFilter(this.#filter!.slice(0, -1));
348
+ return;
349
+ }
350
+ const printable = extractPrintableText(data);
351
+ if (printable) {
352
+ this.#setFilter(this.#filter! + printable);
353
+ return;
354
+ }
355
+ if (this.#browser.handleScrollKey(data)) {
356
+ this.deps.requestRender();
357
+ }
358
+ }
359
+
360
+ /** Leave the filter, keeping the selected item outlined in the full transcript. */
361
+ #closeFilter(): void {
362
+ this.#filter = undefined;
363
+ this.deps.requestRender();
364
+ }
365
+
366
+ /** Update the query; keep the selection when it still matches, else rest on the newest match above it. */
367
+ #setFilter(query: string): void {
368
+ this.#filter = query;
369
+ const matches = this.#filterMatches();
370
+ if (!matches.includes(this.#selected)) {
371
+ this.#selected = matches.findLast(index => index < this.#selected) ?? matches.at(-1) ?? this.#selected;
372
+ }
373
+ this.deps.requestRender();
374
+ }
375
+
376
+ #stepFiltered(delta: -1 | 1, userTurnsOnly: boolean): void {
377
+ const matches = this.#filterMatches().filter(index => !userTurnsOnly || this.#targets[index]!.isUserTurn);
378
+ const next =
379
+ delta < 0 ? matches.findLast(index => index < this.#selected) : matches.find(index => index > this.#selected);
380
+ if (next === undefined) return;
381
+ this.#selected = next;
382
+ this.deps.requestRender();
383
+ }
384
+
385
+ /**
386
+ * Visible main-path target indices whose rendered text contains every
387
+ * whitespace-separated query word as a whole word (case-insensitive: `ls`
388
+ * matches `ls -la` but not `tools`); all visible targets for an empty query.
389
+ * Matching the rendered rows keeps results honest: collapsed tool output
390
+ * only matches once Ctrl+O expands it.
391
+ */
392
+ #filterMatches(): number[] {
393
+ const words = (this.#filter ?? "").toLowerCase().split(/\s+/).filter(Boolean);
394
+ const patterns = words.map(
395
+ word => new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u"),
396
+ );
397
+ const matches: number[] = [];
398
+ for (let index = 0; index < this.#targets.length; index++) {
399
+ if (!this.#isMainSelectable(index)) continue;
400
+ const target = this.#targets[index]!;
401
+ const texts = this.#mainRows.slice(target.start, target.end).map(rows => {
402
+ let text = this.#rowText.get(rows);
403
+ if (text === undefined) {
404
+ text = Bun.stripANSI(rows.join("\n")).toLowerCase();
405
+ this.#rowText.set(rows, text);
406
+ }
407
+ return text;
408
+ });
409
+ if (patterns.every(pattern => texts.some(text => pattern.test(text)))) matches.push(index);
410
+ }
411
+ return matches;
412
+ }
413
+
285
414
  /** Up/Down: step within the active column; leaving a sibling column's top exits the strip. */
286
415
  #moveVertical(delta: -1 | 1): void {
287
416
  if (this.#activeVariant > 0) {
@@ -337,6 +466,7 @@ export class RewindSelectorComponent implements Component {
337
466
  const children = this.#builder.container.children;
338
467
  const prepared = this.#browser.prepareOutline(children, this.#targets, this.#selected, contentWidth);
339
468
  this.#mainVisible = prepared.visible;
469
+ this.#mainRows = prepared.childRows;
340
470
  if (prepared.selected !== this.#selected) {
341
471
  // The current target collapsed (e.g. expansion toggle): rest on the
342
472
  // nearest visible one and leave the strip.
@@ -345,6 +475,8 @@ export class RewindSelectorComponent implements Component {
345
475
  this.#siblingSelected = 0;
346
476
  }
347
477
 
478
+ if (this.#filter !== undefined) return this.#filterFrame(this.#filter, prepared.childRows, contentWidth);
479
+
348
480
  const columns = this.#stripColumns();
349
481
  const composed =
350
482
  columns.length > 0
@@ -357,16 +489,64 @@ export class RewindSelectorComponent implements Component {
357
489
  prepared,
358
490
  }).column;
359
491
  const position = this.#targets.length > 0 ? `${this.#selected + 1}/${this.#targets.length} ` : "";
360
- const lateral = columns.length > 0 ? "←/→ branches" : "←/→ user turns";
492
+ const upDown = editorKeys("tui.select.up", "tui.select.down");
493
+ const leftRight = formatKeyHints(["left", "right"]);
494
+ const lateral = columns.length > 0 ? `${leftRight} branches` : `${leftRight} user turns`;
495
+ const keys = `${upDown} step ${lateral} ${formatKeyHint("f")} filter ${formatKeyHint("enter")} rewind ${expandKeyHint()} expand ${editorKey("tui.select.cancel")} cancel`;
361
496
  return {
362
- header: [
363
- `${theme.icon.rewind} ${theme.bold("Rewind")}${theme.sep.dot}${theme.fg("dim", "pick the point to continue from")}`,
364
- ],
497
+ header: [this.#header()],
365
498
  body: {
366
499
  lines: composed.lines,
367
500
  anchor: this.#outlineAnchor(composed),
368
501
  },
369
- footer: [theme.fg("dim", `${position}↑/↓ step ${lateral} enter rewind ctrl+o expand esc cancel`)],
502
+ footer: [theme.fg("dim", `${position}${keys}`)],
503
+ };
504
+ }
505
+
506
+ #header(): string {
507
+ return `${theme.icon.rewind} ${theme.bold("Rewind")}${theme.sep.dot}${theme.fg("dim", "pick the point to continue from")}`;
508
+ }
509
+
510
+ /** Only the matching items of the current path, concatenated; no branch strip. */
511
+ #filterFrame(
512
+ query: string,
513
+ childRows: readonly (readonly string[])[],
514
+ contentWidth: number,
515
+ ): TranscriptBrowserFrame {
516
+ const matches = this.#filterMatches();
517
+ const rows: (readonly string[])[] = [];
518
+ const targets: OutlineTarget[] = [];
519
+ for (const index of matches) {
520
+ const target = this.#targets[index]!;
521
+ const start = rows.length;
522
+ rows.push(...childRows.slice(target.start, target.end));
523
+ targets.push({ ...target, start, end: rows.length });
524
+ }
525
+ const selected = matches.indexOf(this.#selected);
526
+ const composed = composeOutlineColumn(rows, 0, rows.length, targets, selected, contentWidth, undefined);
527
+ const lines = matches.length > 0 ? composed.lines : [theme.fg("muted", ` No items match "${query}"`)];
528
+ const count =
529
+ matches.length === 0
530
+ ? theme.fg("error", "no matches")
531
+ : theme.fg("dim", `${selected >= 0 ? selected + 1 : "-"}/${matches.length}`);
532
+ const upDown = editorKeys("tui.select.up", "tui.select.down");
533
+ const keys = `${upDown} step ${formatKeyHints(["left", "right"])} user turns ${formatKeyHint("enter")} rewind ${editorKey("tui.select.cancel")} show all`;
534
+ return {
535
+ header: [this.#header()],
536
+ body: {
537
+ lines,
538
+ anchor:
539
+ composed.selStart >= 0
540
+ ? {
541
+ id: `rewind:filter:${query}:${this.#targets[this.#selected]?.turnId ?? this.#selected}`,
542
+ start: composed.selStart,
543
+ end: composed.selEnd,
544
+ }
545
+ : undefined,
546
+ },
547
+ footer: [
548
+ `${theme.fg("accent", "filter:")} ${query}${theme.fg("accent", "▏")} ${count} ${theme.fg("dim", keys)}`,
549
+ ],
370
550
  };
371
551
  }
372
552
 
@@ -2,8 +2,8 @@ import { type Component, Ellipsis, matchesKey, ScrollView, Text, truncateToWidth
2
2
  import { theme } from "../theme/theme";
3
3
  import { matchesSelectCancel } from "../keybinding-matchers";
4
4
  import { OverlayPanel, PanelDivider, PanelRows } from "../chrome/overlay-box";
5
-
6
- const FOOTER_HINT = "↑/↓ scroll · Esc close";
5
+ import { formatKeyHints } from "../app-keybindings";
6
+ import { editorKey } from "../chrome/keybinding-hints";
7
7
  const PANEL_CHROME_ROWS = 4;
8
8
 
9
9
  /** Terminal surface needed to size the session info viewport. */
@@ -74,7 +74,8 @@ export class SessionInfoOverlay implements Component {
74
74
 
75
75
  render(width: number): readonly string[] {
76
76
  const innerWidth = Math.max(1, width - 4);
77
- this.#footer.setLines([theme.fg("dim", truncateToWidth(FOOTER_HINT, innerWidth))]);
77
+ const footerHint = `${formatKeyHints(["up", "down"])} scroll · ${editorKey("tui.select.cancel")} close`;
78
+ this.#footer.setLines([theme.fg("dim", truncateToWidth(footerHint, innerWidth))]);
78
79
 
79
80
  const maxBodyHeight = Math.max(1, this.#host.terminal.rows - PANEL_CHROME_ROWS);
80
81
  const fullWidthInfoLines = this.#info.render(innerWidth);
@@ -38,6 +38,8 @@ import { shortenPath } from "../render/render-utils";
38
38
  import { HookSelectorComponent } from "./hook-selector";
39
39
  import { bottomBorder, OverlayPanel, row, topBorder } from "../chrome/overlay-box";
40
40
  import { MenuSelection, getMenuWindow } from "../components/menu-selection";
41
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
42
+ import { interruptKey } from "../chrome/keybinding-hints";
41
43
 
42
44
  /**
43
45
  * Themed glyph + colored label for a session's lifecycle status, or `undefined`
@@ -615,7 +617,10 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
615
617
  } else {
616
618
  // "Current folder" scope - hint to try "all"
617
619
  lines.push(
618
- truncateToWidth(theme.fg("muted", "No sessions in current folder. Press Tab to view all."), width),
620
+ truncateToWidth(
621
+ theme.fg("muted", `No sessions in current folder. Press ${formatKeyHint("tab")} to view all.`),
622
+ width,
623
+ ),
619
624
  );
620
625
  }
621
626
  return lines;
@@ -1094,7 +1099,12 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1094
1099
  /** Blank · keybinding hint · bottom border. Rendered by {@link render}. */
1095
1100
  #footerLines(width: number): string[] {
1096
1101
  const scopeHint = this.#scope === "all" ? "current folder" : "all projects";
1097
- const hint = theme.fg("muted", `[Del/⌫ delete · Enter select · Tab ${scopeHint} · Esc cancel]`);
1102
+ // Keys mirror SessionList#handleInput; cancel is `app.interrupt` (raw Escape when unbound).
1103
+ const cancel = interruptKey();
1104
+ const hint = theme.fg(
1105
+ "muted",
1106
+ `[${formatKeyHints(["delete", "backspace"])} delete · ${formatKeyHint("enter")} select · ${formatKeyHint("tab")} ${scopeHint} · ${cancel} cancel]`,
1107
+ );
1098
1108
  return [row("", width), row(hint, width), row("", width), bottomBorder(width)];
1099
1109
  }
1100
1110
 
@@ -48,6 +48,8 @@ import { SnapcompactShapePreview } from "./snapcompact-shape-preview";
48
48
  import { getPreset } from "../status-line/presets";
49
49
  import { FormField, SelectFormField, TextFormField } from "../components/form";
50
50
  import { formTheme } from "../chrome/form-theme";
51
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
52
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
51
53
 
52
54
  /**
53
55
  * Free-text string setting field backed by the shared text form field.
@@ -70,7 +72,7 @@ function createSettingsTextField(
70
72
  secret,
71
73
  initialValue: currentValue || undefined,
72
74
  empty: "submit",
73
- hint: " Enter to save · Esc to cancel · Clear field to unset",
75
+ hint: ` ${editorKey("tui.input.submit")} to save · ${editorKey("tui.select.cancel")} to cancel · Clear field to unset`,
74
76
  onSubmit,
75
77
  onCancel,
76
78
  requestRender,
@@ -106,7 +108,7 @@ function createSettingsSelectField(
106
108
  onSelectionChange,
107
109
  onSubmit: onSelect,
108
110
  onCancel,
109
- hint: " Enter to select · Esc to go back",
111
+ hint: ` ${editorKey("tui.select.confirm")} to select · ${editorKey("tui.select.cancel")} to go back`,
110
112
  footer,
111
113
  requestRender,
112
114
  });
@@ -172,9 +174,10 @@ class MultiSelectSubmenu extends Container {
172
174
  this.#cursor = this.#options.findIndex(option => option.value === item.value);
173
175
  };
174
176
  this.#selectList.onCancel = this.#onClose;
177
+ const back = `${editorKey("tui.select.cancel")} to go back`;
175
178
  const hint = this.#ordered
176
- ? " Click to toggle · drag selected items to reorder · ←/→ move · 1-9 place · Esc to go back"
177
- : " Click/Enter/Space to toggle · Esc to go back";
179
+ ? ` Click to toggle · drag selected items to reorder · ${formatKeyHints(["left", "right"])} move · 1-9 place · ${back}`
180
+ : ` Click/${editorKey("tui.select.confirm")}/${formatKeyHint("space")} to toggle · ${back}`;
178
181
  this.#field = new FormField(this.#selectList, {
179
182
  theme: formTheme,
180
183
  label: this.#title,
@@ -340,7 +343,7 @@ class ProviderLimitsSubmenu extends Container {
340
343
  items,
341
344
  maxVisible: 12,
342
345
  selectTheme: getSelectListTheme(),
343
- hint: " Enter to edit provider · Esc to go back",
346
+ hint: ` ${editorKey("tui.select.confirm")} to edit provider · ${editorKey("tui.select.cancel")} to go back`,
344
347
  onSubmit: value => {
345
348
  if (value === "__clear_all") {
346
349
  this.#settings.set("providers.maxInFlightRequests", {});
@@ -369,7 +372,7 @@ class ProviderLimitsSubmenu extends Container {
369
372
  "Enter a positive number. Decimals round down. Clear the field to make this provider unlimited.",
370
373
  initialValue: limits[provider]?.toString() ?? undefined,
371
374
  empty: "submit",
372
- hint: " Enter to save · Esc to cancel · Clear field to unset",
375
+ hint: ` ${editorKey("tui.input.submit")} to save · ${editorKey("tui.select.cancel")} to cancel · Clear field to unset`,
373
376
  validate: value => {
374
377
  if (value.trim() === "") return undefined;
375
378
  const limit = Number(value.trim());
@@ -567,17 +570,21 @@ export class SettingsSelectorComponent implements Component {
567
570
  }
568
571
 
569
572
  #footerHintText(): string {
573
+ const confirm = editorKey("tui.select.confirm");
574
+ const cancel = editorKey("tui.select.cancel");
575
+ const tab = formatKeyHint("tab");
576
+ const switchTabs = `${formatKeyHints(["left", "right"])} to switch tabs`;
570
577
  if (this.#searchList) {
571
- return "Enter to change · Tab to jump tabs · Esc to exit search";
578
+ return `${confirm} to change · ${tab} to jump tabs · ${cancel} to exit search`;
572
579
  }
573
580
  if (this.#currentTabId === "plugins") {
574
- return "Tab to switch tabs · Esc to close";
581
+ return `${tab} to switch tabs · ${cancel} to close`;
575
582
  }
576
583
  if (this.#currentList?.sectionFocused) {
577
- return "↑/↓ to jump sections · Tab/Enter to settings · ←/→ to switch tabs · Esc to close";
584
+ return `${editorKeys("tui.select.up", "tui.select.down")} to jump sections · ${tab}/${confirm} to settings · ${switchTabs} · ${cancel} to close`;
578
585
  }
579
- const nav = this.#hasSectionJump ? "Tab to jump sections · ←/→ to switch tabs" : "Tab to switch tabs";
580
- return `Enter/Space to change · ${nav} · Type to search · Esc to close`;
586
+ const nav = this.#hasSectionJump ? `${tab} to jump sections · ${switchTabs}` : `${tab} to switch tabs`;
587
+ return `${confirm}/${formatKeyHint("space")} to change · ${nav} · Type to search · ${cancel} to close`;
581
588
  }
582
589
 
583
590
  /** Single-line search banner: accent icon, editable query with live cursor, right-aligned match count. */
@@ -55,6 +55,8 @@ import { canonicalizeMessage } from "../chat/thinking-display";
55
55
  import { resolveAssistantErrorPresentation } from "../chat/transcript-render-helpers";
56
56
  import { OverlayPanel, PanelDivider } from "../chrome/overlay-box";
57
57
  import { TreeView, type TreeRow } from "../components/tree-view";
58
+ import { formatKeyHint, formatKeyHints } from "../app-keybindings";
59
+ import { editorKeys, interruptKey } from "../chrome/keybinding-hints";
58
60
 
59
61
  /** Filter mode for tree display */
60
62
  type FilterMode = TreeFilterMode;
@@ -523,7 +525,9 @@ class TreeList implements Component {
523
525
  lines.push(truncateToWidth(theme.fg("muted", `(0/0)${this.#getFilterLabel()}`), width));
524
526
  } else if (this.#searchQuery.length > 0) {
525
527
  lines.push(truncateToWidth(theme.fg("muted", `No entries match search "${this.#searchQuery}"`), width));
526
- lines.push(truncateToWidth(theme.fg("muted", "Press Backspace to clear the search"), width));
528
+ lines.push(
529
+ truncateToWidth(theme.fg("muted", `Press ${formatKeyHint("backspace")} to clear the search`), width),
530
+ );
527
531
  lines.push(truncateToWidth(theme.fg("muted", `(0/${totalCount})${this.#getFilterLabel()}`), width));
528
532
  } else {
529
533
  const filterLabel = this.#getFilterLabel().trim() || "[default]";
@@ -533,7 +537,15 @@ class TreeList implements Component {
533
537
  width,
534
538
  ),
535
539
  );
536
- lines.push(truncateToWidth(theme.fg("muted", "Press Alt+A to show all, Alt+D for default"), width));
540
+ lines.push(
541
+ truncateToWidth(
542
+ theme.fg(
543
+ "muted",
544
+ `Press ${formatKeyHint("alt+a")} to show all, ${formatKeyHint("alt+d")} for default`,
545
+ ),
546
+ width,
547
+ ),
548
+ );
537
549
  lines.push(truncateToWidth(theme.fg("muted", `(0/${totalCount})${this.#getFilterLabel()}`), width));
538
550
  }
539
551
  return lines;
@@ -1010,7 +1022,8 @@ class LabelInput implements Component {
1010
1022
  const lines: string[] = [];
1011
1023
  lines.push(truncateToWidth(theme.fg("muted", "Label (empty to remove):"), width));
1012
1024
  lines.push(...this.#input.render(width));
1013
- lines.push(truncateToWidth(theme.fg("dim", "enter: save esc: cancel"), width));
1025
+ const cancel = interruptKey();
1026
+ lines.push(truncateToWidth(theme.fg("dim", `${formatKeyHint("enter")}: save ${cancel}: cancel`), width));
1014
1027
  return lines;
1015
1028
  }
1016
1029
 
@@ -1068,7 +1081,17 @@ export class TreeSelectorComponent extends OverlayPanel {
1068
1081
  new TruncatedText(
1069
1082
  theme.fg(
1070
1083
  "muted",
1071
- "Enter: switch. Alt+↑/↓: previous/next turn. PgUp/PgDn (←/→): page. Home/End: first/last item. Shift+Enter: summarize & switch. Shift+L: label. Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search",
1084
+ [
1085
+ `${formatKeyHint("enter")}: switch.`,
1086
+ `${formatKeyHints(["alt+up", "alt+down"])}: previous/next turn.`,
1087
+ `${editorKeys("tui.select.pageUp", "tui.select.pageDown")} (${formatKeyHints(["left", "right"])}): page.`,
1088
+ `${formatKeyHints(["home", "end"])}: first/last item.`,
1089
+ `${formatKeyHint("shift+enter")}: summarize & switch.`,
1090
+ `${formatKeyHint("shift+l")}: label.`,
1091
+ `${formatKeyHint("ctrl+o")}: filter.`,
1092
+ `${formatKeyHints(["alt+d", "alt+t", "alt+u", "alt+l", "alt+a"])}: filter.`,
1093
+ "Type to search",
1094
+ ].join(" "),
1072
1095
  ),
1073
1096
  0,
1074
1097
  0,
@@ -38,6 +38,8 @@ import {
38
38
  matchesSelectUp,
39
39
  } from "../keybinding-matchers";
40
40
  import { OverlayPanel, PanelDivider, PanelRows } from "../chrome/overlay-box";
41
+ import { formatKeyHint } from "../app-keybindings";
42
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
41
43
 
42
44
  /** Local calendar-day activity consumed by the usage heatmap. */
43
45
  export interface DailyActivityPoint {
@@ -725,8 +727,12 @@ export class UsageDashboardComponent implements Component {
725
727
  const checkedText = latestFetchedAt ? `checked ${formatDuration(this.#nowMs - latestFetchedAt)} ago` : "";
726
728
  const title = this.#view === "detail" ? "Usage · Details" : "Usage";
727
729
 
728
- const scrollHint = maxScroll > 0 ? "↑/↓ scroll · " : "";
729
- const hint = this.#view === "detail" ? `${scrollHint}Esc back` : `${scrollHint}↵ details · Esc close`;
730
+ const scrollHint = maxScroll > 0 ? `${editorKeys("tui.select.up", "tui.select.down")} scroll · ` : "";
731
+ const cancel = editorKey("tui.select.cancel");
732
+ const hint =
733
+ this.#view === "detail"
734
+ ? `${scrollHint}${cancel} back`
735
+ : `${scrollHint}${formatKeyHint("enter")} details · ${cancel} close`;
730
736
  this.#panel.title = title;
731
737
  this.#header.setLines([checkedText ? theme.fg("dim", checkedText) : ""]);
732
738
  this.#body.setLines(contentSource.slice(this.#scroll, this.#scroll + contentRows));
@@ -5,6 +5,7 @@ import { getComposerCacheDir } from "@oh-my-pi/pi-utils/dirs";
5
5
  import type { LspServerInfo, RecentSession } from "./welcome";
6
6
  import type { ComposerPreferences, ComposerStatusSnapshot } from "./composer";
7
7
  import type { SymbolPreset } from "../theme/theme";
8
+ import { isWordCompletionMethod } from "./word-completion";
8
9
 
9
10
  const CACHE_VERSION = 1;
10
11
  const STATUS_CACHE_VERSION = 3;
@@ -204,7 +205,7 @@ function readUiState(file: string): { preferences: ComposerPreferences; theme: C
204
205
  typeof imeSafeCursor !== "boolean" ||
205
206
  typeof autocompleteMaxVisible !== "number" ||
206
207
  typeof spellingTypoDetection !== "boolean" ||
207
- typeof spellingAutocomplete !== "boolean" ||
208
+ !isWordCompletionMethod(spellingAutocomplete) ||
208
209
  typeof spellingAutocorrect !== "boolean"
209
210
  ) {
210
211
  return undefined;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Composer hints: the right-aligned placeholder shown in the empty composer to
3
+ * surface gestures that matter right now (e.g. background agents are running).
4
+ *
5
+ * Hints are checked in priority order; the first one that applies wins. A hint
6
+ * retires for good once the user has performed its gesture
7
+ * {@link ComposerHint.learnedAfter} times — the host records each use under the
8
+ * hint's id and reports counts through {@link ComposerHintContext.uses}.
9
+ *
10
+ * Add a hint by appending to {@link COMPOSER_HINTS}, extending
11
+ * {@link ComposerHintId}, and recording the gesture where it is handled.
12
+ */
13
+ import { formatDoubleTap, formatKeyHint, type KeybindingsManager } from "../app-keybindings";
14
+ import { theme } from "../theme/theme";
15
+
16
+ /** Stable hint ids; persisted as usage-counter keys, so never rename one. */
17
+ export type ComposerHintId = "agents" | "effort";
18
+
19
+ /** Live state that hint conditions read. Built by the host on every render of the empty composer. */
20
+ export interface ComposerHintContext {
21
+ /** Subagents currently running in the background. */
22
+ runningAgents: number;
23
+ /** The view is focused on a subagent session instead of the main one. */
24
+ focusedOnAgent: boolean;
25
+ /** The viewed session already has messages. */
26
+ conversationStarted: boolean;
27
+ keybindings: KeybindingsManager;
28
+ /** Times the user has performed the gesture a hint teaches. */
29
+ uses(id: ComposerHintId): number;
30
+ }
31
+
32
+ /** A gesture tip: `key` renders highlighted, `label` describes what it does. */
33
+ export interface ComposerHint {
34
+ id: ComposerHintId;
35
+ /** Uses of the gesture after which the hint stops showing. */
36
+ learnedAfter: number;
37
+ /** Key and label when the hint applies, otherwise `undefined`. */
38
+ resolve(ctx: ComposerHintContext): { key: string; label: string } | undefined;
39
+ }
40
+
41
+ /** Registered hints, highest priority first. */
42
+ export const COMPOSER_HINTS: readonly ComposerHint[] = [
43
+ {
44
+ // Matches the ←← gesture in the input controller, which opens the agent hub
45
+ // from the main session (in a focused view it hops to the parent instead).
46
+ id: "agents",
47
+ learnedAfter: 3,
48
+ resolve: ctx => {
49
+ if (ctx.runningAgents === 0 || ctx.focusedOnAgent) return undefined;
50
+ return {
51
+ key: formatDoubleTap("left"),
52
+ label: `to see ${ctx.runningAgents} running ${ctx.runningAgents === 1 ? "agent" : "agents"}`,
53
+ };
54
+ },
55
+ },
56
+ {
57
+ id: "effort",
58
+ learnedAfter: 3,
59
+ resolve: ctx => {
60
+ if (ctx.conversationStarted) return undefined;
61
+ const key = ctx.keybindings.getKeys("app.thinking.cycle")[0];
62
+ return key ? { key: formatKeyHint(key), label: "to change thinking effort" } : undefined;
63
+ },
64
+ },
65
+ ];
66
+
67
+ /** Render the highest-priority applicable, not-yet-learned hint, if any. */
68
+ export function resolveComposerHint(ctx: ComposerHintContext): string | undefined {
69
+ for (const hint of COMPOSER_HINTS) {
70
+ if (ctx.uses(hint.id) >= hint.learnedAfter) continue;
71
+ const resolved = hint.resolve(ctx);
72
+ if (resolved) return `${theme.fg("accent", resolved.key)} ${theme.fg("dim", theme.italic(resolved.label))}`;
73
+ }
74
+ return undefined;
75
+ }
@@ -15,6 +15,7 @@ import {
15
15
  import { sliceWithWidth, truncateToWidth, visibleWidth } from "../utils";
16
16
  import { postmortem } from "@oh-my-pi/pi-utils";
17
17
  import { CustomEditor } from "./custom-editor";
18
+ import type { WordCompletionMethod } from "./word-completion";
18
19
  import { type AnimationFrame, TranscriptContainer } from "../chrome/transcript-container";
19
20
  import { type LspServerInfo, type RecentSession, WelcomeComponent } from "./welcome";
20
21
  import { ensureThemeSync, getEditorTheme, theme } from "../theme/theme";
@@ -31,7 +32,7 @@ export interface ComposerPreferences {
31
32
  readonly imeSafeCursor: boolean;
32
33
  readonly autocompleteMaxVisible: number;
33
34
  readonly spellingTypoDetection: boolean;
34
- readonly spellingAutocomplete: boolean;
35
+ readonly spellingAutocomplete: WordCompletionMethod;
35
36
  readonly spellingAutocorrect: boolean;
36
37
  }
37
38
 
@@ -45,7 +46,7 @@ export const COMPOSER_DEFAULTS: ComposerPreferences = {
45
46
  imeSafeCursor: false,
46
47
  autocompleteMaxVisible: 10,
47
48
  spellingTypoDetection: true,
48
- spellingAutocomplete: true,
49
+ spellingAutocomplete: "auto",
49
50
  spellingAutocorrect: false,
50
51
  };
51
52