@mariozechner/pi-coding-agent 0.32.3 → 0.33.0

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 (45) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +76 -2
  3. package/dist/core/export-html/template.css +34 -4
  4. package/dist/core/export-html/template.js +17 -4
  5. package/dist/core/keybindings.d.ts +59 -0
  6. package/dist/core/keybindings.d.ts.map +1 -0
  7. package/dist/core/keybindings.js +149 -0
  8. package/dist/core/keybindings.js.map +1 -0
  9. package/dist/modes/interactive/components/custom-editor.d.ts +11 -12
  10. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  11. package/dist/modes/interactive/components/custom-editor.js +48 -72
  12. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  13. package/dist/modes/interactive/components/hook-editor.d.ts.map +1 -1
  14. package/dist/modes/interactive/components/hook-editor.js +5 -4
  15. package/dist/modes/interactive/components/hook-editor.js.map +1 -1
  16. package/dist/modes/interactive/components/hook-input.d.ts.map +1 -1
  17. package/dist/modes/interactive/components/hook-input.js +4 -3
  18. package/dist/modes/interactive/components/hook-input.js.map +1 -1
  19. package/dist/modes/interactive/components/hook-selector.d.ts.map +1 -1
  20. package/dist/modes/interactive/components/hook-selector.js +6 -5
  21. package/dist/modes/interactive/components/hook-selector.js.map +1 -1
  22. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  23. package/dist/modes/interactive/components/model-selector.js +6 -5
  24. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  25. package/dist/modes/interactive/components/oauth-selector.d.ts.map +1 -1
  26. package/dist/modes/interactive/components/oauth-selector.js +6 -5
  27. package/dist/modes/interactive/components/oauth-selector.js.map +1 -1
  28. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  29. package/dist/modes/interactive/components/session-selector.js +6 -9
  30. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  31. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  32. package/dist/modes/interactive/components/tree-selector.js +14 -15
  33. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  34. package/dist/modes/interactive/components/user-message-selector.d.ts.map +1 -1
  35. package/dist/modes/interactive/components/user-message-selector.js +6 -11
  36. package/dist/modes/interactive/components/user-message-selector.js.map +1 -1
  37. package/dist/modes/interactive/interactive-mode.d.ts +21 -1
  38. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  39. package/dist/modes/interactive/interactive-mode.js +175 -45
  40. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  41. package/docs/tui.md +18 -15
  42. package/examples/custom-tools/subagent/README.md +2 -2
  43. package/examples/hooks/snake.ts +7 -7
  44. package/examples/hooks/todo/index.ts +2 -2
  45. package/package.json +5 -4
@@ -2,12 +2,15 @@
2
2
  * Interactive mode for the coding agent.
3
3
  * Handles TUI rendering and user interaction, delegating business logic to AgentSession.
4
4
  */
5
+ import * as crypto from "node:crypto";
5
6
  import * as fs from "node:fs";
6
7
  import * as os from "node:os";
7
8
  import * as path from "node:path";
8
- import { CombinedAutocompleteProvider, Container, Input, Loader, Markdown, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
9
+ import Clipboard from "@crosscopy/clipboard";
10
+ import { CombinedAutocompleteProvider, Container, getEditorKeybindings, Input, Loader, Markdown, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
9
11
  import { exec, spawn, spawnSync } from "child_process";
10
12
  import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js";
13
+ import { KeybindingsManager } from "../../core/keybindings.js";
11
14
  import { createCompactionSummaryMessage } from "../../core/messages.js";
12
15
  import { SessionManager } from "../../core/session-manager.js";
13
16
  import { loadSkills } from "../../core/skills.js";
@@ -49,6 +52,7 @@ export class InteractiveMode {
49
52
  editor;
50
53
  editorContainer;
51
54
  footer;
55
+ keybindings;
52
56
  version;
53
57
  isInitialized = false;
54
58
  onInputCallback;
@@ -88,6 +92,9 @@ export class InteractiveMode {
88
92
  hookEditor = undefined;
89
93
  // Custom tools for custom rendering
90
94
  customTools;
95
+ // Clipboard image tracking: imageId -> temp file path
96
+ clipboardImages = new Map();
97
+ clipboardImageCounter = 0;
91
98
  // Convenience accessors
92
99
  get agent() {
93
100
  return this.session.agent;
@@ -108,7 +115,8 @@ export class InteractiveMode {
108
115
  this.chatContainer = new Container();
109
116
  this.pendingMessagesContainer = new Container();
110
117
  this.statusContainer = new Container();
111
- this.editor = new CustomEditor(getEditorTheme());
118
+ this.keybindings = KeybindingsManager.create();
119
+ this.editor = new CustomEditor(getEditorTheme(), this.keybindings);
112
120
  this.editorContainer = new Container();
113
121
  this.editorContainer.addChild(this.editor);
114
122
  this.footer = new FooterComponent(session);
@@ -150,42 +158,61 @@ export class InteractiveMode {
150
158
  async init() {
151
159
  if (this.isInitialized)
152
160
  return;
153
- // Add header
161
+ // Add header with keybindings from config
154
162
  const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
155
- const instructions = theme.fg("dim", "esc") +
163
+ // Format keybinding for startup display (lowercase, compact)
164
+ const formatStartupKey = (keys) => {
165
+ const keyArray = Array.isArray(keys) ? keys : [keys];
166
+ return keyArray.join("/");
167
+ };
168
+ const kb = this.keybindings;
169
+ const interrupt = formatStartupKey(kb.getKeys("interrupt"));
170
+ const clear = formatStartupKey(kb.getKeys("clear"));
171
+ const exit = formatStartupKey(kb.getKeys("exit"));
172
+ const suspend = formatStartupKey(kb.getKeys("suspend"));
173
+ const deleteToLineEnd = formatStartupKey(getEditorKeybindings().getKeys("deleteToLineEnd"));
174
+ const cycleThinkingLevel = formatStartupKey(kb.getKeys("cycleThinkingLevel"));
175
+ const cycleModelForward = formatStartupKey(kb.getKeys("cycleModelForward"));
176
+ const cycleModelBackward = formatStartupKey(kb.getKeys("cycleModelBackward"));
177
+ const selectModel = formatStartupKey(kb.getKeys("selectModel"));
178
+ const expandTools = formatStartupKey(kb.getKeys("expandTools"));
179
+ const toggleThinking = formatStartupKey(kb.getKeys("toggleThinking"));
180
+ const externalEditor = formatStartupKey(kb.getKeys("externalEditor"));
181
+ const followUp = formatStartupKey(kb.getKeys("followUp"));
182
+ const instructions = theme.fg("dim", interrupt) +
156
183
  theme.fg("muted", " to interrupt") +
157
184
  "\n" +
158
- theme.fg("dim", "ctrl+c") +
185
+ theme.fg("dim", clear) +
159
186
  theme.fg("muted", " to clear") +
160
187
  "\n" +
161
- theme.fg("dim", "ctrl+c twice") +
188
+ theme.fg("dim", `${clear} twice`) +
162
189
  theme.fg("muted", " to exit") +
163
190
  "\n" +
164
- theme.fg("dim", "ctrl+d") +
191
+ theme.fg("dim", exit) +
165
192
  theme.fg("muted", " to exit (empty)") +
166
193
  "\n" +
167
- theme.fg("dim", "ctrl+z") +
194
+ theme.fg("dim", suspend) +
168
195
  theme.fg("muted", " to suspend") +
169
196
  "\n" +
170
- theme.fg("dim", "ctrl+k") +
197
+ theme.fg("dim", deleteToLineEnd) +
171
198
  theme.fg("muted", " to delete line") +
172
199
  "\n" +
173
- theme.fg("dim", "shift+tab") +
200
+ theme.fg("dim", cycleThinkingLevel) +
174
201
  theme.fg("muted", " to cycle thinking") +
175
202
  "\n" +
176
- theme.fg("dim", "ctrl+p/shift+ctrl+p") +
203
+ theme.fg("dim", `${cycleModelForward}/${cycleModelBackward}`) +
177
204
  theme.fg("muted", " to cycle models") +
178
205
  "\n" +
179
- theme.fg("dim", "ctrl+l") +
206
+ theme.fg("dim", selectModel) +
180
207
  theme.fg("muted", " to select model") +
181
208
  "\n" +
182
- theme.fg("dim", "ctrl+o") +
209
+ theme.fg("dim", expandTools) +
183
210
  theme.fg("muted", " to expand tools") +
184
211
  "\n" +
185
- theme.fg("dim", "ctrl+t") +
212
+ theme.fg("dim", toggleThinking) +
186
213
  theme.fg("muted", " to toggle thinking") +
187
214
  "\n" +
188
- theme.fg("dim", "ctrl+g") +
215
+ theme.fg("dim", externalEditor) +
189
216
  theme.fg("muted", " for external editor") +
190
217
  "\n" +
191
218
  theme.fg("dim", "/") +
@@ -194,9 +221,12 @@ export class InteractiveMode {
194
221
  theme.fg("dim", "!") +
195
222
  theme.fg("muted", " to run bash") +
196
223
  "\n" +
197
- theme.fg("dim", "alt+enter") +
224
+ theme.fg("dim", followUp) +
198
225
  theme.fg("muted", " to queue follow-up") +
199
226
  "\n" +
227
+ theme.fg("dim", "ctrl+v") +
228
+ theme.fg("muted", " to paste image") +
229
+ "\n" +
200
230
  theme.fg("dim", "drop files") +
201
231
  theme.fg("muted", " to attach");
202
232
  const header = new Text(`${logo}\n${instructions}`, 1, 0);
@@ -636,19 +666,20 @@ export class InteractiveMode {
636
666
  }
637
667
  }
638
668
  };
639
- this.editor.onCtrlC = () => this.handleCtrlC();
669
+ // Register app action handlers
670
+ this.editor.onAction("clear", () => this.handleCtrlC());
640
671
  this.editor.onCtrlD = () => this.handleCtrlD();
641
- this.editor.onCtrlZ = () => this.handleCtrlZ();
642
- this.editor.onShiftTab = () => this.cycleThinkingLevel();
643
- this.editor.onCtrlP = () => this.cycleModel("forward");
644
- this.editor.onShiftCtrlP = () => this.cycleModel("backward");
672
+ this.editor.onAction("suspend", () => this.handleCtrlZ());
673
+ this.editor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel());
674
+ this.editor.onAction("cycleModelForward", () => this.cycleModel("forward"));
675
+ this.editor.onAction("cycleModelBackward", () => this.cycleModel("backward"));
645
676
  // Global debug handler on TUI (works regardless of focus)
646
677
  this.ui.onDebug = () => this.handleDebugCommand();
647
- this.editor.onCtrlL = () => this.showModelSelector();
648
- this.editor.onCtrlO = () => this.toggleToolOutputExpansion();
649
- this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility();
650
- this.editor.onCtrlG = () => this.openExternalEditor();
651
- this.editor.onAltEnter = () => this.handleAltEnter();
678
+ this.editor.onAction("selectModel", () => this.showModelSelector());
679
+ this.editor.onAction("expandTools", () => this.toggleToolOutputExpansion());
680
+ this.editor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility());
681
+ this.editor.onAction("externalEditor", () => this.openExternalEditor());
682
+ this.editor.onAction("followUp", () => this.handleFollowUp());
652
683
  this.editor.onChange = (text) => {
653
684
  const wasBashMode = this.isBashMode;
654
685
  this.isBashMode = text.trimStart().startsWith("!");
@@ -656,6 +687,47 @@ export class InteractiveMode {
656
687
  this.updateEditorBorderColor();
657
688
  }
658
689
  };
690
+ // Handle clipboard image paste (triggered on Ctrl+V)
691
+ this.editor.onPasteImage = () => {
692
+ this.handleClipboardImagePaste();
693
+ };
694
+ }
695
+ async handleClipboardImagePaste() {
696
+ try {
697
+ if (!Clipboard.hasImage()) {
698
+ return;
699
+ }
700
+ const imageData = await Clipboard.getImageBinary();
701
+ if (!imageData || imageData.length === 0) {
702
+ return;
703
+ }
704
+ // Write to temp file
705
+ const imageId = ++this.clipboardImageCounter;
706
+ const tmpDir = os.tmpdir();
707
+ const fileName = `pi-clipboard-${crypto.randomUUID()}.png`;
708
+ const filePath = path.join(tmpDir, fileName);
709
+ fs.writeFileSync(filePath, Buffer.from(imageData));
710
+ // Store mapping and insert marker
711
+ this.clipboardImages.set(imageId, filePath);
712
+ this.editor.insertTextAtCursor(`[image #${imageId}]`);
713
+ this.ui.requestRender();
714
+ }
715
+ catch {
716
+ // Silently ignore clipboard errors (may not have permission, etc.)
717
+ }
718
+ }
719
+ /**
720
+ * Replace [image #N] markers with actual file paths and clear the image map.
721
+ */
722
+ replaceImageMarkers(text) {
723
+ let result = text;
724
+ for (const [imageId, filePath] of this.clipboardImages) {
725
+ const marker = `[image #${imageId}]`;
726
+ result = result.replace(marker, filePath);
727
+ }
728
+ this.clipboardImages.clear();
729
+ this.clipboardImageCounter = 0;
730
+ return result;
659
731
  }
660
732
  setupEditorSubmitHandler() {
661
733
  this.editor.onSubmit = async (text) => {
@@ -755,6 +827,11 @@ export class InteractiveMode {
755
827
  this.editor.setText("");
756
828
  return;
757
829
  }
830
+ if (text === "/quit" || text === "/exit") {
831
+ this.editor.setText("");
832
+ await this.shutdown();
833
+ return;
834
+ }
758
835
  // Handle bash command (! for normal, !! for excluded from context)
759
836
  if (text.startsWith("!")) {
760
837
  const isExcluded = text.startsWith("!!");
@@ -777,6 +854,8 @@ export class InteractiveMode {
777
854
  return;
778
855
  }
779
856
  // If streaming, use prompt() with steer behavior
857
+ // Replace image markers with actual file paths
858
+ text = this.replaceImageMarkers(text);
780
859
  // This handles hook commands (execute immediately), slash command expansion, and queueing
781
860
  if (this.session.isStreaming) {
782
861
  this.editor.addToHistory(text);
@@ -1228,7 +1307,7 @@ export class InteractiveMode {
1228
1307
  // Send SIGTSTP to process group (pid=0 means all processes in group)
1229
1308
  process.kill(0, "SIGTSTP");
1230
1309
  }
1231
- async handleAltEnter() {
1310
+ async handleFollowUp() {
1232
1311
  const text = this.editor.getText().trim();
1233
1312
  if (!text)
1234
1313
  return;
@@ -1920,38 +1999,89 @@ export class InteractiveMode {
1920
1999
  this.chatContainer.addChild(new DynamicBorder());
1921
2000
  this.ui.requestRender();
1922
2001
  }
2002
+ /**
2003
+ * Format keybindings for display (e.g., "ctrl+c" -> "Ctrl+C").
2004
+ */
2005
+ formatKeyDisplay(keys) {
2006
+ const keyArray = Array.isArray(keys) ? keys : [keys];
2007
+ return keyArray
2008
+ .map((key) => key
2009
+ .split("+")
2010
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
2011
+ .join("+"))
2012
+ .join("/");
2013
+ }
2014
+ /**
2015
+ * Get display string for an app keybinding action.
2016
+ */
2017
+ getAppKeyDisplay(action) {
2018
+ const display = this.keybindings.getDisplayString(action);
2019
+ return this.formatKeyDisplay(display);
2020
+ }
2021
+ /**
2022
+ * Get display string for an editor keybinding action.
2023
+ */
2024
+ getEditorKeyDisplay(action) {
2025
+ const keys = getEditorKeybindings().getKeys(action);
2026
+ return this.formatKeyDisplay(keys);
2027
+ }
1923
2028
  handleHotkeysCommand() {
2029
+ // Navigation keybindings
2030
+ const cursorWordLeft = this.getEditorKeyDisplay("cursorWordLeft");
2031
+ const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight");
2032
+ const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart");
2033
+ const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd");
2034
+ // Editing keybindings
2035
+ const submit = this.getEditorKeyDisplay("submit");
2036
+ const newLine = this.getEditorKeyDisplay("newLine");
2037
+ const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward");
2038
+ const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart");
2039
+ const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd");
2040
+ const tab = this.getEditorKeyDisplay("tab");
2041
+ // App keybindings
2042
+ const interrupt = this.getAppKeyDisplay("interrupt");
2043
+ const clear = this.getAppKeyDisplay("clear");
2044
+ const exit = this.getAppKeyDisplay("exit");
2045
+ const suspend = this.getAppKeyDisplay("suspend");
2046
+ const cycleThinkingLevel = this.getAppKeyDisplay("cycleThinkingLevel");
2047
+ const cycleModelForward = this.getAppKeyDisplay("cycleModelForward");
2048
+ const expandTools = this.getAppKeyDisplay("expandTools");
2049
+ const toggleThinking = this.getAppKeyDisplay("toggleThinking");
2050
+ const externalEditor = this.getAppKeyDisplay("externalEditor");
2051
+ const followUp = this.getAppKeyDisplay("followUp");
1924
2052
  const hotkeys = `
1925
2053
  **Navigation**
1926
2054
  | Key | Action |
1927
2055
  |-----|--------|
1928
2056
  | \`Arrow keys\` | Move cursor / browse history (Up when empty) |
1929
- | \`Option+Left/Right\` | Move by word |
1930
- | \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line |
1931
- | \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line |
2057
+ | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
2058
+ | \`${cursorLineStart}\` | Start of line |
2059
+ | \`${cursorLineEnd}\` | End of line |
1932
2060
 
1933
2061
  **Editing**
1934
2062
  | Key | Action |
1935
2063
  |-----|--------|
1936
- | \`Enter\` | Send message |
1937
- | \`Shift+Enter\` / \`Alt+Enter\` | New line |
1938
- | \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards |
1939
- | \`Ctrl+U\` | Delete to start of line |
1940
- | \`Ctrl+K\` | Delete to end of line |
2064
+ | \`${submit}\` | Send message |
2065
+ | \`${newLine}\` | New line |
2066
+ | \`${deleteWordBackward}\` | Delete word backwards |
2067
+ | \`${deleteToLineStart}\` | Delete to start of line |
2068
+ | \`${deleteToLineEnd}\` | Delete to end of line |
1941
2069
 
1942
2070
  **Other**
1943
2071
  | Key | Action |
1944
2072
  |-----|--------|
1945
- | \`Tab\` | Path completion / accept autocomplete |
1946
- | \`Escape\` | Cancel autocomplete / abort streaming |
1947
- | \`Ctrl+C\` | Clear editor (first) / exit (second) |
1948
- | \`Ctrl+D\` | Exit (when editor is empty) |
1949
- | \`Ctrl+Z\` | Suspend to background |
1950
- | \`Shift+Tab\` | Cycle thinking level |
1951
- | \`Ctrl+P\` | Cycle models |
1952
- | \`Ctrl+O\` | Toggle tool output expansion |
1953
- | \`Ctrl+T\` | Toggle thinking block visibility |
1954
- | \`Ctrl+G\` | Edit message in external editor |
2073
+ | \`${tab}\` | Path completion / accept autocomplete |
2074
+ | \`${interrupt}\` | Cancel autocomplete / abort streaming |
2075
+ | \`${clear}\` | Clear editor (first) / exit (second) |
2076
+ | \`${exit}\` | Exit (when editor is empty) |
2077
+ | \`${suspend}\` | Suspend to background |
2078
+ | \`${cycleThinkingLevel}\` | Cycle thinking level |
2079
+ | \`${cycleModelForward}\` | Cycle models |
2080
+ | \`${expandTools}\` | Toggle tool output expansion |
2081
+ | \`${toggleThinking}\` | Toggle thinking block visibility |
2082
+ | \`${externalEditor}\` | Edit message in external editor |
2083
+ | \`${followUp}\` | Queue follow-up message |
2084
+ | \`Ctrl+V\` | Paste image from clipboard |
1955
2085
  | \`/\` | Slash commands |
1956
2086
  | \`!\` | Run bash command |
1957
2087
  `;