@mariozechner/pi-coding-agent 0.37.3 → 0.37.5

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 (61) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +3 -0
  3. package/dist/core/auth-storage.d.ts.map +1 -1
  4. package/dist/core/auth-storage.js +4 -6
  5. package/dist/core/auth-storage.js.map +1 -1
  6. package/dist/core/extensions/index.d.ts +1 -1
  7. package/dist/core/extensions/index.d.ts.map +1 -1
  8. package/dist/core/extensions/index.js.map +1 -1
  9. package/dist/core/extensions/loader.d.ts.map +1 -1
  10. package/dist/core/extensions/loader.js +34 -3
  11. package/dist/core/extensions/loader.js.map +1 -1
  12. package/dist/core/extensions/runner.d.ts +4 -1
  13. package/dist/core/extensions/runner.d.ts.map +1 -1
  14. package/dist/core/extensions/runner.js +4 -0
  15. package/dist/core/extensions/runner.js.map +1 -1
  16. package/dist/core/extensions/types.d.ts +17 -1
  17. package/dist/core/extensions/types.d.ts.map +1 -1
  18. package/dist/core/extensions/types.js.map +1 -1
  19. package/dist/core/sdk.d.ts.map +1 -1
  20. package/dist/core/sdk.js +1 -0
  21. package/dist/core/sdk.js.map +1 -1
  22. package/dist/core/system-prompt.d.ts.map +1 -1
  23. package/dist/core/system-prompt.js +1 -1
  24. package/dist/core/system-prompt.js.map +1 -1
  25. package/dist/core/tools/index.d.ts +1 -1
  26. package/dist/core/tools/index.d.ts.map +1 -1
  27. package/dist/core/tools/index.js +1 -0
  28. package/dist/core/tools/index.js.map +1 -1
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/main.d.ts.map +1 -1
  34. package/dist/main.js +20 -1
  35. package/dist/main.js.map +1 -1
  36. package/dist/modes/interactive/components/index.d.ts +28 -0
  37. package/dist/modes/interactive/components/index.d.ts.map +1 -0
  38. package/dist/modes/interactive/components/index.js +29 -0
  39. package/dist/modes/interactive/components/index.js.map +1 -0
  40. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  41. package/dist/modes/interactive/components/session-selector.js +1 -1
  42. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  43. package/dist/modes/interactive/interactive-mode.d.ts +6 -0
  44. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  45. package/dist/modes/interactive/interactive-mode.js +52 -4
  46. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  47. package/dist/modes/print-mode.d.ts.map +1 -1
  48. package/dist/modes/print-mode.js +9 -0
  49. package/dist/modes/print-mode.js.map +1 -1
  50. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  51. package/dist/modes/rpc/rpc-mode.js +12 -0
  52. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  53. package/docs/extensions.md +149 -9
  54. package/docs/tui.md +220 -2
  55. package/examples/extensions/README.md +1 -0
  56. package/examples/extensions/custom-header.ts +72 -0
  57. package/examples/extensions/preset.ts +398 -0
  58. package/examples/extensions/truncated-tool.ts +192 -0
  59. package/examples/extensions/with-deps/package-lock.json +2 -2
  60. package/examples/extensions/with-deps/package.json +1 -1
  61. package/package.json +4 -4
@@ -306,6 +306,8 @@ pi.on("session_start", async (_event, ctx) => {
306
306
  });
307
307
  ```
308
308
 
309
+ **Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [file-trigger.ts](../examples/extensions/file-trigger.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
310
+
309
311
  #### session_before_switch / session_switch
310
312
 
311
313
  Fired when starting a new session (`/new`) or switching sessions (`/resume`).
@@ -327,6 +329,8 @@ pi.on("session_switch", async (event, ctx) => {
327
329
  });
328
330
  ```
329
331
 
332
+ **Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts)
333
+
330
334
  #### session_before_branch / session_branch
331
335
 
332
336
  Fired when branching via `/branch`.
@@ -344,6 +348,8 @@ pi.on("session_branch", async (event, ctx) => {
344
348
  });
345
349
  ```
346
350
 
351
+ **Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
352
+
347
353
  #### session_before_compact / session_compact
348
354
 
349
355
  Fired on compaction. See [compaction.md](compaction.md) for details.
@@ -371,6 +377,8 @@ pi.on("session_compact", async (event, ctx) => {
371
377
  });
372
378
  ```
373
379
 
380
+ **Examples:** [custom-compaction.ts](../examples/extensions/custom-compaction.ts)
381
+
374
382
  #### session_before_tree / session_tree
375
383
 
376
384
  Fired on `/tree` navigation.
@@ -388,6 +396,8 @@ pi.on("session_tree", async (event, ctx) => {
388
396
  });
389
397
  ```
390
398
 
399
+ **Examples:** [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
400
+
391
401
  #### session_shutdown
392
402
 
393
403
  Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
@@ -398,6 +408,8 @@ pi.on("session_shutdown", async (_event, ctx) => {
398
408
  });
399
409
  ```
400
410
 
411
+ **Examples:** [auto-commit-on-exit.ts](../examples/extensions/auto-commit-on-exit.ts)
412
+
401
413
  ### Agent Events
402
414
 
403
415
  #### before_agent_start
@@ -422,6 +434,8 @@ pi.on("before_agent_start", async (event, ctx) => {
422
434
  });
423
435
  ```
424
436
 
437
+ **Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
438
+
425
439
  #### agent_start / agent_end
426
440
 
427
441
  Fired once per user prompt.
@@ -434,6 +448,8 @@ pi.on("agent_end", async (event, ctx) => {
434
448
  });
435
449
  ```
436
450
 
451
+ **Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
452
+
437
453
  #### turn_start / turn_end
438
454
 
439
455
  Fired for each turn (one LLM response + tool calls).
@@ -448,6 +464,8 @@ pi.on("turn_end", async (event, ctx) => {
448
464
  });
449
465
  ```
450
466
 
467
+ **Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [status-line.ts](../examples/extensions/status-line.ts)
468
+
451
469
  #### context
452
470
 
453
471
  Fired before each LLM call. Modify messages non-destructively.
@@ -460,6 +478,8 @@ pi.on("context", async (event, ctx) => {
460
478
  });
461
479
  ```
462
480
 
481
+ **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts)
482
+
463
483
  ### Tool Events
464
484
 
465
485
  #### tool_call
@@ -478,6 +498,8 @@ pi.on("tool_call", async (event, ctx) => {
478
498
  });
479
499
  ```
480
500
 
501
+ **Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts)
502
+
481
503
  #### tool_result
482
504
 
483
505
  Fired after tool executes. **Can modify result.**
@@ -498,6 +520,8 @@ pi.on("tool_result", async (event, ctx) => {
498
520
  });
499
521
  ```
500
522
 
523
+ **Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
524
+
501
525
  ## ExtensionContext
502
526
 
503
527
  Every handler receives `ctx: ExtensionContext`:
@@ -595,7 +619,7 @@ const result = await ctx.navigateTree("entry-id-456", {
595
619
 
596
620
  ### pi.on(event, handler)
597
621
 
598
- Subscribe to events. See [Events](#events).
622
+ Subscribe to events. See [Events](#events) for event types and return values.
599
623
 
600
624
  ### pi.registerTool(definition)
601
625
 
@@ -630,9 +654,11 @@ pi.registerTool({
630
654
  });
631
655
  ```
632
656
 
657
+ **Examples:** [hello.ts](../examples/extensions/hello.ts), [question.ts](../examples/extensions/question.ts), [todo.ts](../examples/extensions/todo.ts), [truncated-tool.ts](../examples/extensions/truncated-tool.ts)
658
+
633
659
  ### pi.sendMessage(message, options?)
634
660
 
635
- Inject a custom message into the session:
661
+ Inject a custom message into the session.
636
662
 
637
663
  ```typescript
638
664
  pi.sendMessage({
@@ -653,6 +679,8 @@ pi.sendMessage({
653
679
  - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything.
654
680
  - `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`).
655
681
 
682
+ **Examples:** [file-trigger.ts](../examples/extensions/file-trigger.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
683
+
656
684
  ### pi.sendUserMessage(content, options?)
657
685
 
658
686
  Send a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn.
@@ -683,7 +711,7 @@ See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a co
683
711
 
684
712
  ### pi.appendEntry(customType, data?)
685
713
 
686
- Persist extension state (does NOT participate in LLM context):
714
+ Persist extension state (does NOT participate in LLM context).
687
715
 
688
716
  ```typescript
689
717
  pi.appendEntry("my-state", { count: 42 });
@@ -698,9 +726,11 @@ pi.on("session_start", async (_event, ctx) => {
698
726
  });
699
727
  ```
700
728
 
729
+ **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [snake.ts](../examples/extensions/snake.ts), [tools.ts](../examples/extensions/tools.ts)
730
+
701
731
  ### pi.registerCommand(name, options)
702
732
 
703
- Register a command:
733
+ Register a command.
704
734
 
705
735
  ```typescript
706
736
  pi.registerCommand("stats", {
@@ -712,13 +742,15 @@ pi.registerCommand("stats", {
712
742
  });
713
743
  ```
714
744
 
745
+ **Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [handoff.ts](../examples/extensions/handoff.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [send-user-message.ts](../examples/extensions/send-user-message.ts), [snake.ts](../examples/extensions/snake.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
746
+
715
747
  ### pi.registerMessageRenderer(customType, renderer)
716
748
 
717
749
  Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui).
718
750
 
719
751
  ### pi.registerShortcut(shortcut, options)
720
752
 
721
- Register a keyboard shortcut:
753
+ Register a keyboard shortcut.
722
754
 
723
755
  ```typescript
724
756
  pi.registerShortcut("ctrl+shift+p", {
@@ -729,9 +761,11 @@ pi.registerShortcut("ctrl+shift+p", {
729
761
  });
730
762
  ```
731
763
 
764
+ **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
765
+
732
766
  ### pi.registerFlag(name, options)
733
767
 
734
- Register a CLI flag:
768
+ Register a CLI flag.
735
769
 
736
770
  ```typescript
737
771
  pi.registerFlag("--plan", {
@@ -746,24 +780,57 @@ if (pi.getFlag("--plan")) {
746
780
  }
747
781
  ```
748
782
 
783
+ **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
784
+
749
785
  ### pi.exec(command, args, options?)
750
786
 
751
- Execute a shell command:
787
+ Execute a shell command.
752
788
 
753
789
  ```typescript
754
790
  const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
755
791
  // result.stdout, result.stderr, result.code, result.killed
756
792
  ```
757
793
 
794
+ **Examples:** [auto-commit-on-exit.ts](../examples/extensions/auto-commit-on-exit.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts)
795
+
758
796
  ### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)
759
797
 
760
- Manage active tools:
798
+ Manage active tools.
761
799
 
762
800
  ```typescript
763
801
  const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"]
764
802
  pi.setActiveTools(["read", "bash"]); // Switch to read-only
765
803
  ```
766
804
 
805
+ **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts)
806
+
807
+ ### pi.setModel(model)
808
+
809
+ Set the current model. Returns `false` if no API key is available for the model.
810
+
811
+ ```typescript
812
+ const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");
813
+ if (model) {
814
+ const success = await pi.setModel(model);
815
+ if (!success) {
816
+ ctx.ui.notify("No API key for this model", "error");
817
+ }
818
+ }
819
+ ```
820
+
821
+ **Examples:** [preset.ts](../examples/extensions/preset.ts)
822
+
823
+ ### pi.getThinkingLevel() / pi.setThinkingLevel(level)
824
+
825
+ Get or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use "off").
826
+
827
+ ```typescript
828
+ const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
829
+ pi.setThinkingLevel("high");
830
+ ```
831
+
832
+ **Examples:** [preset.ts](../examples/extensions/preset.ts)
833
+
767
834
  ### pi.events
768
835
 
769
836
  Shared event bus for communication between extensions:
@@ -857,6 +924,57 @@ pi.registerTool({
857
924
 
858
925
  **Important:** Use `StringEnum` from `@mariozechner/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API.
859
926
 
927
+ ### Output Truncation
928
+
929
+ **Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause:
930
+ - Context overflow errors (prompt too long)
931
+ - Compaction failures
932
+ - Degraded model performance
933
+
934
+ The built-in limit is **50KB** (~10k tokens) and **2000 lines**, whichever is hit first. Use the exported truncation utilities:
935
+
936
+ ```typescript
937
+ import {
938
+ truncateHead, // Keep first N lines/bytes (good for file reads, search results)
939
+ truncateTail, // Keep last N lines/bytes (good for logs, command output)
940
+ formatSize, // Human-readable size (e.g., "50KB", "1.5MB")
941
+ DEFAULT_MAX_BYTES, // 50KB
942
+ DEFAULT_MAX_LINES, // 2000
943
+ } from "@mariozechner/pi-coding-agent";
944
+
945
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
946
+ const output = await runCommand();
947
+
948
+ // Apply truncation
949
+ const truncation = truncateHead(output, {
950
+ maxLines: DEFAULT_MAX_LINES,
951
+ maxBytes: DEFAULT_MAX_BYTES,
952
+ });
953
+
954
+ let result = truncation.content;
955
+
956
+ if (truncation.truncated) {
957
+ // Write full output to temp file
958
+ const tempFile = writeTempFile(output);
959
+
960
+ // Inform the LLM where to find complete output
961
+ result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
962
+ result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
963
+ result += ` Full output saved to: ${tempFile}]`;
964
+ }
965
+
966
+ return { content: [{ type: "text", text: result }] };
967
+ }
968
+ ```
969
+
970
+ **Key points:**
971
+ - Use `truncateHead` for content where the beginning matters (search results, file reads)
972
+ - Use `truncateTail` for content where the end matters (logs, command output)
973
+ - Always inform the LLM when output is truncated and where to find the full version
974
+ - Document the truncation limits in your tool's description
975
+
976
+ See [examples/extensions/truncated-tool.ts](../examples/extensions/truncated-tool.ts) for a complete example wrapping `rg` (ripgrep) with proper truncation.
977
+
860
978
  ### Multiple Tools
861
979
 
862
980
  One extension can register multiple tools with shared state:
@@ -943,6 +1061,14 @@ If `renderCall`/`renderResult` is not defined or throws:
943
1061
 
944
1062
  Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render.
945
1063
 
1064
+ **For custom components, see [tui.md](tui.md)** which has copy-paste patterns for:
1065
+ - Selection dialogs (SelectList)
1066
+ - Async operations with cancel (BorderedLoader)
1067
+ - Settings toggles (SettingsList)
1068
+ - Status indicators (setStatus)
1069
+ - Widgets above editor (setWidget)
1070
+ - Custom footers (setFooter)
1071
+
946
1072
  ### Dialogs
947
1073
 
948
1074
  ```typescript
@@ -962,6 +1088,12 @@ const text = await ctx.ui.editor("Edit:", "prefilled text");
962
1088
  ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
963
1089
  ```
964
1090
 
1091
+ **Examples:**
1092
+ - `ctx.ui.select()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [question.ts](../examples/extensions/question.ts)
1093
+ - `ctx.ui.confirm()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts)
1094
+ - `ctx.ui.editor()`: [handoff.ts](../examples/extensions/handoff.ts)
1095
+ - `ctx.ui.setEditorText()`: [handoff.ts](../examples/extensions/handoff.ts), [qna.ts](../examples/extensions/qna.ts)
1096
+
965
1097
  ### Widgets, Status, and Footer
966
1098
 
967
1099
  ```typescript
@@ -989,6 +1121,12 @@ ctx.ui.setEditorText("Prefill text");
989
1121
  const current = ctx.ui.getEditorText();
990
1122
  ```
991
1123
 
1124
+ **Examples:**
1125
+ - `ctx.ui.setStatus()`: [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [status-line.ts](../examples/extensions/status-line.ts)
1126
+ - `ctx.ui.setWidget()`: [plan-mode.ts](../examples/extensions/plan-mode.ts)
1127
+ - `ctx.ui.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.ts)
1128
+ - `ctx.ui.setHeader()`: [custom-header.ts](../examples/extensions/custom-header.ts)
1129
+
992
1130
  ### Custom Components
993
1131
 
994
1132
  For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called:
@@ -1018,7 +1156,9 @@ The callback receives:
1018
1156
  - `theme` - Current theme for styling
1019
1157
  - `done(value)` - Call to close component and return value
1020
1158
 
1021
- See [tui.md](tui.md) for the full component API and [examples/extensions/](../examples/extensions/) for working examples (snake.ts, todo.ts, qna.ts).
1159
+ See [tui.md](tui.md) for the full component API.
1160
+
1161
+ **Examples:** [handoff.ts](../examples/extensions/handoff.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [snake.ts](../examples/extensions/snake.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
1022
1162
 
1023
1163
  ### Message Rendering
1024
1164
 
package/docs/tui.md CHANGED
@@ -340,7 +340,225 @@ class CachedComponent {
340
340
 
341
341
  Call `invalidate()` when state changes, then `handle.requestRender()` to trigger re-render.
342
342
 
343
+ ## Common Patterns
344
+
345
+ These patterns cover the most common UI needs in extensions. **Copy these patterns instead of building from scratch.**
346
+
347
+ ### Pattern 1: Selection Dialog (SelectList)
348
+
349
+ For letting users pick from a list of options. Use `SelectList` from `@mariozechner/pi-tui` with `DynamicBorder` for framing.
350
+
351
+ ```typescript
352
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
353
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
354
+ import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
355
+
356
+ pi.registerCommand("pick", {
357
+ handler: async (_args, ctx) => {
358
+ const items: SelectItem[] = [
359
+ { value: "opt1", label: "Option 1", description: "First option" },
360
+ { value: "opt2", label: "Option 2", description: "Second option" },
361
+ { value: "opt3", label: "Option 3" }, // description is optional
362
+ ];
363
+
364
+ const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
365
+ const container = new Container();
366
+
367
+ // Top border
368
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
369
+
370
+ // Title
371
+ container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0));
372
+
373
+ // SelectList with theme
374
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
375
+ selectedPrefix: (t) => theme.fg("accent", t),
376
+ selectedText: (t) => theme.fg("accent", t),
377
+ description: (t) => theme.fg("muted", t),
378
+ scrollInfo: (t) => theme.fg("dim", t),
379
+ noMatch: (t) => theme.fg("warning", t),
380
+ });
381
+ selectList.onSelect = (item) => done(item.value);
382
+ selectList.onCancel = () => done(null);
383
+ container.addChild(selectList);
384
+
385
+ // Help text
386
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
387
+
388
+ // Bottom border
389
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
390
+
391
+ return {
392
+ render: (w) => container.render(w),
393
+ invalidate: () => container.invalidate(),
394
+ handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
395
+ };
396
+ });
397
+
398
+ if (result) {
399
+ ctx.ui.notify(`Selected: ${result}`, "info");
400
+ }
401
+ },
402
+ });
403
+ ```
404
+
405
+ **Examples:** [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts)
406
+
407
+ ### Pattern 2: Async Operation with Cancel (BorderedLoader)
408
+
409
+ For operations that take time and should be cancellable. `BorderedLoader` shows a spinner and handles escape to cancel.
410
+
411
+ ```typescript
412
+ import { BorderedLoader } from "@mariozechner/pi-coding-agent";
413
+
414
+ pi.registerCommand("fetch", {
415
+ handler: async (_args, ctx) => {
416
+ const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
417
+ const loader = new BorderedLoader(tui, theme, "Fetching data...");
418
+ loader.onAbort = () => done(null);
419
+
420
+ // Do async work
421
+ fetchData(loader.signal)
422
+ .then((data) => done(data))
423
+ .catch(() => done(null));
424
+
425
+ return loader;
426
+ });
427
+
428
+ if (result === null) {
429
+ ctx.ui.notify("Cancelled", "info");
430
+ } else {
431
+ ctx.ui.setEditorText(result);
432
+ }
433
+ },
434
+ });
435
+ ```
436
+
437
+ **Examples:** [qna.ts](../examples/extensions/qna.ts), [handoff.ts](../examples/extensions/handoff.ts)
438
+
439
+ ### Pattern 3: Settings/Toggles (SettingsList)
440
+
441
+ For toggling multiple settings. Use `SettingsList` from `@mariozechner/pi-tui` with `getSettingsListTheme()`.
442
+
443
+ ```typescript
444
+ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
445
+ import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
446
+
447
+ pi.registerCommand("settings", {
448
+ handler: async (_args, ctx) => {
449
+ const items: SettingItem[] = [
450
+ { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] },
451
+ { id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] },
452
+ ];
453
+
454
+ await ctx.ui.custom((_tui, theme, done) => {
455
+ const container = new Container();
456
+ container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1));
457
+
458
+ const settingsList = new SettingsList(
459
+ items,
460
+ Math.min(items.length + 2, 15),
461
+ getSettingsListTheme(),
462
+ (id, newValue) => {
463
+ // Handle value change
464
+ ctx.ui.notify(`${id} = ${newValue}`, "info");
465
+ },
466
+ () => done(undefined), // On close
467
+ );
468
+ container.addChild(settingsList);
469
+
470
+ return {
471
+ render: (w) => container.render(w),
472
+ invalidate: () => container.invalidate(),
473
+ handleInput: (data) => settingsList.handleInput?.(data),
474
+ };
475
+ });
476
+ },
477
+ });
478
+ ```
479
+
480
+ **Examples:** [tools.ts](../examples/extensions/tools.ts)
481
+
482
+ ### Pattern 4: Persistent Status Indicator
483
+
484
+ Show status in the footer that persists across renders. Good for mode indicators.
485
+
486
+ ```typescript
487
+ // Set status (shown in footer)
488
+ ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
489
+
490
+ // Clear status
491
+ ctx.ui.setStatus("my-ext", undefined);
492
+ ```
493
+
494
+ **Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
495
+
496
+ ### Pattern 5: Widget Above Editor
497
+
498
+ Show persistent content above the input editor. Good for todo lists, progress.
499
+
500
+ ```typescript
501
+ // Simple string array
502
+ ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
503
+
504
+ // Or with theme
505
+ ctx.ui.setWidget("my-widget", (_tui, theme) => {
506
+ const lines = items.map((item, i) =>
507
+ item.done
508
+ ? theme.fg("success", "✓ ") + theme.fg("muted", item.text)
509
+ : theme.fg("dim", "○ ") + item.text
510
+ );
511
+ return {
512
+ render: () => lines,
513
+ invalidate: () => {},
514
+ };
515
+ });
516
+
517
+ // Clear
518
+ ctx.ui.setWidget("my-widget", undefined);
519
+ ```
520
+
521
+ **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts)
522
+
523
+ ### Pattern 6: Custom Footer
524
+
525
+ Replace the entire footer with custom content.
526
+
527
+ ```typescript
528
+ ctx.ui.setFooter((_tui, theme) => ({
529
+ render(width: number): string[] {
530
+ const left = theme.fg("dim", "custom footer");
531
+ const right = theme.fg("accent", "status");
532
+ const padding = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
533
+ return [truncateToWidth(left + padding + right, width)];
534
+ },
535
+ invalidate() {},
536
+ }));
537
+
538
+ // Restore default
539
+ ctx.ui.setFooter(undefined);
540
+ ```
541
+
542
+ **Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts)
543
+
544
+ ## Key Rules
545
+
546
+ 1. **Always use theme from callback** - Don't import theme directly. Use `theme` from the `ctx.ui.custom((tui, theme, done) => ...)` callback.
547
+
548
+ 2. **Always type DynamicBorder color param** - Write `(s: string) => theme.fg("accent", s)`, not `(s) => theme.fg("accent", s)`.
549
+
550
+ 3. **Call tui.requestRender() after state changes** - In `handleInput`, call `tui.requestRender()` after updating state.
551
+
552
+ 4. **Return the three-method object** - Custom components need `{ render, invalidate, handleInput }`.
553
+
554
+ 5. **Use existing components** - `SelectList`, `SettingsList`, `BorderedLoader` cover 90% of cases. Don't rebuild them.
555
+
343
556
  ## Examples
344
557
 
345
- - **Snake game**: [examples/hooks/snake.ts](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence
346
- - **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - Custom `renderCall` and `renderResult`
558
+ - **Selection UI**: [examples/extensions/preset.ts](../examples/extensions/preset.ts) - SelectList with DynamicBorder framing
559
+ - **Async with cancel**: [examples/extensions/qna.ts](../examples/extensions/qna.ts) - BorderedLoader for LLM calls
560
+ - **Settings toggles**: [examples/extensions/tools.ts](../examples/extensions/tools.ts) - SettingsList for tool enable/disable
561
+ - **Status indicators**: [examples/extensions/plan-mode.ts](../examples/extensions/plan-mode.ts) - setStatus and setWidget
562
+ - **Custom footer**: [examples/extensions/custom-footer.ts](../examples/extensions/custom-footer.ts) - setFooter with stats
563
+ - **Snake game**: [examples/extensions/snake.ts](../examples/extensions/snake.ts) - Full game with keyboard input, game loop
564
+ - **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - renderCall and renderResult
@@ -36,6 +36,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
36
36
 
37
37
  | Extension | Description |
38
38
  |-----------|-------------|
39
+ | `preset.ts` | Named presets for model, thinking level, tools, and instructions via `--preset` flag and `/preset` command |
39
40
  | `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
40
41
  | `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
41
42
  | `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Custom Header Extension
3
+ *
4
+ * Demonstrates ctx.ui.setHeader() for replacing the built-in header
5
+ * (logo + keybinding hints) with a custom component showing the pi mascot.
6
+ */
7
+
8
+ import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
9
+
10
+ // --- PI MASCOT ---
11
+ // Based on pi_mascot.ts - the pi agent character
12
+ function getPiMascot(theme: Theme): string[] {
13
+ // --- COLORS ---
14
+ // 3b1b Blue: R=80, G=180, B=230
15
+ const piBlue = (text: string) => theme.fg("accent", text);
16
+ const white = (text: string) => text; // Use plain white (or theme.fg("text", text))
17
+ const black = (text: string) => theme.fg("dim", text); // Use dim for contrast
18
+
19
+ // --- GLYPHS ---
20
+ const BLOCK = "█";
21
+ const PUPIL = "▌"; // Vertical half-block for the pupil
22
+
23
+ // --- CONSTRUCTION ---
24
+
25
+ // 1. The Eye Unit: [White Full Block][Black Vertical Sliver]
26
+ // This creates the "looking sideways" effect
27
+ const eye = `${white(BLOCK)}${black(PUPIL)}`;
28
+
29
+ // 2. Line 1: The Eyes
30
+ // 5 spaces indent aligns them with the start of the legs
31
+ const lineEyes = ` ${eye} ${eye}`;
32
+
33
+ // 3. Line 2: The Wide Top Bar (The "Overhang")
34
+ // 14 blocks wide for that serif-style roof
35
+ const lineBar = ` ${piBlue(BLOCK.repeat(14))}`;
36
+
37
+ // 4. Lines 3-6: The Legs
38
+ // Indented 5 spaces relative to the very left edge
39
+ // Leg width: 2 blocks | Gap: 4 blocks
40
+ const lineLeg = ` ${piBlue(BLOCK.repeat(2))} ${piBlue(BLOCK.repeat(2))}`;
41
+
42
+ // --- ASSEMBLY ---
43
+ return ["", lineEyes, lineBar, lineLeg, lineLeg, lineLeg, lineLeg, ""];
44
+ }
45
+
46
+ export default function (pi: ExtensionAPI) {
47
+ // Set custom header immediately on load (if UI is available)
48
+ pi.on("session_start", async (_event, ctx) => {
49
+ if (ctx.hasUI) {
50
+ ctx.ui.setHeader((_tui, theme) => {
51
+ return {
52
+ render(_width: number): string[] {
53
+ const mascotLines = getPiMascot(theme);
54
+ // Add a subtitle with hint
55
+ const subtitle = theme.fg("muted", " shitty coding agent");
56
+ return [...mascotLines, subtitle];
57
+ },
58
+ invalidate() {},
59
+ };
60
+ });
61
+ }
62
+ });
63
+
64
+ // Command to restore built-in header
65
+ pi.registerCommand("builtin-header", {
66
+ description: "Restore built-in header with keybinding hints",
67
+ handler: async (_args, ctx) => {
68
+ ctx.ui.setHeader(undefined);
69
+ ctx.ui.notify("Built-in header restored", "info");
70
+ },
71
+ });
72
+ }