@mariozechner/pi-coding-agent 0.32.3 → 0.34.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 (102) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +76 -3
  3. package/dist/cli/args.d.ts +5 -1
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +18 -1
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/agent-session.d.ts +24 -1
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +65 -9
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/bash-executor.d.ts.map +1 -1
  12. package/dist/core/bash-executor.js +2 -1
  13. package/dist/core/bash-executor.js.map +1 -1
  14. package/dist/core/custom-tools/loader.d.ts.map +1 -1
  15. package/dist/core/custom-tools/loader.js +1 -0
  16. package/dist/core/custom-tools/loader.js.map +1 -1
  17. package/dist/core/export-html/template.css +34 -4
  18. package/dist/core/export-html/template.js +17 -4
  19. package/dist/core/hooks/index.d.ts +1 -1
  20. package/dist/core/hooks/index.d.ts.map +1 -1
  21. package/dist/core/hooks/index.js.map +1 -1
  22. package/dist/core/hooks/loader.d.ts +56 -1
  23. package/dist/core/hooks/loader.d.ts.map +1 -1
  24. package/dist/core/hooks/loader.js +54 -2
  25. package/dist/core/hooks/loader.js.map +1 -1
  26. package/dist/core/hooks/runner.d.ts +33 -5
  27. package/dist/core/hooks/runner.d.ts.map +1 -1
  28. package/dist/core/hooks/runner.js +100 -9
  29. package/dist/core/hooks/runner.js.map +1 -1
  30. package/dist/core/hooks/types.d.ts +135 -3
  31. package/dist/core/hooks/types.d.ts.map +1 -1
  32. package/dist/core/hooks/types.js.map +1 -1
  33. package/dist/core/keybindings.d.ts +59 -0
  34. package/dist/core/keybindings.d.ts.map +1 -0
  35. package/dist/core/keybindings.js +149 -0
  36. package/dist/core/keybindings.js.map +1 -0
  37. package/dist/core/sdk.d.ts +3 -0
  38. package/dist/core/sdk.d.ts.map +1 -1
  39. package/dist/core/sdk.js +102 -27
  40. package/dist/core/sdk.js.map +1 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/main.d.ts.map +1 -1
  46. package/dist/main.js +32 -7
  47. package/dist/main.js.map +1 -1
  48. package/dist/modes/interactive/components/custom-editor.d.ts +13 -12
  49. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  50. package/dist/modes/interactive/components/custom-editor.js +50 -68
  51. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  52. package/dist/modes/interactive/components/hook-editor.d.ts.map +1 -1
  53. package/dist/modes/interactive/components/hook-editor.js +5 -4
  54. package/dist/modes/interactive/components/hook-editor.js.map +1 -1
  55. package/dist/modes/interactive/components/hook-input.d.ts.map +1 -1
  56. package/dist/modes/interactive/components/hook-input.js +4 -3
  57. package/dist/modes/interactive/components/hook-input.js.map +1 -1
  58. package/dist/modes/interactive/components/hook-selector.d.ts.map +1 -1
  59. package/dist/modes/interactive/components/hook-selector.js +6 -5
  60. package/dist/modes/interactive/components/hook-selector.js.map +1 -1
  61. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  62. package/dist/modes/interactive/components/model-selector.js +6 -5
  63. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  64. package/dist/modes/interactive/components/oauth-selector.d.ts.map +1 -1
  65. package/dist/modes/interactive/components/oauth-selector.js +6 -5
  66. package/dist/modes/interactive/components/oauth-selector.js.map +1 -1
  67. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  68. package/dist/modes/interactive/components/session-selector.js +6 -9
  69. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  70. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  71. package/dist/modes/interactive/components/tree-selector.js +14 -15
  72. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  73. package/dist/modes/interactive/components/user-message-selector.d.ts.map +1 -1
  74. package/dist/modes/interactive/components/user-message-selector.js +6 -11
  75. package/dist/modes/interactive/components/user-message-selector.js.map +1 -1
  76. package/dist/modes/interactive/interactive-mode.d.ts +34 -1
  77. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  78. package/dist/modes/interactive/interactive-mode.js +300 -64
  79. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  80. package/dist/modes/interactive/theme/theme.d.ts +1 -0
  81. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  82. package/dist/modes/interactive/theme/theme.js +3 -0
  83. package/dist/modes/interactive/theme/theme.js.map +1 -1
  84. package/dist/modes/print-mode.d.ts.map +1 -1
  85. package/dist/modes/print-mode.js +3 -0
  86. package/dist/modes/print-mode.js.map +1 -1
  87. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  88. package/dist/modes/rpc/rpc-mode.js +16 -0
  89. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  90. package/dist/modes/rpc/rpc-types.d.ts +6 -0
  91. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  92. package/dist/modes/rpc/rpc-types.js.map +1 -1
  93. package/docs/hooks.md +114 -4
  94. package/docs/tui.md +18 -15
  95. package/examples/custom-tools/subagent/README.md +2 -2
  96. package/examples/hooks/README.md +3 -0
  97. package/examples/hooks/pirate.ts +44 -0
  98. package/examples/hooks/plan-mode.ts +548 -0
  99. package/examples/hooks/snake.ts +7 -7
  100. package/examples/hooks/todo/index.ts +2 -2
  101. package/examples/hooks/tools.ts +145 -0
  102. 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, matchesKey, 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;
@@ -86,6 +90,9 @@ export class InteractiveMode {
86
90
  hookSelector = undefined;
87
91
  hookInput = undefined;
88
92
  hookEditor = undefined;
93
+ // Hook widgets (components rendered above the editor)
94
+ hookWidgets = new Map();
95
+ widgetContainer;
89
96
  // Custom tools for custom rendering
90
97
  customTools;
91
98
  // Convenience accessors
@@ -108,7 +115,9 @@ 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.widgetContainer = new Container();
119
+ this.keybindings = KeybindingsManager.create();
120
+ this.editor = new CustomEditor(getEditorTheme(), this.keybindings);
112
121
  this.editorContainer = new Container();
113
122
  this.editorContainer.addChild(this.editor);
114
123
  this.footer = new FooterComponent(session);
@@ -150,42 +159,61 @@ export class InteractiveMode {
150
159
  async init() {
151
160
  if (this.isInitialized)
152
161
  return;
153
- // Add header
162
+ // Add header with keybindings from config
154
163
  const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
155
- const instructions = theme.fg("dim", "esc") +
164
+ // Format keybinding for startup display (lowercase, compact)
165
+ const formatStartupKey = (keys) => {
166
+ const keyArray = Array.isArray(keys) ? keys : [keys];
167
+ return keyArray.join("/");
168
+ };
169
+ const kb = this.keybindings;
170
+ const interrupt = formatStartupKey(kb.getKeys("interrupt"));
171
+ const clear = formatStartupKey(kb.getKeys("clear"));
172
+ const exit = formatStartupKey(kb.getKeys("exit"));
173
+ const suspend = formatStartupKey(kb.getKeys("suspend"));
174
+ const deleteToLineEnd = formatStartupKey(getEditorKeybindings().getKeys("deleteToLineEnd"));
175
+ const cycleThinkingLevel = formatStartupKey(kb.getKeys("cycleThinkingLevel"));
176
+ const cycleModelForward = formatStartupKey(kb.getKeys("cycleModelForward"));
177
+ const cycleModelBackward = formatStartupKey(kb.getKeys("cycleModelBackward"));
178
+ const selectModel = formatStartupKey(kb.getKeys("selectModel"));
179
+ const expandTools = formatStartupKey(kb.getKeys("expandTools"));
180
+ const toggleThinking = formatStartupKey(kb.getKeys("toggleThinking"));
181
+ const externalEditor = formatStartupKey(kb.getKeys("externalEditor"));
182
+ const followUp = formatStartupKey(kb.getKeys("followUp"));
183
+ const instructions = theme.fg("dim", interrupt) +
156
184
  theme.fg("muted", " to interrupt") +
157
185
  "\n" +
158
- theme.fg("dim", "ctrl+c") +
186
+ theme.fg("dim", clear) +
159
187
  theme.fg("muted", " to clear") +
160
188
  "\n" +
161
- theme.fg("dim", "ctrl+c twice") +
189
+ theme.fg("dim", `${clear} twice`) +
162
190
  theme.fg("muted", " to exit") +
163
191
  "\n" +
164
- theme.fg("dim", "ctrl+d") +
192
+ theme.fg("dim", exit) +
165
193
  theme.fg("muted", " to exit (empty)") +
166
194
  "\n" +
167
- theme.fg("dim", "ctrl+z") +
195
+ theme.fg("dim", suspend) +
168
196
  theme.fg("muted", " to suspend") +
169
197
  "\n" +
170
- theme.fg("dim", "ctrl+k") +
198
+ theme.fg("dim", deleteToLineEnd) +
171
199
  theme.fg("muted", " to delete line") +
172
200
  "\n" +
173
- theme.fg("dim", "shift+tab") +
201
+ theme.fg("dim", cycleThinkingLevel) +
174
202
  theme.fg("muted", " to cycle thinking") +
175
203
  "\n" +
176
- theme.fg("dim", "ctrl+p/shift+ctrl+p") +
204
+ theme.fg("dim", `${cycleModelForward}/${cycleModelBackward}`) +
177
205
  theme.fg("muted", " to cycle models") +
178
206
  "\n" +
179
- theme.fg("dim", "ctrl+l") +
207
+ theme.fg("dim", selectModel) +
180
208
  theme.fg("muted", " to select model") +
181
209
  "\n" +
182
- theme.fg("dim", "ctrl+o") +
210
+ theme.fg("dim", expandTools) +
183
211
  theme.fg("muted", " to expand tools") +
184
212
  "\n" +
185
- theme.fg("dim", "ctrl+t") +
213
+ theme.fg("dim", toggleThinking) +
186
214
  theme.fg("muted", " to toggle thinking") +
187
215
  "\n" +
188
- theme.fg("dim", "ctrl+g") +
216
+ theme.fg("dim", externalEditor) +
189
217
  theme.fg("muted", " for external editor") +
190
218
  "\n" +
191
219
  theme.fg("dim", "/") +
@@ -194,9 +222,12 @@ export class InteractiveMode {
194
222
  theme.fg("dim", "!") +
195
223
  theme.fg("muted", " to run bash") +
196
224
  "\n" +
197
- theme.fg("dim", "alt+enter") +
225
+ theme.fg("dim", followUp) +
198
226
  theme.fg("muted", " to queue follow-up") +
199
227
  "\n" +
228
+ theme.fg("dim", "ctrl+v") +
229
+ theme.fg("muted", " to paste image") +
230
+ "\n" +
200
231
  theme.fg("dim", "drop files") +
201
232
  theme.fg("muted", " to attach");
202
233
  const header = new Text(`${logo}\n${instructions}`, 1, 0);
@@ -224,6 +255,7 @@ export class InteractiveMode {
224
255
  this.ui.addChild(this.chatContainer);
225
256
  this.ui.addChild(this.pendingMessagesContainer);
226
257
  this.ui.addChild(this.statusContainer);
258
+ this.ui.addChild(this.widgetContainer);
227
259
  this.ui.addChild(new Spacer(1));
228
260
  this.ui.addChild(this.editorContainer);
229
261
  this.ui.addChild(this.footer);
@@ -292,20 +324,7 @@ export class InteractiveMode {
292
324
  this.chatContainer.addChild(new Spacer(1));
293
325
  }
294
326
  // Create and set hook & tool UI context
295
- const uiContext = {
296
- select: (title, options) => this.showHookSelector(title, options),
297
- confirm: (title, message) => this.showHookConfirm(title, message),
298
- input: (title, placeholder) => this.showHookInput(title, placeholder),
299
- notify: (message, type) => this.showHookNotify(message, type),
300
- setStatus: (key, text) => this.setHookStatus(key, text),
301
- custom: (factory) => this.showHookCustom(factory),
302
- setEditorText: (text) => this.editor.setText(text),
303
- getEditorText: () => this.editor.getText(),
304
- editor: (title, prefill) => this.showHookEditor(title, prefill),
305
- get theme() {
306
- return theme;
307
- },
308
- };
327
+ const uiContext = this.createHookUIContext();
309
328
  this.setToolUIContext(uiContext, true);
310
329
  // Notify custom tools of session start
311
330
  await this.emitCustomToolSessionEvent({
@@ -336,6 +355,9 @@ export class InteractiveMode {
336
355
  appendEntryHandler: (customType, data) => {
337
356
  this.sessionManager.appendCustomEntry(customType, data);
338
357
  },
358
+ getActiveToolsHandler: () => this.session.getActiveToolNames(),
359
+ getAllToolsHandler: () => this.session.getAllToolNames(),
360
+ setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames),
339
361
  newSessionHandler: async (options) => {
340
362
  // Stop any loading animation
341
363
  if (this.loadingAnimation) {
@@ -400,8 +422,10 @@ export class InteractiveMode {
400
422
  });
401
423
  // Subscribe to hook errors
402
424
  hookRunner.onError((error) => {
403
- this.showHookError(error.hookPath, error.error);
425
+ this.showHookError(error.hookPath, error.error, error.stack);
404
426
  });
427
+ // Set up hook-registered shortcuts
428
+ this.setupHookShortcuts(hookRunner);
405
429
  // Show loaded hooks
406
430
  const hookPaths = hookRunner.getHookPaths();
407
431
  if (hookPaths.length > 0) {
@@ -446,6 +470,40 @@ export class InteractiveMode {
446
470
  this.chatContainer.addChild(errorText);
447
471
  this.ui.requestRender();
448
472
  }
473
+ /**
474
+ * Set up keyboard shortcuts registered by hooks.
475
+ */
476
+ setupHookShortcuts(hookRunner) {
477
+ const shortcuts = hookRunner.getShortcuts();
478
+ if (shortcuts.size === 0)
479
+ return;
480
+ // Create a context for shortcut handlers
481
+ const createContext = () => ({
482
+ ui: this.createHookUIContext(),
483
+ hasUI: true,
484
+ cwd: process.cwd(),
485
+ sessionManager: this.sessionManager,
486
+ modelRegistry: this.session.modelRegistry,
487
+ model: this.session.model,
488
+ isIdle: () => !this.session.isStreaming,
489
+ abort: () => this.session.abort(),
490
+ hasPendingMessages: () => this.session.pendingMessageCount > 0,
491
+ });
492
+ // Set up the hook shortcut handler on the editor
493
+ this.editor.onHookShortcut = (data) => {
494
+ for (const [shortcutStr, shortcut] of shortcuts) {
495
+ // Cast to KeyId - hook shortcuts use the same format
496
+ if (matchesKey(data, shortcutStr)) {
497
+ // Run handler async, don't block input
498
+ Promise.resolve(shortcut.handler(createContext())).catch((err) => {
499
+ this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`);
500
+ });
501
+ return true;
502
+ }
503
+ }
504
+ return false;
505
+ };
506
+ }
449
507
  /**
450
508
  * Set hook status text in the footer.
451
509
  */
@@ -453,6 +511,73 @@ export class InteractiveMode {
453
511
  this.footer.setHookStatus(key, text);
454
512
  this.ui.requestRender();
455
513
  }
514
+ /**
515
+ * Set a hook widget (string array or custom component).
516
+ */
517
+ setHookWidget(key, content) {
518
+ // Dispose and remove existing widget
519
+ const existing = this.hookWidgets.get(key);
520
+ if (existing?.dispose)
521
+ existing.dispose();
522
+ if (content === undefined) {
523
+ this.hookWidgets.delete(key);
524
+ }
525
+ else if (Array.isArray(content)) {
526
+ // Wrap string array in a Container with Text components
527
+ const container = new Container();
528
+ for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) {
529
+ container.addChild(new Text(line, 1, 0));
530
+ }
531
+ if (content.length > InteractiveMode.MAX_WIDGET_LINES) {
532
+ container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
533
+ }
534
+ this.hookWidgets.set(key, container);
535
+ }
536
+ else {
537
+ // Factory function - create component
538
+ const component = content(this.ui, theme);
539
+ this.hookWidgets.set(key, component);
540
+ }
541
+ this.renderWidgets();
542
+ }
543
+ // Maximum total widget lines to prevent viewport overflow
544
+ static MAX_WIDGET_LINES = 10;
545
+ /**
546
+ * Render all hook widgets to the widget container.
547
+ */
548
+ renderWidgets() {
549
+ if (!this.widgetContainer)
550
+ return;
551
+ this.widgetContainer.clear();
552
+ if (this.hookWidgets.size === 0) {
553
+ this.ui.requestRender();
554
+ return;
555
+ }
556
+ for (const [_key, component] of this.hookWidgets) {
557
+ this.widgetContainer.addChild(component);
558
+ }
559
+ this.ui.requestRender();
560
+ }
561
+ /**
562
+ * Create the HookUIContext for hooks and tools.
563
+ */
564
+ createHookUIContext() {
565
+ return {
566
+ select: (title, options) => this.showHookSelector(title, options),
567
+ confirm: (title, message) => this.showHookConfirm(title, message),
568
+ input: (title, placeholder) => this.showHookInput(title, placeholder),
569
+ notify: (message, type) => this.showHookNotify(message, type),
570
+ setStatus: (key, text) => this.setHookStatus(key, text),
571
+ setWidget: (key, content) => this.setHookWidget(key, content),
572
+ custom: (factory) => this.showHookCustom(factory),
573
+ setEditorText: (text) => this.editor.setText(text),
574
+ getEditorText: () => this.editor.getText(),
575
+ editor: (title, prefill) => this.showHookEditor(title, prefill),
576
+ get theme() {
577
+ return theme;
578
+ },
579
+ };
580
+ }
456
581
  /**
457
582
  * Show a selector for hooks.
458
583
  */
@@ -586,9 +711,21 @@ export class InteractiveMode {
586
711
  /**
587
712
  * Show a hook error in the UI.
588
713
  */
589
- showHookError(hookPath, error) {
590
- const errorText = new Text(theme.fg("error", `Hook "${hookPath}" error: ${error}`), 1, 0);
714
+ showHookError(hookPath, error, stack) {
715
+ const errorMsg = `Hook "${hookPath}" error: ${error}`;
716
+ const errorText = new Text(theme.fg("error", errorMsg), 1, 0);
591
717
  this.chatContainer.addChild(errorText);
718
+ if (stack) {
719
+ // Show stack trace in dim color, indented
720
+ const stackLines = stack
721
+ .split("\n")
722
+ .slice(1) // Skip first line (duplicates error message)
723
+ .map((line) => theme.fg("dim", ` ${line.trim()}`))
724
+ .join("\n");
725
+ if (stackLines) {
726
+ this.chatContainer.addChild(new Text(stackLines, 1, 0));
727
+ }
728
+ }
592
729
  this.ui.requestRender();
593
730
  }
594
731
  /**
@@ -636,19 +773,20 @@ export class InteractiveMode {
636
773
  }
637
774
  }
638
775
  };
639
- this.editor.onCtrlC = () => this.handleCtrlC();
776
+ // Register app action handlers
777
+ this.editor.onAction("clear", () => this.handleCtrlC());
640
778
  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");
779
+ this.editor.onAction("suspend", () => this.handleCtrlZ());
780
+ this.editor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel());
781
+ this.editor.onAction("cycleModelForward", () => this.cycleModel("forward"));
782
+ this.editor.onAction("cycleModelBackward", () => this.cycleModel("backward"));
645
783
  // Global debug handler on TUI (works regardless of focus)
646
784
  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();
785
+ this.editor.onAction("selectModel", () => this.showModelSelector());
786
+ this.editor.onAction("expandTools", () => this.toggleToolOutputExpansion());
787
+ this.editor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility());
788
+ this.editor.onAction("externalEditor", () => this.openExternalEditor());
789
+ this.editor.onAction("followUp", () => this.handleFollowUp());
652
790
  this.editor.onChange = (text) => {
653
791
  const wasBashMode = this.isBashMode;
654
792
  this.isBashMode = text.trimStart().startsWith("!");
@@ -656,6 +794,32 @@ export class InteractiveMode {
656
794
  this.updateEditorBorderColor();
657
795
  }
658
796
  };
797
+ // Handle clipboard image paste (triggered on Ctrl+V)
798
+ this.editor.onPasteImage = () => {
799
+ this.handleClipboardImagePaste();
800
+ };
801
+ }
802
+ async handleClipboardImagePaste() {
803
+ try {
804
+ if (!Clipboard.hasImage()) {
805
+ return;
806
+ }
807
+ const imageData = await Clipboard.getImageBinary();
808
+ if (!imageData || imageData.length === 0) {
809
+ return;
810
+ }
811
+ // Write to temp file
812
+ const tmpDir = os.tmpdir();
813
+ const fileName = `pi-clipboard-${crypto.randomUUID()}.png`;
814
+ const filePath = path.join(tmpDir, fileName);
815
+ fs.writeFileSync(filePath, Buffer.from(imageData));
816
+ // Insert file path directly
817
+ this.editor.insertTextAtCursor(filePath);
818
+ this.ui.requestRender();
819
+ }
820
+ catch {
821
+ // Silently ignore clipboard errors (may not have permission, etc.)
822
+ }
659
823
  }
660
824
  setupEditorSubmitHandler() {
661
825
  this.editor.onSubmit = async (text) => {
@@ -755,6 +919,11 @@ export class InteractiveMode {
755
919
  this.editor.setText("");
756
920
  return;
757
921
  }
922
+ if (text === "/quit" || text === "/exit") {
923
+ this.editor.setText("");
924
+ await this.shutdown();
925
+ return;
926
+ }
758
927
  // Handle bash command (! for normal, !! for excluded from context)
759
928
  if (text.startsWith("!")) {
760
929
  const isExcluded = text.startsWith("!!");
@@ -1228,7 +1397,7 @@ export class InteractiveMode {
1228
1397
  // Send SIGTSTP to process group (pid=0 means all processes in group)
1229
1398
  process.kill(0, "SIGTSTP");
1230
1399
  }
1231
- async handleAltEnter() {
1400
+ async handleFollowUp() {
1232
1401
  const text = this.editor.getText().trim();
1233
1402
  if (!text)
1234
1403
  return;
@@ -1315,7 +1484,7 @@ export class InteractiveMode {
1315
1484
  this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
1316
1485
  return;
1317
1486
  }
1318
- const currentText = this.editor.getText();
1487
+ const currentText = this.editor.getExpandedText();
1319
1488
  const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);
1320
1489
  try {
1321
1490
  // Write current content to temp file
@@ -1920,41 +2089,108 @@ export class InteractiveMode {
1920
2089
  this.chatContainer.addChild(new DynamicBorder());
1921
2090
  this.ui.requestRender();
1922
2091
  }
2092
+ /**
2093
+ * Format keybindings for display (e.g., "ctrl+c" -> "Ctrl+C").
2094
+ */
2095
+ formatKeyDisplay(keys) {
2096
+ const keyArray = Array.isArray(keys) ? keys : [keys];
2097
+ return keyArray
2098
+ .map((key) => key
2099
+ .split("+")
2100
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
2101
+ .join("+"))
2102
+ .join("/");
2103
+ }
2104
+ /**
2105
+ * Get display string for an app keybinding action.
2106
+ */
2107
+ getAppKeyDisplay(action) {
2108
+ const display = this.keybindings.getDisplayString(action);
2109
+ return this.formatKeyDisplay(display);
2110
+ }
2111
+ /**
2112
+ * Get display string for an editor keybinding action.
2113
+ */
2114
+ getEditorKeyDisplay(action) {
2115
+ const keys = getEditorKeybindings().getKeys(action);
2116
+ return this.formatKeyDisplay(keys);
2117
+ }
1923
2118
  handleHotkeysCommand() {
1924
- const hotkeys = `
2119
+ // Navigation keybindings
2120
+ const cursorWordLeft = this.getEditorKeyDisplay("cursorWordLeft");
2121
+ const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight");
2122
+ const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart");
2123
+ const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd");
2124
+ // Editing keybindings
2125
+ const submit = this.getEditorKeyDisplay("submit");
2126
+ const newLine = this.getEditorKeyDisplay("newLine");
2127
+ const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward");
2128
+ const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart");
2129
+ const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd");
2130
+ const tab = this.getEditorKeyDisplay("tab");
2131
+ // App keybindings
2132
+ const interrupt = this.getAppKeyDisplay("interrupt");
2133
+ const clear = this.getAppKeyDisplay("clear");
2134
+ const exit = this.getAppKeyDisplay("exit");
2135
+ const suspend = this.getAppKeyDisplay("suspend");
2136
+ const cycleThinkingLevel = this.getAppKeyDisplay("cycleThinkingLevel");
2137
+ const cycleModelForward = this.getAppKeyDisplay("cycleModelForward");
2138
+ const expandTools = this.getAppKeyDisplay("expandTools");
2139
+ const toggleThinking = this.getAppKeyDisplay("toggleThinking");
2140
+ const externalEditor = this.getAppKeyDisplay("externalEditor");
2141
+ const followUp = this.getAppKeyDisplay("followUp");
2142
+ let hotkeys = `
1925
2143
  **Navigation**
1926
2144
  | Key | Action |
1927
2145
  |-----|--------|
1928
2146
  | \`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 |
2147
+ | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
2148
+ | \`${cursorLineStart}\` | Start of line |
2149
+ | \`${cursorLineEnd}\` | End of line |
1932
2150
 
1933
2151
  **Editing**
1934
2152
  | Key | Action |
1935
2153
  |-----|--------|
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 |
2154
+ | \`${submit}\` | Send message |
2155
+ | \`${newLine}\` | New line |
2156
+ | \`${deleteWordBackward}\` | Delete word backwards |
2157
+ | \`${deleteToLineStart}\` | Delete to start of line |
2158
+ | \`${deleteToLineEnd}\` | Delete to end of line |
1941
2159
 
1942
2160
  **Other**
1943
2161
  | Key | Action |
1944
2162
  |-----|--------|
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 |
2163
+ | \`${tab}\` | Path completion / accept autocomplete |
2164
+ | \`${interrupt}\` | Cancel autocomplete / abort streaming |
2165
+ | \`${clear}\` | Clear editor (first) / exit (second) |
2166
+ | \`${exit}\` | Exit (when editor is empty) |
2167
+ | \`${suspend}\` | Suspend to background |
2168
+ | \`${cycleThinkingLevel}\` | Cycle thinking level |
2169
+ | \`${cycleModelForward}\` | Cycle models |
2170
+ | \`${expandTools}\` | Toggle tool output expansion |
2171
+ | \`${toggleThinking}\` | Toggle thinking block visibility |
2172
+ | \`${externalEditor}\` | Edit message in external editor |
2173
+ | \`${followUp}\` | Queue follow-up message |
2174
+ | \`Ctrl+V\` | Paste image from clipboard |
1955
2175
  | \`/\` | Slash commands |
1956
2176
  | \`!\` | Run bash command |
1957
2177
  `;
2178
+ // Add hook-registered shortcuts
2179
+ const hookRunner = this.session.hookRunner;
2180
+ if (hookRunner) {
2181
+ const shortcuts = hookRunner.getShortcuts();
2182
+ if (shortcuts.size > 0) {
2183
+ hotkeys += `
2184
+ **Hooks**
2185
+ | Key | Action |
2186
+ |-----|--------|
2187
+ `;
2188
+ for (const [key, shortcut] of shortcuts) {
2189
+ const description = shortcut.description ?? shortcut.hookPath;
2190
+ hotkeys += `| \`${key}\` | ${description} |\n`;
2191
+ }
2192
+ }
2193
+ }
1958
2194
  this.chatContainer.addChild(new Spacer(1));
1959
2195
  this.chatContainer.addChild(new DynamicBorder());
1960
2196
  this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));