@oh-my-pi/pi-tui 18.3.2 → 18.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/types/app-keybindings.d.ts +2 -17
  3. package/dist/types/autocomplete.d.ts +18 -3
  4. package/dist/types/chrome/keybinding-hints.d.ts +16 -10
  5. package/dist/types/components/editor.d.ts +11 -0
  6. package/dist/types/key-hint-format.d.ts +29 -0
  7. package/dist/types/keys.d.ts +2 -1
  8. package/dist/types/overlays/agent-hub.d.ts +1 -1
  9. package/dist/types/overlays/agent-transcript-viewer.d.ts +1 -1
  10. package/dist/types/overlays/model-picker.d.ts +1 -3
  11. package/dist/types/prompt/composer-hints.d.ts +42 -0
  12. package/dist/types/prompt/composer.d.ts +2 -1
  13. package/dist/types/prompt/custom-editor.d.ts +8 -2
  14. package/dist/types/prompt/macos-spelling.d.ts +7 -16
  15. package/dist/types/prompt/prompt-action-autocomplete.d.ts +4 -1
  16. package/dist/types/prompt/prose-gate.d.ts +24 -0
  17. package/dist/types/prompt/word-completion.d.ts +46 -0
  18. package/dist/types/render/render-utils.d.ts +1 -1
  19. package/dist/types/theme/active-symbols.d.ts +12 -0
  20. package/dist/types/theme/symbols.d.ts +1 -1
  21. package/dist/types/theme/theme-class.d.ts +0 -1
  22. package/dist/types/tools/eval.d.ts +1 -1
  23. package/dist/types/tools/mcp.d.ts +3 -1
  24. package/dist/types/tools/task-yield-assembly.d.ts +10 -2
  25. package/dist/types/tools/task.d.ts +4 -0
  26. package/package.json +11 -10
  27. package/src/app-keybindings.ts +11 -104
  28. package/src/apps/autoresearch-dashboard.ts +10 -3
  29. package/src/apps/debug/log-viewer.ts +3 -2
  30. package/src/apps/debug/raw-sse.ts +2 -1
  31. package/src/apps/git/git-tui.ts +9 -5
  32. package/src/apps/live-visualizer.ts +2 -1
  33. package/src/apps/ps-top.ts +13 -4
  34. package/src/autocomplete.ts +42 -7
  35. package/src/chat/compaction-summary-message.ts +2 -1
  36. package/src/chat/execution-shared.ts +4 -2
  37. package/src/chat/tool-execution.ts +3 -1
  38. package/src/chat/ttsr-notification.ts +4 -3
  39. package/src/chat/user-message.ts +2 -2
  40. package/src/chrome/keybinding-hints.ts +38 -13
  41. package/src/components/editor.ts +180 -58
  42. package/src/components/settings-list.ts +10 -3
  43. package/src/components/tab-bar.ts +2 -1
  44. package/src/debug-server.ts +4 -1
  45. package/src/hotkeys-markdown.ts +14 -17
  46. package/src/key-hint-format.ts +122 -0
  47. package/src/keys.ts +2 -1
  48. package/src/overlays/advisor-config.ts +28 -6
  49. package/src/overlays/agent-hub.ts +8 -5
  50. package/src/overlays/agent-transcript-viewer.ts +6 -4
  51. package/src/overlays/agents-hub.ts +22 -8
  52. package/src/overlays/annotation-overlay.ts +31 -17
  53. package/src/overlays/ask-dialog.ts +14 -19
  54. package/src/overlays/bordered-loader.ts +2 -1
  55. package/src/overlays/btw-history-panel.ts +15 -12
  56. package/src/overlays/btw-panel.ts +12 -7
  57. package/src/overlays/cleanse-panel.ts +9 -6
  58. package/src/overlays/codex-reset-fireworks.ts +5 -3
  59. package/src/overlays/copy-selector.ts +13 -5
  60. package/src/overlays/extensions/extension-dashboard.ts +6 -1
  61. package/src/overlays/history-search.ts +3 -2
  62. package/src/overlays/hook-editor.ts +7 -2
  63. package/src/overlays/hook-input.ts +2 -1
  64. package/src/overlays/hook-selector.ts +5 -1
  65. package/src/overlays/login-dialog.ts +17 -4
  66. package/src/overlays/logout-account-selector.ts +10 -1
  67. package/src/overlays/mcp-add-wizard.ts +32 -20
  68. package/src/overlays/model-hub.ts +55 -28
  69. package/src/overlays/model-picker.ts +27 -12
  70. package/src/overlays/move-overlay.ts +5 -1
  71. package/src/overlays/omfg-panel.ts +8 -5
  72. package/src/overlays/pause-screen.ts +7 -3
  73. package/src/overlays/plan-review-overlay.ts +40 -14
  74. package/src/overlays/plan-save-overlay.ts +3 -1
  75. package/src/overlays/plugin-settings.ts +15 -8
  76. package/src/overlays/reset-usage-selector.ts +10 -3
  77. package/src/overlays/rewind-selector.ts +11 -5
  78. package/src/overlays/session-info-overlay.ts +4 -3
  79. package/src/overlays/session-selector.ts +12 -2
  80. package/src/overlays/settings-selector.ts +18 -11
  81. package/src/overlays/tree-selector.ts +27 -4
  82. package/src/overlays/usage-dashboard.ts +8 -2
  83. package/src/prompt/composer-cache.ts +2 -1
  84. package/src/prompt/composer-hints.ts +75 -0
  85. package/src/prompt/composer.ts +3 -2
  86. package/src/prompt/custom-editor.ts +22 -4
  87. package/src/prompt/macos-spelling.ts +17 -158
  88. package/src/prompt/prompt-action-autocomplete.ts +9 -2
  89. package/src/prompt/prose-gate.ts +87 -0
  90. package/src/prompt/tips.txt +6 -6
  91. package/src/prompt/welcome.ts +35 -2
  92. package/src/prompt/word-completion.ts +205 -0
  93. package/src/render/render-utils.ts +4 -4
  94. package/src/setup/scenes/composer.ts +5 -1
  95. package/src/setup/scenes/model.ts +3 -1
  96. package/src/setup/scenes/providers.ts +4 -1
  97. package/src/setup/scenes/sign-in.ts +4 -2
  98. package/src/setup/scenes/splash.ts +9 -4
  99. package/src/setup/scenes/theme.ts +18 -5
  100. package/src/setup/wizard-overlay.ts +5 -4
  101. package/src/theme/active-symbols.ts +20 -0
  102. package/src/theme/glyph-bundle.json +28 -1
  103. package/src/theme/symbols.ts +88 -6
  104. package/src/theme/theme-class.ts +0 -1
  105. package/src/theme/theme.ts +2 -0
  106. package/src/tools/bash-interactive.ts +2 -1
  107. package/src/tools/eval.ts +3 -3
  108. package/src/tools/mcp.ts +3 -1
  109. package/src/tools/task-yield-assembly.ts +34 -8
  110. package/src/tools/task.ts +14 -5
  111. package/dist/types/overlays/tiny-title-download-progress.d.ts +0 -21
  112. package/src/overlays/tiny-title-download-progress.ts +0 -102
@@ -42,6 +42,9 @@ import { matchesAppToolsExpand, matchesSelectCancel, matchesSelectDown, matchesS
42
42
  import { ChatTranscriptBuilder } from "../chat/chat-transcript-builder";
43
43
  import { TranscriptBrowser, type TranscriptBrowserFrame } from "../chat/transcript-browser";
44
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";
45
48
  import {
46
49
  appendOutlineEntries,
47
50
  type ComposedColumn,
@@ -486,16 +489,17 @@ export class RewindSelectorComponent implements Component {
486
489
  prepared,
487
490
  }).column;
488
491
  const position = this.#targets.length > 0 ? `${this.#selected + 1}/${this.#targets.length} ` : "";
489
- 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`;
490
496
  return {
491
497
  header: [this.#header()],
492
498
  body: {
493
499
  lines: composed.lines,
494
500
  anchor: this.#outlineAnchor(composed),
495
501
  },
496
- footer: [
497
- theme.fg("dim", `${position}↑/↓ step ${lateral} f filter enter rewind ctrl+o expand esc cancel`),
498
- ],
502
+ footer: [theme.fg("dim", `${position}${keys}`)],
499
503
  };
500
504
  }
501
505
 
@@ -525,6 +529,8 @@ export class RewindSelectorComponent implements Component {
525
529
  matches.length === 0
526
530
  ? theme.fg("error", "no matches")
527
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`;
528
534
  return {
529
535
  header: [this.#header()],
530
536
  body: {
@@ -539,7 +545,7 @@ export class RewindSelectorComponent implements Component {
539
545
  : undefined,
540
546
  },
541
547
  footer: [
542
- `${theme.fg("accent", "filter:")} ${query}${theme.fg("accent", "▏")} ${count} ${theme.fg("dim", "↑/↓ step ←/→ user turns enter rewind esc show all")}`,
548
+ `${theme.fg("accent", "filter:")} ${query}${theme.fg("accent", "▏")} ${count} ${theme.fg("dim", keys)}`,
543
549
  ],
544
550
  };
545
551
  }
@@ -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
 
@@ -26,11 +26,18 @@ import {
26
26
  skillChipStyle,
27
27
  skillToken,
28
28
  } from "./composer-attachments";
29
- import { MacOSSpellingProvider, type SpellingFeatures } from "./macos-spelling";
29
+ import { type MacOSSpellingFeatures, MacOSSpellingProvider } from "./macos-spelling";
30
30
  import { hasMagicKeyword, highlightMagicKeywords } from "./magic-keywords";
31
31
  import { isQueuedMessageList, parseQueueShorthand, QUEUE_LIST_MARKER_RE } from "./queue-input";
32
+ import { type WordCompletionMethod, WordCompletionProvider } from "./word-completion";
32
33
  import { fgOrPlain, theme } from "../theme/theme";
33
34
 
35
+ /** Independently switchable prose-assistance features of the composer. */
36
+ export interface SpellingFeatures extends MacOSSpellingFeatures {
37
+ /** Word-completion engine; `off` disables ghost text. */
38
+ autocomplete: WordCompletionMethod;
39
+ }
40
+
34
41
  type ConfigurableEditorAction = Extract<
35
42
  AppKeybinding,
36
43
  | "app.interrupt"
@@ -374,6 +381,7 @@ export type ComposerChipDescriptor =
374
381
  */
375
382
  export class CustomEditor extends Editor {
376
383
  #spelling = new MacOSSpellingProvider();
384
+ #wordCompletion = new WordCompletionProvider();
377
385
  imageLinks?: readonly (string | undefined)[];
378
386
 
379
387
  /** Draft images pasted into the composer, consumed on submit. Co-located with
@@ -432,14 +440,24 @@ export class CustomEditor extends Editor {
432
440
  this.#requestShimmerRepaint?.();
433
441
  };
434
442
  this.#spelling.onUpdate = requestTextAssistRepaint;
443
+ this.#wordCompletion.onUpdate = requestTextAssistRepaint;
435
444
  this.onTextAssistApplied = requestTextAssistRepaint;
436
- this.setTextAssistProvider(this.#spelling);
445
+ this.setTextAssistProvider({
446
+ getWordCompletion: (lines, cursorLine, cursorCol) =>
447
+ this.#wordCompletion.getWordCompletion(lines, cursorLine, cursorCol),
448
+ wordCompletionFeedback: (lines, cursorLine, cursorCol, suggestion, accepted) =>
449
+ this.#wordCompletion.wordCompletionFeedback(lines, cursorLine, cursorCol, suggestion, accepted),
450
+ tryAutocorrect: (lines, cursorLine, cursorCol) => this.#spelling.tryAutocorrect(lines, cursorLine, cursorCol),
451
+ getWordReplacements: (lines, cursorLine, cursorCol) =>
452
+ this.#spelling.getWordReplacements(lines, cursorLine, cursorCol),
453
+ });
437
454
  if (args[0] instanceof TUI) this.tui = args[0];
438
455
  }
439
456
 
440
- /** Independently configure typo detection, word autocomplete, and autocorrect. */
457
+ /** Independently configure typo detection, the word-completion engine, and autocorrect. */
441
458
  setSpellingFeatures(features: SpellingFeatures): void {
442
- this.#spelling.setFeatures(features);
459
+ this.#spelling.setFeatures({ typoDetection: features.typoDetection, autocorrect: features.autocorrect });
460
+ this.#wordCompletion.setMethod(features.autocomplete);
443
461
  }
444
462
 
445
463
  /** Clear the composer draft: optionally commit `historyText` to history, then