@mariozechner/pi-coding-agent 0.33.0 → 0.34.1

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 (68) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +23 -2
  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 +2 -0
  16. package/dist/core/custom-tools/loader.js.map +1 -1
  17. package/dist/core/hooks/index.d.ts +1 -1
  18. package/dist/core/hooks/index.d.ts.map +1 -1
  19. package/dist/core/hooks/index.js.map +1 -1
  20. package/dist/core/hooks/loader.d.ts +56 -1
  21. package/dist/core/hooks/loader.d.ts.map +1 -1
  22. package/dist/core/hooks/loader.js +54 -2
  23. package/dist/core/hooks/loader.js.map +1 -1
  24. package/dist/core/hooks/runner.d.ts +33 -5
  25. package/dist/core/hooks/runner.d.ts.map +1 -1
  26. package/dist/core/hooks/runner.js +101 -9
  27. package/dist/core/hooks/runner.js.map +1 -1
  28. package/dist/core/hooks/types.d.ts +141 -3
  29. package/dist/core/hooks/types.d.ts.map +1 -1
  30. package/dist/core/hooks/types.js.map +1 -1
  31. package/dist/core/sdk.d.ts +3 -0
  32. package/dist/core/sdk.d.ts.map +1 -1
  33. package/dist/core/sdk.js +102 -27
  34. package/dist/core/sdk.js.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/main.d.ts.map +1 -1
  40. package/dist/main.js +32 -7
  41. package/dist/main.js.map +1 -1
  42. package/dist/modes/interactive/components/custom-editor.d.ts +2 -0
  43. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  44. package/dist/modes/interactive/components/custom-editor.js +6 -0
  45. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  46. package/dist/modes/interactive/interactive-mode.d.ts +19 -6
  47. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  48. package/dist/modes/interactive/interactive-mode.js +149 -42
  49. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  50. package/dist/modes/interactive/theme/theme.d.ts +1 -0
  51. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  52. package/dist/modes/interactive/theme/theme.js +3 -0
  53. package/dist/modes/interactive/theme/theme.js.map +1 -1
  54. package/dist/modes/print-mode.d.ts.map +1 -1
  55. package/dist/modes/print-mode.js +3 -0
  56. package/dist/modes/print-mode.js.map +1 -1
  57. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  58. package/dist/modes/rpc/rpc-mode.js +25 -0
  59. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  60. package/dist/modes/rpc/rpc-types.d.ts +11 -0
  61. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  62. package/dist/modes/rpc/rpc-types.js.map +1 -1
  63. package/docs/hooks.md +121 -4
  64. package/examples/hooks/README.md +3 -0
  65. package/examples/hooks/pirate.ts +44 -0
  66. package/examples/hooks/plan-mode.ts +548 -0
  67. package/examples/hooks/tools.ts +145 -0
  68. package/package.json +4 -4
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import Clipboard from "@crosscopy/clipboard";
10
- import { CombinedAutocompleteProvider, Container, getEditorKeybindings, Input, Loader, Markdown, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
10
+ import { CombinedAutocompleteProvider, Container, getEditorKeybindings, Input, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
11
11
  import { exec, spawn, spawnSync } from "child_process";
12
12
  import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js";
13
13
  import { KeybindingsManager } from "../../core/keybindings.js";
@@ -90,11 +90,11 @@ export class InteractiveMode {
90
90
  hookSelector = undefined;
91
91
  hookInput = undefined;
92
92
  hookEditor = undefined;
93
+ // Hook widgets (components rendered above the editor)
94
+ hookWidgets = new Map();
95
+ widgetContainer;
93
96
  // Custom tools for custom rendering
94
97
  customTools;
95
- // Clipboard image tracking: imageId -> temp file path
96
- clipboardImages = new Map();
97
- clipboardImageCounter = 0;
98
98
  // Convenience accessors
99
99
  get agent() {
100
100
  return this.session.agent;
@@ -115,6 +115,7 @@ export class InteractiveMode {
115
115
  this.chatContainer = new Container();
116
116
  this.pendingMessagesContainer = new Container();
117
117
  this.statusContainer = new Container();
118
+ this.widgetContainer = new Container();
118
119
  this.keybindings = KeybindingsManager.create();
119
120
  this.editor = new CustomEditor(getEditorTheme(), this.keybindings);
120
121
  this.editorContainer = new Container();
@@ -254,6 +255,7 @@ export class InteractiveMode {
254
255
  this.ui.addChild(this.chatContainer);
255
256
  this.ui.addChild(this.pendingMessagesContainer);
256
257
  this.ui.addChild(this.statusContainer);
258
+ this.ui.addChild(this.widgetContainer);
257
259
  this.ui.addChild(new Spacer(1));
258
260
  this.ui.addChild(this.editorContainer);
259
261
  this.ui.addChild(this.footer);
@@ -322,20 +324,7 @@ export class InteractiveMode {
322
324
  this.chatContainer.addChild(new Spacer(1));
323
325
  }
324
326
  // Create and set hook & tool UI context
325
- const uiContext = {
326
- select: (title, options) => this.showHookSelector(title, options),
327
- confirm: (title, message) => this.showHookConfirm(title, message),
328
- input: (title, placeholder) => this.showHookInput(title, placeholder),
329
- notify: (message, type) => this.showHookNotify(message, type),
330
- setStatus: (key, text) => this.setHookStatus(key, text),
331
- custom: (factory) => this.showHookCustom(factory),
332
- setEditorText: (text) => this.editor.setText(text),
333
- getEditorText: () => this.editor.getText(),
334
- editor: (title, prefill) => this.showHookEditor(title, prefill),
335
- get theme() {
336
- return theme;
337
- },
338
- };
327
+ const uiContext = this.createHookUIContext();
339
328
  this.setToolUIContext(uiContext, true);
340
329
  // Notify custom tools of session start
341
330
  await this.emitCustomToolSessionEvent({
@@ -366,6 +355,9 @@ export class InteractiveMode {
366
355
  appendEntryHandler: (customType, data) => {
367
356
  this.sessionManager.appendCustomEntry(customType, data);
368
357
  },
358
+ getActiveToolsHandler: () => this.session.getActiveToolNames(),
359
+ getAllToolsHandler: () => this.session.getAllToolNames(),
360
+ setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames),
369
361
  newSessionHandler: async (options) => {
370
362
  // Stop any loading animation
371
363
  if (this.loadingAnimation) {
@@ -430,8 +422,10 @@ export class InteractiveMode {
430
422
  });
431
423
  // Subscribe to hook errors
432
424
  hookRunner.onError((error) => {
433
- this.showHookError(error.hookPath, error.error);
425
+ this.showHookError(error.hookPath, error.error, error.stack);
434
426
  });
427
+ // Set up hook-registered shortcuts
428
+ this.setupHookShortcuts(hookRunner);
435
429
  // Show loaded hooks
436
430
  const hookPaths = hookRunner.getHookPaths();
437
431
  if (hookPaths.length > 0) {
@@ -476,6 +470,40 @@ export class InteractiveMode {
476
470
  this.chatContainer.addChild(errorText);
477
471
  this.ui.requestRender();
478
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
+ }
479
507
  /**
480
508
  * Set hook status text in the footer.
481
509
  */
@@ -483,6 +511,74 @@ export class InteractiveMode {
483
511
  this.footer.setHookStatus(key, text);
484
512
  this.ui.requestRender();
485
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
+ setTitle: (title) => this.ui.terminal.setTitle(title),
573
+ custom: (factory) => this.showHookCustom(factory),
574
+ setEditorText: (text) => this.editor.setText(text),
575
+ getEditorText: () => this.editor.getText(),
576
+ editor: (title, prefill) => this.showHookEditor(title, prefill),
577
+ get theme() {
578
+ return theme;
579
+ },
580
+ };
581
+ }
486
582
  /**
487
583
  * Show a selector for hooks.
488
584
  */
@@ -616,9 +712,21 @@ export class InteractiveMode {
616
712
  /**
617
713
  * Show a hook error in the UI.
618
714
  */
619
- showHookError(hookPath, error) {
620
- const errorText = new Text(theme.fg("error", `Hook "${hookPath}" error: ${error}`), 1, 0);
715
+ showHookError(hookPath, error, stack) {
716
+ const errorMsg = `Hook "${hookPath}" error: ${error}`;
717
+ const errorText = new Text(theme.fg("error", errorMsg), 1, 0);
621
718
  this.chatContainer.addChild(errorText);
719
+ if (stack) {
720
+ // Show stack trace in dim color, indented
721
+ const stackLines = stack
722
+ .split("\n")
723
+ .slice(1) // Skip first line (duplicates error message)
724
+ .map((line) => theme.fg("dim", ` ${line.trim()}`))
725
+ .join("\n");
726
+ if (stackLines) {
727
+ this.chatContainer.addChild(new Text(stackLines, 1, 0));
728
+ }
729
+ }
622
730
  this.ui.requestRender();
623
731
  }
624
732
  /**
@@ -702,33 +810,18 @@ export class InteractiveMode {
702
810
  return;
703
811
  }
704
812
  // Write to temp file
705
- const imageId = ++this.clipboardImageCounter;
706
813
  const tmpDir = os.tmpdir();
707
814
  const fileName = `pi-clipboard-${crypto.randomUUID()}.png`;
708
815
  const filePath = path.join(tmpDir, fileName);
709
816
  fs.writeFileSync(filePath, Buffer.from(imageData));
710
- // Store mapping and insert marker
711
- this.clipboardImages.set(imageId, filePath);
712
- this.editor.insertTextAtCursor(`[image #${imageId}]`);
817
+ // Insert file path directly
818
+ this.editor.insertTextAtCursor(filePath);
713
819
  this.ui.requestRender();
714
820
  }
715
821
  catch {
716
822
  // Silently ignore clipboard errors (may not have permission, etc.)
717
823
  }
718
824
  }
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;
731
- }
732
825
  setupEditorSubmitHandler() {
733
826
  this.editor.onSubmit = async (text) => {
734
827
  text = text.trim();
@@ -854,8 +947,6 @@ export class InteractiveMode {
854
947
  return;
855
948
  }
856
949
  // If streaming, use prompt() with steer behavior
857
- // Replace image markers with actual file paths
858
- text = this.replaceImageMarkers(text);
859
950
  // This handles hook commands (execute immediately), slash command expansion, and queueing
860
951
  if (this.session.isStreaming) {
861
952
  this.editor.addToHistory(text);
@@ -1394,7 +1485,7 @@ export class InteractiveMode {
1394
1485
  this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
1395
1486
  return;
1396
1487
  }
1397
- const currentText = this.editor.getText();
1488
+ const currentText = this.editor.getExpandedText();
1398
1489
  const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);
1399
1490
  try {
1400
1491
  // Write current content to temp file
@@ -2049,7 +2140,7 @@ export class InteractiveMode {
2049
2140
  const toggleThinking = this.getAppKeyDisplay("toggleThinking");
2050
2141
  const externalEditor = this.getAppKeyDisplay("externalEditor");
2051
2142
  const followUp = this.getAppKeyDisplay("followUp");
2052
- const hotkeys = `
2143
+ let hotkeys = `
2053
2144
  **Navigation**
2054
2145
  | Key | Action |
2055
2146
  |-----|--------|
@@ -2085,6 +2176,22 @@ export class InteractiveMode {
2085
2176
  | \`/\` | Slash commands |
2086
2177
  | \`!\` | Run bash command |
2087
2178
  `;
2179
+ // Add hook-registered shortcuts
2180
+ const hookRunner = this.session.hookRunner;
2181
+ if (hookRunner) {
2182
+ const shortcuts = hookRunner.getShortcuts();
2183
+ if (shortcuts.size > 0) {
2184
+ hotkeys += `
2185
+ **Hooks**
2186
+ | Key | Action |
2187
+ |-----|--------|
2188
+ `;
2189
+ for (const [key, shortcut] of shortcuts) {
2190
+ const description = shortcut.description ?? shortcut.hookPath;
2191
+ hotkeys += `| \`${key}\` | ${description} |\n`;
2192
+ }
2193
+ }
2194
+ }
2088
2195
  this.chatContainer.addChild(new Spacer(1));
2089
2196
  this.chatContainer.addChild(new DynamicBorder());
2090
2197
  this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));