@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
@@ -4,6 +4,7 @@ import type { EditorInlineReplacement, EditorTextAssistProvider, EditorWordRepla
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
5
  import { isMagicKeyword } from "./magic-keywords";
6
6
  import { maskNonProse } from "./markdown-prose";
7
+ import { isProseWord, lineContext, ProseSource, type SpellingDecorationContext } from "./prose-gate";
7
8
 
8
9
  /** Styled underline: red curly undercurl via colon-subparameter SGR (4:3 + SGR 58 color). */
9
10
  const STYLED_TYPO_MARKS = { start: "\x1b[4:3m\x1b[58:2::255:95:95m", end: "\x1b[4:0m\x1b[59m" } as const;
@@ -14,35 +15,20 @@ const STYLED_TYPO_MARKS = { start: "\x1b[4:3m\x1b[58:2::255:95:95m", end: "\x1b[
14
15
  */
15
16
  const FLAT_TYPO_MARKS = { start: "\x1b[4m", end: "\x1b[24m" } as const;
16
17
 
17
- const WORD_SUFFIX = /[\p{L}\p{M}']+$/u;
18
18
  const COMPLETED_WORD = /([\p{L}\p{M}']+)([\s.,;:!?"\])}])$/u;
19
- const CODEISH_CHARACTERS = "\\/@_=:{}[]<>";
20
- const CAMEL_CASE = /\p{Ll}\p{Lu}/u;
21
19
  const CACHE_LIMIT = 256;
22
- const MAX_SPELLING_BUFFER_LENGTH = 20_000;
23
- const MAX_SPELLING_LINE_LENGTH = 1_000;
24
20
  const WORD_BOUNDARY = /[\s.,;:!?"\])}]/u;
25
21
 
26
22
  /** Independently switchable macOS prose-assistance features. */
27
- export interface SpellingFeatures {
23
+ export interface MacOSSpellingFeatures {
28
24
  typoDetection: boolean;
29
- autocomplete: boolean;
30
25
  autocorrect: boolean;
31
26
  }
32
27
 
33
- /** Logical source location for one rendered editor segment. */
34
- export interface SpellingDecorationContext {
35
- editorText: string;
36
- lines: readonly string[];
37
- line: number;
38
- startCol: number;
39
- }
40
-
41
28
  /** Native spelling operations used by {@link MacOSSpellingProvider}. */
42
29
  export interface SpellingBackend {
43
30
  isAvailable(): boolean;
44
31
  checkSpelling(text: string): Promise<readonly native.SpellingRange[]>;
45
- completeWord(text: string, start: number, length: number): Promise<readonly string[]>;
46
32
  autocorrectWord(text: string, start: number, length: number): Promise<string | null>;
47
33
  spellingGuesses(text: string, start: number, length: number): Promise<readonly string[]>;
48
34
  }
@@ -51,41 +37,21 @@ const NATIVE_BACKEND: SpellingBackend = {
51
37
  isAvailable: () =>
52
38
  typeof native.macOSSpellCheckerAvailable === "function" &&
53
39
  typeof native.macOSCheckSpelling === "function" &&
54
- typeof native.macOSCompleteWord === "function" &&
55
40
  typeof native.macOSAutocorrectWord === "function" &&
56
41
  typeof native.macOSSpellingGuesses === "function" &&
57
42
  native.macOSSpellCheckerAvailable(),
58
43
  checkSpelling: text => native.macOSCheckSpelling(text),
59
- completeWord: (text, start, length) => native.macOSCompleteWord(text, start, length),
60
44
  autocorrectWord: (text, start, length) => native.macOSAutocorrectWord(text, start, length),
61
45
  spellingGuesses: (text, start, length) => native.macOSSpellingGuesses(text, start, length),
62
46
  };
63
47
 
64
- function tokenAt(text: string, start: number, end: number): string {
65
- let tokenStart = start;
66
- while (tokenStart > 0 && !/\s/.test(text[tokenStart - 1] ?? "")) tokenStart--;
67
- let tokenEnd = end;
68
- while (tokenEnd < text.length && !/\s/.test(text[tokenEnd] ?? "")) tokenEnd++;
69
- return text.slice(tokenStart, tokenEnd);
70
- }
71
-
72
- function isProseWord(text: string, masked: string, start: number, end: number): boolean {
73
- if (start < 0 || end <= start || end > text.length) return false;
74
- if (masked.slice(start, end).trim().length === 0) return false;
75
- const token = tokenAt(text, start, end);
76
- for (const char of token) {
77
- if (CODEISH_CHARACTERS.includes(char)) return false;
78
- }
79
- if (CAMEL_CASE.test(token) || /\d/.test(token)) return false;
80
- return !text.trimStart().startsWith("/") && !text.startsWith("->") && !text.startsWith("=>");
81
- }
82
-
83
48
  /**
84
- * Bridges Apple's spelling service into the editor's separate typo,
85
- * word-completion, and autocorrection paths.
49
+ * Bridges Apple's spelling service into the editor's typo and autocorrection
50
+ * paths. Word completion is the cross-platform `WordCompletionProvider`
51
+ * (`word-completion.ts`).
86
52
  */
87
53
  export class MacOSSpellingProvider implements EditorTextAssistProvider {
88
- #features: SpellingFeatures = { typoDetection: false, autocomplete: false, autocorrect: false };
54
+ #features: MacOSSpellingFeatures = { typoDetection: false, autocorrect: false };
89
55
  #available: boolean;
90
56
  #availabilityChecked = false;
91
57
  #cacheGeneration = 0;
@@ -93,12 +59,7 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
93
59
  #typoInFlight = new Map<string, Promise<readonly native.SpellingRange[]>>();
94
60
  #automaticTypoActive = false;
95
61
  #automaticTypoQueue = new Map<string, string>();
96
- #completionCache = new Map<string, string | null>();
97
- #completionActiveKey: string | undefined;
98
- #completionQueued: { key: string; line: string; start: number; prefix: string } | undefined;
99
- #sourceText = "";
100
- #sourceMask = "";
101
- #sourceLineOffsets: number[] = [];
62
+ #prose = new ProseSource();
102
63
  /** Underline open/close pair, chosen once from the terminal's styled-underline capability. */
103
64
  readonly #marks: { start: string; end: string };
104
65
 
@@ -113,17 +74,16 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
113
74
  this.#marks = styledUnderlines ? STYLED_TYPO_MARKS : FLAT_TYPO_MARKS;
114
75
  }
115
76
 
116
- /** Apply all three independent feature gates and invalidate rendered typo ranges. */
117
- setFeatures(features: SpellingFeatures): void {
77
+ /** Apply both independent feature gates and invalidate rendered typo ranges. */
78
+ setFeatures(features: MacOSSpellingFeatures): void {
118
79
  if (
119
80
  this.#features.typoDetection === features.typoDetection &&
120
- this.#features.autocomplete === features.autocomplete &&
121
81
  this.#features.autocorrect === features.autocorrect
122
82
  ) {
123
83
  return;
124
84
  }
125
85
  this.#features = { ...features };
126
- if (!this.#availabilityChecked && (features.typoDetection || features.autocomplete || features.autocorrect)) {
86
+ if (!this.#availabilityChecked && (features.typoDetection || features.autocorrect)) {
127
87
  this.#availabilityChecked = true;
128
88
  this.#available = typeof this.backend.isAvailable === "function" && this.backend.isAvailable();
129
89
  }
@@ -137,7 +97,7 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
137
97
  decorate: (span: string) => string = value => value,
138
98
  ): string {
139
99
  if (!this.#available || !this.#features.typoDetection || text.length === 0) return decorate(text);
140
- if (!this.#sourceRangeIsProse(context, context.startCol, context.startCol + text.length)) {
100
+ if (!this.#prose.isProse(context, context.startCol, context.startCol + text.length)) {
141
101
  return decorate(text);
142
102
  }
143
103
  const lane = `${context.line}:${context.startCol}`;
@@ -160,7 +120,7 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
160
120
  // must never re-emit already-rendered text: that doubles it on screen and
161
121
  // desyncs the rendered width from the measured width (cursor drift).
162
122
  if (range.start < cursor || end > text.length) continue;
163
- if (!this.#sourceRangeIsProse(context, context.startCol + range.start, context.startCol + end)) {
123
+ if (!this.#prose.isProse(context, context.startCol + range.start, context.startCol + end)) {
164
124
  continue;
165
125
  }
166
126
  rendered += decorate(text.slice(cursor, range.start));
@@ -170,26 +130,6 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
170
130
  return rendered + decorate(text.slice(cursor));
171
131
  }
172
132
 
173
- /** Return the cached macOS completion suffix for the word ending at the cursor. */
174
- getWordCompletion(lines: string[], cursorLine: number, cursorCol: number): string | null {
175
- if (!this.#available || !this.#features.autocomplete) return null;
176
- const line = lines[cursorLine] ?? "";
177
- if (/^[\p{L}\p{M}']/u.test(line.slice(cursorCol))) return null;
178
- const match = WORD_SUFFIX.exec(line.slice(0, cursorCol));
179
- if (!match || match[0].length < 2) return null;
180
- const start = cursorCol - match[0].length;
181
- const masked = maskNonProse(line);
182
- if (!isProseWord(line, masked, start, cursorCol)) return null;
183
- const context = this.#context(lines, cursorLine);
184
- if (!this.#sourceRangeIsProse(context, start, cursorCol)) return null;
185
-
186
- const prefix = match[0];
187
- const key = `${start}:${prefix.length}:${line}`;
188
- if (this.#completionCache.has(key)) return this.#completionCache.get(key) ?? null;
189
- this.#scheduleWordCompletion(key, line, start, prefix);
190
- return null;
191
- }
192
-
193
133
  /** Return the confident macOS correction after a completed prose word. */
194
134
  async tryAutocorrect(
195
135
  lines: string[],
@@ -207,8 +147,8 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
207
147
  const start = match.index;
208
148
  const masked = maskNonProse(textBeforeCursor);
209
149
  if (!isProseWord(textBeforeCursor, masked, start, start + word.length)) return null;
210
- const context = this.#context(lines, cursorLine);
211
- if (!this.#sourceRangeIsProse(context, start, start + word.length)) return null;
150
+ const context = lineContext(lines, cursorLine);
151
+ if (!this.#prose.isProse(context, start, start + word.length)) return null;
212
152
  try {
213
153
  const correction = await this.backend.autocorrectWord(textBeforeCursor, start, word.length);
214
154
  if (!correction || correction === word) return null;
@@ -228,15 +168,15 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
228
168
  if (!this.#available || !this.#features.typoDetection) return null;
229
169
 
230
170
  const line = lines[cursorLine] ?? "";
231
- const context = this.#context(lines, cursorLine);
232
- if (!this.#sourceRangeIsProse(context, 0, line.length)) return null;
171
+ const context = lineContext(lines, cursorLine);
172
+ if (!this.#prose.isProse(context, 0, line.length)) return null;
233
173
  const ranges = this.#typoCache.get(line) ?? (await this.#loadTypoRanges(line));
234
174
  const range = ranges.find(candidate => {
235
175
  const end = candidate.start + candidate.length;
236
176
  return (
237
177
  cursorCol >= candidate.start &&
238
178
  (cursorCol <= end || (cursorCol === end + 1 && WORD_BOUNDARY.test(line[end] ?? ""))) &&
239
- this.#sourceRangeIsProse(context, candidate.start, end)
179
+ this.#prose.isProse(context, candidate.start, end)
240
180
  );
241
181
  });
242
182
  if (!range || !this.#available || !this.#features.typoDetection) return null;
@@ -365,92 +305,11 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
365
305
  return ranges;
366
306
  }
367
307
 
368
- #scheduleWordCompletion(key: string, line: string, start: number, prefix: string): void {
369
- if (this.#completionActiveKey === key || this.#completionQueued?.key === key) return;
370
- if (this.#completionActiveKey !== undefined) {
371
- this.#completionQueued = { key, line, start, prefix };
372
- return;
373
- }
374
- this.#startWordCompletion(key, line, start, prefix);
375
- }
376
- #startWordCompletion(key: string, line: string, start: number, prefix: string): void {
377
- this.#completionActiveKey = key;
378
- const generation = this.#cacheGeneration;
379
- const request = this.#fetchWordCompletion(key, line, start, prefix, generation);
380
- const finished = (): void => {
381
- if (this.#completionActiveKey === key) this.#completionActiveKey = undefined;
382
- const queued = this.#completionQueued;
383
- this.#completionQueued = undefined;
384
- if (queued && this.#available && this.#features.autocomplete && !this.#completionCache.has(queued.key)) {
385
- this.#startWordCompletion(queued.key, queued.line, queued.start, queued.prefix);
386
- }
387
- };
388
- void request.then(finished, finished);
389
- }
390
-
391
- async #fetchWordCompletion(
392
- key: string,
393
- line: string,
394
- start: number,
395
- prefix: string,
396
- generation: number,
397
- ): Promise<void> {
398
- try {
399
- const completions = await this.backend.completeWord(line, start, prefix.length);
400
- if (generation !== this.#cacheGeneration || !this.#available) return;
401
- const lowerPrefix = prefix.toLocaleLowerCase();
402
- let suffix: string | null = null;
403
- for (const completion of completions) {
404
- if (completion.length > prefix.length && completion.toLocaleLowerCase().startsWith(lowerPrefix)) {
405
- suffix = completion.slice(prefix.length);
406
- break;
407
- }
408
- }
409
- if (this.#completionCache.size >= CACHE_LIMIT) this.#completionCache.clear();
410
- this.#completionCache.set(key, suffix);
411
- // A null suffix means no ghost text; the current paint is already right.
412
- if (suffix !== null && this.#completionQueued === undefined) this.onUpdate?.();
413
- } catch (error) {
414
- this.#disable(error);
415
- }
416
- }
417
-
418
- #context(lines: readonly string[], line: number): SpellingDecorationContext {
419
- return { editorText: lines.join("\n"), lines, line, startCol: 0 };
420
- }
421
-
422
- #sourceRangeIsProse(context: SpellingDecorationContext, startCol: number, endCol: number): boolean {
423
- if (context.editorText.length > MAX_SPELLING_BUFFER_LENGTH || startCol < 0 || endCol <= startCol) {
424
- return false;
425
- }
426
- const line = context.lines[context.line];
427
- if (line === undefined || line.length > MAX_SPELLING_LINE_LENGTH || endCol > line.length) return false;
428
- this.#prepareSource(context);
429
- const lineOffset = this.#sourceLineOffsets[context.line];
430
- if (lineOffset === undefined) return false;
431
- return this.#sourceMask.slice(lineOffset + startCol, lineOffset + endCol).trim().length > 0;
432
- }
433
-
434
- #prepareSource(context: SpellingDecorationContext): void {
435
- if (this.#sourceText === context.editorText) return;
436
- this.#sourceText = context.editorText;
437
- this.#sourceMask = maskNonProse(context.editorText);
438
- // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
439
- this.#sourceLineOffsets = new Array<number>(context.lines.length);
440
- let offset = 0;
441
- for (let line = 0; line < context.lines.length; line++) {
442
- this.#sourceLineOffsets[line] = offset;
443
- offset += (context.lines[line]?.length ?? 0) + 1;
444
- }
445
- }
446
-
447
308
  #clearCaches(): void {
448
309
  this.#cacheGeneration++;
449
310
  this.#typoCache.clear();
450
311
  this.#typoInFlight.clear();
451
312
  this.#automaticTypoQueue.clear();
452
- this.#completionCache.clear();
453
- this.#completionQueued = undefined;
454
313
  }
455
314
 
456
315
  #disable(error: unknown): void {
@@ -135,6 +135,7 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
135
135
  cursorLine: number,
136
136
  cursorCol: number,
137
137
  signal?: AbortSignal,
138
+ onPartial?: (suggestions: { items: AutocompleteItem[]; prefix: string }) => void,
138
139
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
139
140
  if (signal?.aborted) return null;
140
141
  const currentLine = lines[cursorLine] || "";
@@ -150,7 +151,13 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
150
151
  const commandName = commandText.slice(1, spaceIndex);
151
152
  const command = this.#commands.find(cmd => cmd.name === commandName || cmd.aliases?.includes(commandName));
152
153
  if (command && (!("allowArgs" in command) || command.allowArgs !== false)) {
153
- const argumentSuggestions = await this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol, signal);
154
+ const argumentSuggestions = await this.#baseProvider.getSuggestions(
155
+ lines,
156
+ cursorLine,
157
+ cursorCol,
158
+ signal,
159
+ onPartial,
160
+ );
154
161
  if (argumentSuggestions) return argumentSuggestions;
155
162
  const modelMentionSuggestions = getModelMentionSuggestions(textBeforeCursor, this.#modelMentions);
156
163
  if (modelMentionSuggestions) return modelMentionSuggestions;
@@ -205,7 +212,7 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
205
212
  if (emojiSuggestions) return emojiSuggestions;
206
213
  }
207
214
 
208
- return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol, signal);
215
+ return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol, signal, onPartial);
209
216
  }
210
217
 
211
218
  applyCompletion(
@@ -0,0 +1,87 @@
1
+ import { maskNonProse } from "./markdown-prose";
2
+
3
+ /**
4
+ * Client-side gates shared by the prose assistants (macOS typo/autocorrect and
5
+ * word completion): only plain prose words in modest buffers are ever sent to
6
+ * a spelling or prediction backend.
7
+ */
8
+
9
+ const CODEISH_CHARACTERS = "\\/@_=:{}[]<>";
10
+ const CAMEL_CASE = /\p{Ll}\p{Lu}/u;
11
+ /** Buffers above this many UTF-16 units get no prose assistance at all. */
12
+ const MAX_PROSE_BUFFER_LENGTH = 20_000;
13
+ /** Lines above this many UTF-16 units get no prose assistance at all. */
14
+ const MAX_PROSE_LINE_LENGTH = 1_000;
15
+
16
+ /** Logical source location for one rendered editor segment. */
17
+ export interface SpellingDecorationContext {
18
+ editorText: string;
19
+ lines: readonly string[];
20
+ line: number;
21
+ startCol: number;
22
+ }
23
+
24
+ function tokenAt(text: string, start: number, end: number): string {
25
+ let tokenStart = start;
26
+ while (tokenStart > 0 && !/\s/.test(text[tokenStart - 1] ?? "")) tokenStart--;
27
+ let tokenEnd = end;
28
+ while (tokenEnd < text.length && !/\s/.test(text[tokenEnd] ?? "")) tokenEnd++;
29
+ return text.slice(tokenStart, tokenEnd);
30
+ }
31
+
32
+ /**
33
+ * Whether `text[start, end)` is a prose word: unmasked by {@link maskNonProse},
34
+ * not inside a path/identifier-like token, and not on a slash-command or
35
+ * queue-shorthand line.
36
+ */
37
+ export function isProseWord(text: string, masked: string, start: number, end: number): boolean {
38
+ if (start < 0 || end <= start || end > text.length) return false;
39
+ if (masked.slice(start, end).trim().length === 0) return false;
40
+ const token = tokenAt(text, start, end);
41
+ for (const char of token) {
42
+ if (CODEISH_CHARACTERS.includes(char)) return false;
43
+ }
44
+ if (CAMEL_CASE.test(token) || /\d/.test(token)) return false;
45
+ return !text.trimStart().startsWith("/") && !text.startsWith("->") && !text.startsWith("=>");
46
+ }
47
+
48
+ /** Whole-buffer context for `line`, starting at column 0. */
49
+ export function lineContext(lines: readonly string[], line: number): SpellingDecorationContext {
50
+ return { editorText: lines.join("\n"), lines, line, startCol: 0 };
51
+ }
52
+
53
+ /**
54
+ * Memoized whole-buffer prose mask: answers whether a line range is prose in
55
+ * the context of the full editor text (fenced code spans lines).
56
+ */
57
+ export class ProseSource {
58
+ #text = "";
59
+ #mask = "";
60
+ #lineOffsets: number[] = [];
61
+
62
+ /** True when `[startCol, endCol)` of `context.line` is prose and the buffer is within the caps. */
63
+ isProse(context: SpellingDecorationContext, startCol: number, endCol: number): boolean {
64
+ if (context.editorText.length > MAX_PROSE_BUFFER_LENGTH || startCol < 0 || endCol <= startCol) {
65
+ return false;
66
+ }
67
+ const line = context.lines[context.line];
68
+ if (line === undefined || line.length > MAX_PROSE_LINE_LENGTH || endCol > line.length) return false;
69
+ this.#prepare(context);
70
+ const lineOffset = this.#lineOffsets[context.line];
71
+ if (lineOffset === undefined) return false;
72
+ return this.#mask.slice(lineOffset + startCol, lineOffset + endCol).trim().length > 0;
73
+ }
74
+
75
+ #prepare(context: SpellingDecorationContext): void {
76
+ if (this.#text === context.editorText) return;
77
+ this.#text = context.editorText;
78
+ this.#mask = maskNonProse(context.editorText);
79
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
80
+ this.#lineOffsets = new Array<number>(context.lines.length);
81
+ let offset = 0;
82
+ for (let line = 0; line < context.lines.length; line++) {
83
+ this.#lineOffsets[line] = offset;
84
+ offset += (context.lines[line]?.length ?? 0) + 1;
85
+ }
86
+ }
87
+ }
@@ -1,7 +1,7 @@
1
1
  Tired of typing "keep going"? Just send a '.'
2
2
  You can /btw to ask a side question
3
3
  Use /tan to fork the current conversation into a background agent
4
- With default keybindings, Ctrl+D deletes the character under the cursor instead of quitting your draft — see /hotkeys for your exit key
4
+ With default keybindings, {key:ctrl+d} deletes the character under the cursor instead of quitting your draft — see /hotkeys for your exit key
5
5
  Find out which model you emotionally abuse the most with `omp stats`
6
6
  Try task isolation to create CoW worktrees
7
7
  Need a cheap nested model call? Use `completion(x...)`. Have a big batch of tasks? Ask clanker to use it!
@@ -13,17 +13,17 @@ Say `workflowz` in your message to drive the task with parallel subagents in eva
13
13
  Bulk data to classify or triage? Say `jevify` in your message — the eval kernel's `judge()` processes the batch and you read only what it flags — watch it glow as you type
14
14
  Log in to several accounts of the same provider — `/login` again — and omp load-balances across them automatically
15
15
  Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any OpenAI-compatible client can hit
16
- Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smol -> slow -> etc
17
- Press ctrl+r to search your prompt history and reuse a past message
16
+ Press {key:alt+p} (or /switch) to switch provider, and {key:ctrl+p} to cycle role models smol -> slow -> etc
17
+ Press {key:ctrl+r} to search your prompt history and reuse a past message
18
18
  `/force read` pins the next turn to one specific tool when the model keeps reaching for the wrong one
19
19
  `/copy code` grabs the last code block to your clipboard — `/copy cmd` grabs the last shell/python command
20
20
  `/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
21
21
  Pair up live: `/collab` shares your session through an end-to-end encrypted relay link — a teammate runs `/join <link>` to watch tool calls stream and prompt the agent from their own omp
22
- Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
22
+ Press {tap:left} to drill into a running or finished agent and inspect its tool calls and transcript
23
23
  Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
24
24
  No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them!
25
25
  Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice
26
26
  Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y)
27
- Press shift+tab to cycle through reasoning effort levels
27
+ Press {key:shift+tab} to cycle through reasoning effort levels
28
28
  omp can reconfigure and extend itself — ask any questions you have!
29
- Lint/type errors piling up? `omp cleanse` (or /cleanse right here) hunts project diagnostics and fixes them with parallel subagents — esc cancels
29
+ Lint/type errors piling up? `omp cleanse` (or /cleanse right here) hunts project diagnostics and fixes them with parallel subagents — {key:escape} cancels
@@ -3,9 +3,13 @@ import type { Component } from "../tui";
3
3
  import { padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
4
4
  import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
5
5
  import { theme } from "../theme/theme";
6
+ import { formatDoubleTap, formatKeyHint, formatKeyHints, type KeyName } from "../app-keybindings";
7
+ import { editorKey } from "../chrome/keybinding-hints";
8
+ import { getKeybindings, type Keybinding } from "../keybindings";
6
9
  import tipsText from "./tips.txt" with { type: "text" };
7
10
 
8
- /** Tips embedded at build time, one per line; blanks dropped. */
11
+ /** Tips embedded at build time, one per line; blanks dropped. Key placeholders
12
+ * (see {@link expandTipKeys}) stay raw until render time. */
9
13
  const TIPS: readonly string[] = tipsText
10
14
  .split("\n")
11
15
  .map(line => line.trim())
@@ -76,6 +80,35 @@ function renderNewTag(phase: number, encoding: ColorEncoding): string {
76
80
  }
77
81
  return out + reset;
78
82
  }
83
+
84
+ /** Key placeholders in tips.txt: `{key:shift+tab}`, `{keys:up,down}`, `{tap:left}`, `{action:tui.editor.undo}`. */
85
+ const TIP_KEY_PLACEHOLDER = /\{(key|keys|tap|action):([^}]+)\}/g;
86
+
87
+ const MODIFIER_NAMES: Record<string, true | undefined> = { ctrl: true, shift: true, alt: true, super: true };
88
+
89
+ /** A `+`-joined chord whose leading parts are modifiers (`ctrl+o`, `shift`, `left`). */
90
+ function isKeyName(key: string): key is KeyName {
91
+ const parts = key.split("+");
92
+ return parts.every((part, i) => part.length > 0 && (i === parts.length - 1 || MODIFIER_NAMES[part] === true));
93
+ }
94
+
95
+ function isKeybinding(action: string): action is Keybinding {
96
+ return action in getKeybindings().getResolvedBindings();
97
+ }
98
+
99
+ /** Expand tip key placeholders through the key formatter; malformed ones stay verbatim. */
100
+ function expandTipKeys(tip: string): string {
101
+ return tip.replace(TIP_KEY_PLACEHOLDER, (placeholder, kind: string, value: string) => {
102
+ if (kind === "action") return isKeybinding(value) ? editorKey(value) : placeholder;
103
+ const keys = value.split(",");
104
+ if (!keys.every(isKeyName)) return placeholder;
105
+ if (kind === "keys") return formatKeyHints(keys);
106
+ const [key] = keys;
107
+ if (key === undefined) return placeholder;
108
+ return kind === "tap" ? formatDoubleTap(key) : formatKeyHint(key);
109
+ });
110
+ }
111
+
79
112
  export function renderWelcomeTip(tip: string, boxWidth: number, phase = 0): string[] {
80
113
  const label = "Tip: ";
81
114
  const labelWidth = visibleWidth(label);
@@ -83,7 +116,7 @@ export function renderWelcomeTip(tip: string, boxWidth: number, phase = 0): stri
83
116
  if (bodyBudget < 8) return [];
84
117
 
85
118
  const isNew = NEW_TIP_MARKER.test(tip);
86
- const body = isNew ? tip.replace(NEW_TIP_MARKER, "") : tip;
119
+ const body = expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip);
87
120
 
88
121
  const wrappedBody = wrapTextWithAnsi(replaceTabs(body), bodyBudget);
89
122
  if (wrappedBody.length === 0) return [];