@oh-my-pi/pi-coding-agent 13.9.11 → 13.9.13

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 (46) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +7 -7
  3. package/src/cli/args.ts +18 -16
  4. package/src/config/keybindings.ts +6 -0
  5. package/src/config/model-registry.ts +4 -4
  6. package/src/config/settings-schema.ts +10 -9
  7. package/src/debug/log-viewer.ts +11 -7
  8. package/src/exec/bash-executor.ts +15 -1
  9. package/src/internal-urls/docs-index.generated.ts +1 -1
  10. package/src/modes/components/agent-dashboard.ts +11 -8
  11. package/src/modes/components/extensions/extension-list.ts +16 -8
  12. package/src/modes/components/settings-defs.ts +2 -2
  13. package/src/modes/components/status-line.ts +5 -9
  14. package/src/modes/components/tree-selector.ts +4 -6
  15. package/src/modes/components/welcome.ts +1 -0
  16. package/src/modes/controllers/command-controller.ts +47 -42
  17. package/src/modes/controllers/event-controller.ts +12 -9
  18. package/src/modes/controllers/input-controller.ts +54 -1
  19. package/src/modes/interactive-mode.ts +4 -10
  20. package/src/modes/prompt-action-autocomplete.ts +201 -0
  21. package/src/modes/types.ts +1 -0
  22. package/src/modes/utils/ui-helpers.ts +12 -0
  23. package/src/patch/index.ts +1 -1
  24. package/src/prompts/system/system-prompt.md +97 -107
  25. package/src/prompts/tools/ast-edit.md +5 -2
  26. package/src/prompts/tools/ast-grep.md +5 -2
  27. package/src/prompts/tools/inspect-image-system.md +20 -0
  28. package/src/prompts/tools/inspect-image.md +32 -0
  29. package/src/session/agent-session.ts +33 -36
  30. package/src/session/compaction/compaction.ts +26 -29
  31. package/src/session/session-manager.ts +15 -7
  32. package/src/tools/bash-interactive.ts +8 -3
  33. package/src/tools/fetch.ts +5 -27
  34. package/src/tools/index.ts +4 -0
  35. package/src/tools/inspect-image-renderer.ts +103 -0
  36. package/src/tools/inspect-image.ts +168 -0
  37. package/src/tools/read.ts +62 -49
  38. package/src/tools/renderers.ts +2 -0
  39. package/src/utils/image-input.ts +264 -0
  40. package/src/web/kagi.ts +0 -42
  41. package/src/web/scrapers/youtube.ts +0 -17
  42. package/src/web/search/index.ts +3 -1
  43. package/src/web/search/provider.ts +4 -1
  44. package/src/web/search/providers/exa.ts +8 -0
  45. package/src/web/search/providers/tavily.ts +162 -0
  46. package/src/web/search/types.ts +1 -0
@@ -149,7 +149,6 @@ export class StatusLineComponent implements Component {
149
149
  #invalidateGitCaches(): void {
150
150
  this.#cachedBranch = undefined;
151
151
  this.#cachedBranchRepoId = undefined;
152
- this.#cachedPr = undefined;
153
152
  this.#cachedPrContext = undefined;
154
153
  }
155
154
  #getCurrentBranch(): string | null {
@@ -261,20 +260,17 @@ export class StatusLineComponent implements Component {
261
260
  return this.#cachedPr ?? null;
262
261
  }
263
262
 
264
- if (this.#cachedPr !== undefined) {
265
- this.#cachedPr = undefined;
266
- this.#cachedPrContext = undefined;
267
- }
263
+ const stalePr = this.#cachedPr;
268
264
 
269
265
  // Don't look up if no branch, detached HEAD, default branch, or already in flight
270
266
  if (!branch || branch === "detached" || this.#isDefaultBranch(branch) || this.#prLookupInFlight) {
271
- return null;
267
+ return stalePr ?? null;
272
268
  }
273
269
 
274
270
  this.#prLookupInFlight = true;
275
271
  const lookupContext = currentContext;
276
272
 
277
- // Fire async lookup, return null until resolved
273
+ // Fire async lookup, keep stale value visible until resolved
278
274
  (async () => {
279
275
  // Helper: only write cache if branch/repo context hasn't changed since launch
280
276
  const setCachedPr = (value: { number: number; url: string } | null) => {
@@ -304,13 +300,13 @@ export class StatusLineComponent implements Component {
304
300
  setCachedPr(null);
305
301
  } finally {
306
302
  this.#prLookupInFlight = false;
307
- if (this.#cachedPr && this.#onBranchChange) {
303
+ if (this.#onBranchChange) {
308
304
  this.#onBranchChange();
309
305
  }
310
306
  }
311
307
  })();
312
308
 
313
- return null;
309
+ return stalePr ?? null;
314
310
  }
315
311
 
316
312
  #getTokensPerSecond(): number | null {
@@ -2,6 +2,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import {
3
3
  type Component,
4
4
  Container,
5
+ extractPrintableText,
5
6
  Input,
6
7
  matchesKey,
7
8
  Spacer,
@@ -745,12 +746,9 @@ class TreeList implements Component {
745
746
  this.onLabelEdit(selected.node.entry.id, selected.node.label);
746
747
  }
747
748
  } else {
748
- const hasControlChars = [...keyData].some(ch => {
749
- const code = ch.charCodeAt(0);
750
- return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
751
- });
752
- if (!hasControlChars && keyData.length > 0) {
753
- this.#searchQuery += keyData;
749
+ const printableText = extractPrintableText(keyData);
750
+ if (printableText) {
751
+ this.#searchQuery += printableText;
754
752
  this.#applyFilter();
755
753
  }
756
754
  }
@@ -122,6 +122,7 @@ export class WelcomeComponent implements Component {
122
122
  const rightLines = [
123
123
  ` ${theme.bold(theme.fg("accent", "Tips"))}`,
124
124
  ` ${theme.fg("dim", "?")}${theme.fg("muted", " for keyboard shortcuts")}`,
125
+ ` ${theme.fg("dim", "#")}${theme.fg("muted", " for prompt actions")}`,
125
126
  ` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
126
127
  ` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
127
128
  ` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
@@ -435,49 +435,54 @@ export class CommandController {
435
435
  const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O";
436
436
  const planModeKey = this.ctx.keybindings.getDisplayString("togglePlanMode") || "Alt+Shift+P";
437
437
  const sttKey = this.ctx.keybindings.getDisplayString("toggleSTT") || "Alt+H";
438
+ const copyLineKey = this.ctx.keybindings.getDisplayString("copyLine") || "Alt+Shift+L";
439
+ const copyPromptKey = this.ctx.keybindings.getDisplayString("copyPrompt") || "Alt+Shift+C";
438
440
  const hotkeys = `
439
- **Navigation**
440
- | Key | Action |
441
- |-----|--------|
442
- | \`Arrow keys\` | Move cursor / browse history (Up when empty) |
443
- | \`Option+Left/Right\` | Move by word |
444
- | \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line |
445
- | \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line |
446
-
447
- **Editing**
448
- | Key | Action |
449
- |-----|--------|
450
- | \`Enter\` | Send message |
451
- | \`Shift+Enter\` / \`Alt+Enter\` | New line |
452
- | \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards |
453
- | \`Ctrl+U\` | Delete to start of line |
454
- | \`Ctrl+K\` | Delete to end of line |
455
-
456
- **Other**
457
- | Key | Action |
458
- |-----|--------|
459
- | \`Tab\` | Path completion / accept autocomplete |
460
- | \`Escape\` | Cancel autocomplete / abort streaming |
461
- | \`Ctrl+C\` | Clear editor (first) / exit (second) |
462
- | \`Ctrl+D\` | Exit (when editor is empty) |
463
- | \`Ctrl+Z\` | Suspend to background |
464
- | \`Shift+Tab\` | Cycle thinking level |
465
- | \`Ctrl+P\` | Cycle role models (slow/default/smol) |
466
- | \`Shift+Ctrl+P\` | Cycle role models (temporary) |
467
- | \`Alt+P\` | Select model (temporary) |
468
- | \`Ctrl+L\` | Select model (set roles) |
469
- | \`${planModeKey}\` | Toggle plan mode |
470
- | \`Ctrl+R\` | Search prompt history |
471
- | \`${expandToolsKey}\` | Toggle tool output expansion |
472
- | \`Ctrl+T\` | Toggle todo list expansion |
473
- | \`Ctrl+G\` | Edit message in external editor |
474
- | \`${sttKey}\` | Toggle speech-to-text recording |
475
- | \`/\` | Slash commands |
476
- | \`!\` | Run bash command |
477
- | \`!!\` | Run bash command (excluded from context) |
478
- | \`$\` | Run Python in shared kernel |
479
- | \`$$\` | Run Python (excluded from context) |
480
- `;
441
+ **Navigation**
442
+ | Key | Action |
443
+ |-----|--------|
444
+ | \`Arrow keys\` | Move cursor / browse history (Up when empty) |
445
+ | \`Option+Left/Right\` | Move by word |
446
+ | \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line |
447
+ | \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line |
448
+
449
+ **Editing**
450
+ | Key | Action |
451
+ |-----|--------|
452
+ | \`Enter\` | Send message |
453
+ | \`Shift+Enter\` / \`Alt+Enter\` | New line |
454
+ | \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards |
455
+ | \`Ctrl+U\` | Delete to start of line |
456
+ | \`Ctrl+K\` | Delete to end of line |
457
+ | \`${copyLineKey}\` | Copy current line |
458
+ | \`${copyPromptKey}\` | Copy whole prompt |
459
+
460
+ **Other**
461
+ | Key | Action |
462
+ |-----|--------|
463
+ | \`Tab\` | Path completion / accept autocomplete |
464
+ | \`Escape\` | Cancel autocomplete / abort streaming |
465
+ | \`Ctrl+C\` | Clear editor (first) / exit (second) |
466
+ | \`Ctrl+D\` | Exit (when editor is empty) |
467
+ | \`Ctrl+Z\` | Suspend to background |
468
+ | \`Shift+Tab\` | Cycle thinking level |
469
+ | \`Ctrl+P\` | Cycle role models (slow/default/smol) |
470
+ | \`Shift+Ctrl+P\` | Cycle role models (temporary) |
471
+ | \`Alt+P\` | Select model (temporary) |
472
+ | \`Ctrl+L\` | Select model (set roles) |
473
+ | \`${planModeKey}\` | Toggle plan mode |
474
+ | \`Ctrl+R\` | Search prompt history |
475
+ | \`${expandToolsKey}\` | Toggle tool output expansion |
476
+ | \`Ctrl+T\` | Toggle todo list expansion |
477
+ | \`Ctrl+G\` | Edit message in external editor |
478
+ | \`${sttKey}\` | Toggle speech-to-text recording |
479
+ | \`#\` | Open prompt actions |
480
+ | \`/\` | Slash commands |
481
+ | \`!\` | Run bash command |
482
+ | \`!!\` | Run bash command (excluded from context) |
483
+ | \`$\` | Run Python in shared kernel |
484
+ | \`$$\` | Run Python (excluded from context) |
485
+ `;
481
486
  this.ctx.chatContainer.addChild(new Spacer(1));
482
487
  this.ctx.chatContainer.addChild(new DynamicBorder());
483
488
  this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
@@ -134,8 +134,19 @@ export class EventController {
134
134
  this.ctx.addMessageToChat(event.message);
135
135
  this.ctx.ui.requestRender();
136
136
  } else if (event.message.role === "user") {
137
+ const textContent = this.ctx.getUserMessageText(event.message);
138
+ const imageCount =
139
+ typeof event.message.content === "string"
140
+ ? 0
141
+ : event.message.content.filter(content => content.type === "image").length;
142
+ const signature = `${textContent}\u0000${imageCount}`;
143
+
137
144
  this.#resetReadGroup();
138
- this.ctx.addMessageToChat(event.message);
145
+ if (this.ctx.optimisticUserMessageSignature !== signature) {
146
+ this.ctx.addMessageToChat(event.message);
147
+ }
148
+ this.ctx.optimisticUserMessageSignature = undefined;
149
+
139
150
  if (!event.message.synthetic) {
140
151
  this.ctx.editor.setText("");
141
152
  this.ctx.updatePendingMessagesDisplay();
@@ -473,15 +484,7 @@ export class EventController {
473
484
  isHandoffAction ? "Auto-handoff cancelled" : "Auto context-full maintenance cancelled",
474
485
  );
475
486
  } else if (event.result) {
476
- this.ctx.chatContainer.clear();
477
487
  this.ctx.rebuildChatFromMessages();
478
- this.ctx.addMessageToChat({
479
- role: "compactionSummary",
480
- tokensBefore: event.result.tokensBefore,
481
- summary: event.result.summary,
482
- shortSummary: event.result.shortSummary,
483
- timestamp: Date.now(),
484
- });
485
488
  this.ctx.statusLine.invalidate();
486
489
  this.ctx.updateEditorTopBorder();
487
490
  } else if (event.errorMessage) {
@@ -1,8 +1,10 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import { type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
3
3
  import { copyToClipboard, readImageFromClipboard, sanitizeText } from "@oh-my-pi/pi-natives";
4
+ import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
4
5
  import { $env } from "@oh-my-pi/pi-utils";
5
6
  import { settings } from "../../config/settings";
7
+ import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
6
8
  import { theme } from "../../modes/theme/theme";
7
9
  import type { InteractiveModeContext } from "../../modes/types";
8
10
  import type { AgentSessionEvent } from "../../session/agent-session";
@@ -87,7 +89,8 @@ export class InputController {
87
89
  this.ctx.editor.onCtrlG = () => void this.openExternalEditor();
88
90
  this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
89
91
  this.ctx.editor.onCtrlV = () => this.handleImagePaste();
90
- this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
92
+ const copyPromptKeys = this.ctx.keybindings.getKeys("copyPrompt");
93
+ this.ctx.editor.onCopyPrompt = copyPromptKeys.includes("alt+shift+c") ? () => this.handleCopyPrompt() : undefined;
91
94
 
92
95
  // Wire up extension shortcuts
93
96
  this.registerExtensionShortcuts();
@@ -129,6 +132,13 @@ export class InputController {
129
132
  for (const key of this.ctx.keybindings.getKeys("toggleSTT")) {
130
133
  this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
131
134
  }
135
+ for (const key of this.ctx.keybindings.getKeys("copyLine")) {
136
+ this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
137
+ }
138
+ for (const key of copyPromptKeys) {
139
+ if (key === "alt+shift+c") continue;
140
+ this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyPrompt());
141
+ }
132
142
 
133
143
  this.ctx.editor.onChange = (text: string) => {
134
144
  const wasBashMode = this.ctx.isBashMode;
@@ -318,6 +328,19 @@ export class InputController {
318
328
  // Include any pending images from clipboard paste
319
329
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
320
330
  this.ctx.pendingImages = [];
331
+
332
+ // Render user message immediately, then let session events catch up
333
+ this.ctx.optimisticUserMessageSignature = `${text}\u0000${images?.length ?? 0}`;
334
+ const optimisticMessage: AgentMessage = {
335
+ role: "user",
336
+ content: [{ type: "text", text }, ...(images ?? [])],
337
+ attribution: "user",
338
+ timestamp: Date.now(),
339
+ };
340
+ this.ctx.addMessageToChat(optimisticMessage);
341
+ this.ctx.editor.setText("");
342
+ this.ctx.ui.requestRender();
343
+
321
344
  this.ctx.onInputCallback({ text, images });
322
345
  }
323
346
  this.ctx.editor.addToHistory(text);
@@ -503,6 +526,36 @@ export class InputController {
503
526
  }
504
527
  }
505
528
 
529
+ createAutocompleteProvider(commands: SlashCommand[], basePath: string): AutocompleteProvider {
530
+ return createPromptActionAutocompleteProvider({
531
+ commands,
532
+ basePath,
533
+ keybindings: this.ctx.keybindings,
534
+ copyCurrentLine: () => this.handleCopyCurrentLine(),
535
+ copyPrompt: () => this.handleCopyPrompt(),
536
+ moveCursorToLineStart: () => this.ctx.editor.moveToLineStart(),
537
+ moveCursorToLineEnd: () => this.ctx.editor.moveToLineEnd(),
538
+ });
539
+ }
540
+
541
+ /** Copy the current editor line to the system clipboard. */
542
+ handleCopyCurrentLine(): void {
543
+ const { line } = this.ctx.editor.getCursor();
544
+ const text = this.ctx.editor.getLines()[line] || "";
545
+ if (!text) {
546
+ this.ctx.showStatus("Nothing to copy");
547
+ return;
548
+ }
549
+ try {
550
+ copyToClipboard(text);
551
+ const sanitized = sanitizeText(text);
552
+ const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized;
553
+ this.ctx.showStatus(`Copied line: ${preview}`);
554
+ } catch {
555
+ this.ctx.showWarning("Failed to copy to clipboard");
556
+ }
557
+ }
558
+
506
559
  /** Copy current prompt text to system clipboard. */
507
560
  handleCopyPrompt(): void {
508
561
  const text = this.ctx.editor.getText();
@@ -6,15 +6,7 @@ import * as path from "node:path";
6
6
  import { type Agent, type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
7
  import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
8
8
  import type { Component, Loader, SlashCommand } from "@oh-my-pi/pi-tui";
9
- import {
10
- CombinedAutocompleteProvider,
11
- Container,
12
- Markdown,
13
- ProcessTerminal,
14
- Spacer,
15
- Text,
16
- TUI,
17
- } from "@oh-my-pi/pi-tui";
9
+ import { Container, Markdown, ProcessTerminal, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
18
10
  import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
19
11
  import chalk from "chalk";
20
12
  import { KeybindingsManager } from "../config/keybindings";
@@ -123,6 +115,7 @@ export class InteractiveMode implements InteractiveModeContext {
123
115
  retryEscapeHandler?: () => void;
124
116
  unsubscribe?: () => void;
125
117
  onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void;
118
+ optimisticUserMessageSignature: string | undefined = undefined;
126
119
  lastSigintTime = 0;
127
120
  lastEscapeTime = 0;
128
121
  shutdownRequested = false;
@@ -389,7 +382,7 @@ export class InteractiveMode implements InteractiveModeContext {
389
382
  name: cmd.name,
390
383
  description: cmd.description,
391
384
  }));
392
- const autocompleteProvider = new CombinedAutocompleteProvider(
385
+ const autocompleteProvider = this.#inputController.createAutocompleteProvider(
393
386
  [...this.#pendingSlashCommands, ...fileSlashCommands],
394
387
  basePath,
395
388
  );
@@ -855,6 +848,7 @@ export class InteractiveMode implements InteractiveModeContext {
855
848
  }
856
849
 
857
850
  showError(message: string): void {
851
+ this.optimisticUserMessageSignature = undefined;
858
852
  this.#uiHelpers.showError(message);
859
853
  }
860
854
 
@@ -0,0 +1,201 @@
1
+ import {
2
+ type AutocompleteItem,
3
+ type AutocompleteProvider,
4
+ CombinedAutocompleteProvider,
5
+ getEditorKeybindings,
6
+ type SlashCommand,
7
+ } from "@oh-my-pi/pi-tui";
8
+ import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
9
+
10
+ interface PromptActionDefinition {
11
+ id: string;
12
+ label: string;
13
+ description: string;
14
+ keywords: string[];
15
+ execute: () => void;
16
+ }
17
+
18
+ interface PromptActionAutocompleteItem extends AutocompleteItem {
19
+ actionId: string;
20
+ execute: () => void;
21
+ }
22
+
23
+ interface PromptActionAutocompleteOptions {
24
+ commands: SlashCommand[];
25
+ basePath: string;
26
+ keybindings: KeybindingsManager;
27
+ copyCurrentLine: () => void;
28
+ copyPrompt: () => void;
29
+ moveCursorToLineStart: () => void;
30
+ moveCursorToLineEnd: () => void;
31
+ }
32
+
33
+ function fuzzyMatch(query: string, target: string): boolean {
34
+ if (query.length === 0) return true;
35
+ if (query.length > target.length) return false;
36
+
37
+ let queryIndex = 0;
38
+ for (let targetIndex = 0; targetIndex < target.length && queryIndex < query.length; targetIndex += 1) {
39
+ if (query[queryIndex] === target[targetIndex]) {
40
+ queryIndex += 1;
41
+ }
42
+ }
43
+
44
+ return queryIndex === query.length;
45
+ }
46
+
47
+ function fuzzyScore(query: string, target: string): number {
48
+ if (query.length === 0) return 1;
49
+ if (target === query) return 100;
50
+ if (target.startsWith(query)) return 80;
51
+ if (target.includes(query)) return 60;
52
+
53
+ let queryIndex = 0;
54
+ let gaps = 0;
55
+ let lastMatchIndex = -1;
56
+ for (let targetIndex = 0; targetIndex < target.length && queryIndex < query.length; targetIndex += 1) {
57
+ if (query[queryIndex] === target[targetIndex]) {
58
+ if (lastMatchIndex >= 0 && targetIndex - lastMatchIndex > 1) {
59
+ gaps += 1;
60
+ }
61
+ lastMatchIndex = targetIndex;
62
+ queryIndex += 1;
63
+ }
64
+ }
65
+
66
+ if (queryIndex !== query.length) return 0;
67
+ return Math.max(1, 40 - gaps * 5);
68
+ }
69
+
70
+ function isPromptActionItem(item: AutocompleteItem): item is PromptActionAutocompleteItem {
71
+ return (
72
+ "actionId" in item && "execute" in item && typeof (item as PromptActionAutocompleteItem).execute === "function"
73
+ );
74
+ }
75
+
76
+ function getPromptActionPrefix(textBeforeCursor: string): string | null {
77
+ const hashIndex = textBeforeCursor.lastIndexOf("#");
78
+ if (hashIndex === -1) return null;
79
+
80
+ const query = textBeforeCursor.slice(hashIndex + 1);
81
+ if (/[\s]/.test(query)) {
82
+ return null;
83
+ }
84
+
85
+ return textBeforeCursor.slice(hashIndex);
86
+ }
87
+
88
+ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
89
+ #baseProvider: CombinedAutocompleteProvider;
90
+ #actions: PromptActionDefinition[];
91
+
92
+ constructor(commands: SlashCommand[], basePath: string, actions: PromptActionDefinition[]) {
93
+ this.#baseProvider = new CombinedAutocompleteProvider(commands, basePath);
94
+ this.#actions = actions;
95
+ }
96
+
97
+ async getSuggestions(
98
+ lines: string[],
99
+ cursorLine: number,
100
+ cursorCol: number,
101
+ ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
102
+ const currentLine = lines[cursorLine] || "";
103
+ const textBeforeCursor = currentLine.slice(0, cursorCol);
104
+ const promptActionPrefix = getPromptActionPrefix(textBeforeCursor);
105
+ if (promptActionPrefix) {
106
+ const query = promptActionPrefix.slice(1).toLowerCase();
107
+ const items = this.#actions
108
+ .map(action => {
109
+ const searchable = [action.label, action.description, ...action.keywords].join(" ").toLowerCase();
110
+ if (!fuzzyMatch(query, searchable)) return null;
111
+ return {
112
+ value: action.label,
113
+ label: action.label,
114
+ description: action.description,
115
+ actionId: action.id,
116
+ execute: action.execute,
117
+ score: fuzzyScore(query, searchable),
118
+ } satisfies PromptActionAutocompleteItem & { score: number };
119
+ })
120
+ .filter(item => item !== null)
121
+ .sort((a, b) => b.score - a.score)
122
+ .map(({ score: _score, ...item }) => item);
123
+ if (items.length > 0) {
124
+ return { items, prefix: promptActionPrefix };
125
+ }
126
+ }
127
+
128
+ return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol);
129
+ }
130
+
131
+ applyCompletion(
132
+ lines: string[],
133
+ cursorLine: number,
134
+ cursorCol: number,
135
+ item: AutocompleteItem,
136
+ prefix: string,
137
+ ): {
138
+ lines: string[];
139
+ cursorLine: number;
140
+ cursorCol: number;
141
+ onApplied?: () => void;
142
+ } {
143
+ if (prefix.startsWith("#") && isPromptActionItem(item)) {
144
+ const currentLine = lines[cursorLine] || "";
145
+ const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
146
+ const afterCursor = currentLine.slice(cursorCol);
147
+ const newLines = [...lines];
148
+ newLines[cursorLine] = beforePrefix + afterCursor;
149
+ return {
150
+ lines: newLines,
151
+ cursorLine,
152
+ cursorCol: beforePrefix.length,
153
+ onApplied: item.execute,
154
+ };
155
+ }
156
+
157
+ return this.#baseProvider.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
158
+ }
159
+
160
+ getInlineHint(lines: string[], cursorLine: number, cursorCol: number): string | null {
161
+ return this.#baseProvider.getInlineHint?.(lines, cursorLine, cursorCol) ?? null;
162
+ }
163
+ }
164
+
165
+ export function createPromptActionAutocompleteProvider(
166
+ options: PromptActionAutocompleteOptions,
167
+ ): PromptActionAutocompleteProvider {
168
+ const editorKeybindings = getEditorKeybindings();
169
+ const actions: PromptActionDefinition[] = [
170
+ {
171
+ id: "copy-line",
172
+ label: "Copy current line",
173
+ description: formatKeyHints(options.keybindings.getKeys("copyLine")),
174
+ keywords: ["copy", "line", "clipboard", "current"],
175
+ execute: options.copyCurrentLine,
176
+ },
177
+ {
178
+ id: "copy-prompt",
179
+ label: "Copy whole prompt",
180
+ description: formatKeyHints(options.keybindings.getKeys("copyPrompt")),
181
+ keywords: ["copy", "prompt", "clipboard", "message"],
182
+ execute: options.copyPrompt,
183
+ },
184
+ {
185
+ id: "cursor-line-start",
186
+ label: "Move cursor to beginning of line",
187
+ description: formatKeyHints(editorKeybindings.getKeys("cursorLineStart")),
188
+ keywords: ["move", "cursor", "line", "start", "beginning", "home"],
189
+ execute: options.moveCursorToLineStart,
190
+ },
191
+ {
192
+ id: "cursor-line-end",
193
+ label: "Move cursor to end of line",
194
+ description: formatKeyHints(editorKeybindings.getKeys("cursorLineEnd")),
195
+ keywords: ["move", "cursor", "line", "end"],
196
+ execute: options.moveCursorToLineEnd,
197
+ },
198
+ ];
199
+
200
+ return new PromptActionAutocompleteProvider(options.commands, options.basePath, actions);
201
+ }
@@ -88,6 +88,7 @@ export interface InteractiveModeContext {
88
88
  retryEscapeHandler?: () => void;
89
89
  unsubscribe?: () => void;
90
90
  onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void;
91
+ optimisticUserMessageSignature: string | undefined;
91
92
  lastSigintTime: number;
92
93
  lastEscapeTime: number;
93
94
  shutdownRequested: boolean;
@@ -209,6 +209,7 @@ export class UiHelpers {
209
209
  sessionContext: SessionContext,
210
210
  options: { updateFooter?: boolean; populateHistory?: boolean } = {},
211
211
  ): void {
212
+ this.ctx.optimisticUserMessageSignature = undefined;
212
213
  this.ctx.pendingTools.clear();
213
214
 
214
215
  if (options.updateFooter) {
@@ -219,7 +220,13 @@ export class UiHelpers {
219
220
  let readGroup: ReadToolGroupComponent | null = null;
220
221
  const readToolCallArgs = new Map<string, Record<string, unknown>>();
221
222
  const readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
223
+ const deferredMessages: AgentMessage[] = [];
222
224
  for (const message of sessionContext.messages) {
225
+ // Defer compaction summaries so they render at the bottom (visible after scroll)
226
+ if (message.role === "compactionSummary") {
227
+ deferredMessages.push(message);
228
+ continue;
229
+ }
223
230
  // Assistant messages need special handling for tool calls
224
231
  if (message.role === "assistant") {
225
232
  this.ctx.addMessageToChat(message);
@@ -349,6 +356,11 @@ export class UiHelpers {
349
356
  }
350
357
  }
351
358
 
359
+ // Render deferred messages (compaction summaries) at the bottom so they're visible
360
+ for (const message of deferredMessages) {
361
+ this.ctx.addMessageToChat(message, options);
362
+ }
363
+
352
364
  this.ctx.pendingTools.clear();
353
365
  this.ctx.ui.requestRender();
354
366
  }
@@ -96,7 +96,7 @@ export type ReplaceParams = Static<typeof replaceEditSchema>;
96
96
  export type PatchParams = Static<typeof patchEditSchema>;
97
97
 
98
98
  /** Pattern matching hashline display format prefixes: `LINE#ID:CONTENT` and `#ID:CONTENT` */
99
- const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\d+\s*#\s*|#)\s*[0-9a-zA-Z]{1,16}:/;
99
+ const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\d+\s*#\s*|#\s*)[ZPMQVRWSNKTXJBYH]{2}:/;
100
100
 
101
101
  /** Pattern matching a unified-diff added-line `+` prefix (but not `++`). Does NOT match `-` to avoid corrupting Markdown list items. */
102
102
  const DIFF_PLUS_RE = /^[+](?![+])/;