@astralyn/pi 0.82.6 → 0.83.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 (263) hide show
  1. package/CHANGELOG.md +61 -5099
  2. package/README.md +12 -111
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +9 -2
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/credential-print.d.ts +23 -0
  7. package/dist/cli/credential-print.d.ts.map +1 -0
  8. package/dist/cli/credential-print.js +116 -0
  9. package/dist/cli/credential-print.js.map +1 -0
  10. package/dist/cli/session-picker.d.ts.map +1 -1
  11. package/dist/cli/session-picker.js +11 -13
  12. package/dist/cli/session-picker.js.map +1 -1
  13. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  14. package/dist/core/agent-session-runtime.js +3 -0
  15. package/dist/core/agent-session-runtime.js.map +1 -1
  16. package/dist/core/agent-session.d.ts +1 -1
  17. package/dist/core/agent-session.d.ts.map +1 -1
  18. package/dist/core/agent-session.js +13 -6
  19. package/dist/core/agent-session.js.map +1 -1
  20. package/dist/core/extensions/runner.d.ts +1 -0
  21. package/dist/core/extensions/runner.d.ts.map +1 -1
  22. package/dist/core/extensions/runner.js +7 -0
  23. package/dist/core/extensions/runner.js.map +1 -1
  24. package/dist/core/extensions/types.d.ts +11 -0
  25. package/dist/core/extensions/types.d.ts.map +1 -1
  26. package/dist/core/extensions/types.js.map +1 -1
  27. package/dist/core/footer-data-provider.d.ts +10 -0
  28. package/dist/core/footer-data-provider.d.ts.map +1 -1
  29. package/dist/core/footer-data-provider.js +1 -1
  30. package/dist/core/footer-data-provider.js.map +1 -1
  31. package/dist/core/model-runtime.d.ts +2 -0
  32. package/dist/core/model-runtime.d.ts.map +1 -1
  33. package/dist/core/model-runtime.js.map +1 -1
  34. package/dist/core/package-manager.d.ts.map +1 -1
  35. package/dist/core/package-manager.js +13 -6
  36. package/dist/core/package-manager.js.map +1 -1
  37. package/dist/core/resource-loader.d.ts +15 -0
  38. package/dist/core/resource-loader.d.ts.map +1 -1
  39. package/dist/core/resource-loader.js +70 -9
  40. package/dist/core/resource-loader.js.map +1 -1
  41. package/dist/core/session-manager.d.ts.map +1 -1
  42. package/dist/core/session-manager.js +2 -2
  43. package/dist/core/session-manager.js.map +1 -1
  44. package/dist/core/tools/bash.d.ts +1 -5
  45. package/dist/core/tools/bash.d.ts.map +1 -1
  46. package/dist/core/tools/bash.js +16 -41
  47. package/dist/core/tools/bash.js.map +1 -1
  48. package/dist/core/tools/edit.d.ts.map +1 -1
  49. package/dist/core/tools/edit.js +42 -11
  50. package/dist/core/tools/edit.js.map +1 -1
  51. package/dist/core/tools/find.d.ts.map +1 -1
  52. package/dist/core/tools/find.js +40 -11
  53. package/dist/core/tools/find.js.map +1 -1
  54. package/dist/core/tools/grep.d.ts.map +1 -1
  55. package/dist/core/tools/grep.js +17 -8
  56. package/dist/core/tools/grep.js.map +1 -1
  57. package/dist/core/tools/ls.d.ts.map +1 -1
  58. package/dist/core/tools/ls.js +3 -3
  59. package/dist/core/tools/ls.js.map +1 -1
  60. package/dist/core/tools/read.d.ts.map +1 -1
  61. package/dist/core/tools/read.js +4 -4
  62. package/dist/core/tools/read.js.map +1 -1
  63. package/dist/core/tools/render-utils.d.ts +7 -0
  64. package/dist/core/tools/render-utils.d.ts.map +1 -1
  65. package/dist/core/tools/render-utils.js +11 -0
  66. package/dist/core/tools/render-utils.js.map +1 -1
  67. package/dist/core/tools/write.d.ts.map +1 -1
  68. package/dist/core/tools/write.js +2 -3
  69. package/dist/core/tools/write.js.map +1 -1
  70. package/dist/core/trust-manager.d.ts.map +1 -1
  71. package/dist/core/trust-manager.js +48 -9
  72. package/dist/core/trust-manager.js.map +1 -1
  73. package/dist/extensions/deepwiki/render.d.ts.map +1 -1
  74. package/dist/extensions/deepwiki/render.js +34 -22
  75. package/dist/extensions/deepwiki/render.js.map +1 -1
  76. package/dist/extensions/index.d.ts.map +1 -1
  77. package/dist/extensions/index.js +0 -2
  78. package/dist/extensions/index.js.map +1 -1
  79. package/dist/extensions/llama/provider.d.ts.map +1 -1
  80. package/dist/extensions/llama/provider.js +1 -1
  81. package/dist/extensions/llama/provider.js.map +1 -1
  82. package/dist/extensions/question/dialog.d.ts.map +1 -1
  83. package/dist/extensions/question/dialog.js +29 -10
  84. package/dist/extensions/question/dialog.js.map +1 -1
  85. package/dist/extensions/question/index.d.ts.map +1 -1
  86. package/dist/extensions/question/index.js +7 -2
  87. package/dist/extensions/question/index.js.map +1 -1
  88. package/dist/extensions/question/render.d.ts +4 -8
  89. package/dist/extensions/question/render.d.ts.map +1 -1
  90. package/dist/extensions/question/render.js +202 -29
  91. package/dist/extensions/question/render.js.map +1 -1
  92. package/dist/extensions/question/results.d.ts.map +1 -1
  93. package/dist/extensions/question/results.js +1 -0
  94. package/dist/extensions/question/results.js.map +1 -1
  95. package/dist/extensions/question/schema.d.ts.map +1 -1
  96. package/dist/extensions/question/schema.js +2 -2
  97. package/dist/extensions/question/schema.js.map +1 -1
  98. package/dist/extensions/question/types.d.ts +1 -0
  99. package/dist/extensions/question/types.d.ts.map +1 -1
  100. package/dist/extensions/question/types.js +1 -1
  101. package/dist/extensions/question/types.js.map +1 -1
  102. package/dist/extensions/router/stream.d.ts +1 -0
  103. package/dist/extensions/router/stream.d.ts.map +1 -1
  104. package/dist/extensions/router/stream.js +44 -13
  105. package/dist/extensions/router/stream.js.map +1 -1
  106. package/dist/extensions/subagent/constants.d.ts +1 -0
  107. package/dist/extensions/subagent/constants.d.ts.map +1 -1
  108. package/dist/extensions/subagent/constants.js +1 -0
  109. package/dist/extensions/subagent/constants.js.map +1 -1
  110. package/dist/extensions/subagent/index.d.ts.map +1 -1
  111. package/dist/extensions/subagent/index.js +2 -0
  112. package/dist/extensions/subagent/index.js.map +1 -1
  113. package/dist/extensions/subagent/render.d.ts.map +1 -1
  114. package/dist/extensions/subagent/render.js +121 -48
  115. package/dist/extensions/subagent/render.js.map +1 -1
  116. package/dist/extensions/subagent/retry.d.ts +17 -0
  117. package/dist/extensions/subagent/retry.d.ts.map +1 -0
  118. package/dist/extensions/subagent/retry.js +41 -0
  119. package/dist/extensions/subagent/retry.js.map +1 -0
  120. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  121. package/dist/extensions/subagent/runner.js +33 -5
  122. package/dist/extensions/subagent/runner.js.map +1 -1
  123. package/dist/extensions/subagent/sdk-runner.d.ts +6 -0
  124. package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
  125. package/dist/extensions/subagent/sdk-runner.js +46 -24
  126. package/dist/extensions/subagent/sdk-runner.js.map +1 -1
  127. package/dist/extensions/subagent/types.d.ts +7 -0
  128. package/dist/extensions/subagent/types.d.ts.map +1 -1
  129. package/dist/extensions/subagent/types.js.map +1 -1
  130. package/dist/extensions/todo/index.d.ts.map +1 -1
  131. package/dist/extensions/todo/index.js +1 -1
  132. package/dist/extensions/todo/index.js.map +1 -1
  133. package/dist/extensions/todo/state.d.ts.map +1 -1
  134. package/dist/extensions/todo/state.js +10 -2
  135. package/dist/extensions/todo/state.js.map +1 -1
  136. package/dist/extensions/todo/view.d.ts +1 -1
  137. package/dist/extensions/todo/view.d.ts.map +1 -1
  138. package/dist/extensions/todo/view.js +179 -34
  139. package/dist/extensions/todo/view.js.map +1 -1
  140. package/dist/main.d.ts.map +1 -1
  141. package/dist/main.js +43 -0
  142. package/dist/main.js.map +1 -1
  143. package/dist/migrations.d.ts.map +1 -1
  144. package/dist/migrations.js +2 -1
  145. package/dist/migrations.js.map +1 -1
  146. package/dist/modes/interactive/components/armin.d.ts.map +1 -1
  147. package/dist/modes/interactive/components/armin.js +5 -0
  148. package/dist/modes/interactive/components/armin.js.map +1 -1
  149. package/dist/modes/interactive/components/bash-execution.d.ts +1 -0
  150. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
  151. package/dist/modes/interactive/components/bash-execution.js +4 -2
  152. package/dist/modes/interactive/components/bash-execution.js.map +1 -1
  153. package/dist/modes/interactive/components/countdown-timer.d.ts +13 -6
  154. package/dist/modes/interactive/components/countdown-timer.d.ts.map +1 -1
  155. package/dist/modes/interactive/components/countdown-timer.js +54 -17
  156. package/dist/modes/interactive/components/countdown-timer.js.map +1 -1
  157. package/dist/modes/interactive/components/extension-selector.d.ts +1 -1
  158. package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
  159. package/dist/modes/interactive/components/extension-selector.js +10 -6
  160. package/dist/modes/interactive/components/extension-selector.js.map +1 -1
  161. package/dist/modes/interactive/components/keybinding-hints.d.ts +11 -1
  162. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  163. package/dist/modes/interactive/components/keybinding-hints.js +56 -0
  164. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  165. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  166. package/dist/modes/interactive/components/model-selector.js +4 -1
  167. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  168. package/dist/modes/interactive/components/session-selector.d.ts +3 -0
  169. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  170. package/dist/modes/interactive/components/session-selector.js +52 -2
  171. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  172. package/dist/modes/interactive/components/tool-execution.d.ts +10 -0
  173. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  174. package/dist/modes/interactive/components/tool-execution.js +133 -18
  175. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  176. package/dist/modes/interactive/components/tool-group.d.ts +1 -0
  177. package/dist/modes/interactive/components/tool-group.d.ts.map +1 -1
  178. package/dist/modes/interactive/components/tool-group.js +5 -0
  179. package/dist/modes/interactive/components/tool-group.js.map +1 -1
  180. package/dist/modes/interactive/components/tree-selector.d.ts +10 -1
  181. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  182. package/dist/modes/interactive/components/tree-selector.js +55 -2
  183. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  184. package/dist/modes/interactive/external-editor.d.ts.map +1 -1
  185. package/dist/modes/interactive/external-editor.js +40 -3
  186. package/dist/modes/interactive/external-editor.js.map +1 -1
  187. package/dist/modes/interactive/interactive-mode.d.ts +5 -0
  188. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  189. package/dist/modes/interactive/interactive-mode.js +92 -24
  190. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  191. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  192. package/dist/modes/interactive/theme/theme.js +3 -2
  193. package/dist/modes/interactive/theme/theme.js.map +1 -1
  194. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  195. package/dist/modes/rpc/rpc-mode.js +13 -0
  196. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  197. package/dist/utils/paths.d.ts +5 -0
  198. package/dist/utils/paths.d.ts.map +1 -1
  199. package/dist/utils/paths.js +7 -0
  200. package/dist/utils/paths.js.map +1 -1
  201. package/docs/bundled/README.md +13 -15
  202. package/docs/bundled/extensions/deepwiki.md +1 -1
  203. package/docs/bundled/extensions/question.md +7 -5
  204. package/docs/bundled/extensions/router.md +3 -4
  205. package/docs/bundled/extensions/statusline.md +0 -1
  206. package/docs/bundled/extensions/subagent.md +4 -3
  207. package/docs/bundled/extensions/todo.md +1 -1
  208. package/docs/bundled/themes.md +1 -1
  209. package/docs/bundled/tool-presentation.md +17 -16
  210. package/docs/compaction.md +25 -30
  211. package/docs/custom-provider.md +298 -583
  212. package/docs/docs.json +0 -8
  213. package/docs/extensions.md +132 -121
  214. package/docs/index.md +18 -74
  215. package/docs/json.md +20 -14
  216. package/docs/models.md +193 -151
  217. package/docs/providers.md +2 -1
  218. package/docs/quickstart.md +3 -3
  219. package/docs/rpc.md +166 -124
  220. package/docs/sdk.md +119 -178
  221. package/docs/security.md +1 -1
  222. package/docs/session-format.md +139 -119
  223. package/docs/skills.md +0 -1
  224. package/docs/themes.md +2 -2
  225. package/docs/tui.md +411 -549
  226. package/docs/usage.md +3 -11
  227. package/examples/README.md +6 -1
  228. package/examples/extensions/README.md +13 -33
  229. package/examples/extensions/custom-provider-anthropic/index.ts +9 -3
  230. package/examples/extensions/dynamic-resources/dynamic.json +1 -1
  231. package/examples/extensions/truncated-tool.ts +2 -2
  232. package/examples/sdk/12-full-control.ts +2 -0
  233. package/npm-shrinkwrap.json +25 -25
  234. package/package.json +5 -5
  235. package/dist/extensions/plan/constants.d.ts +0 -40
  236. package/dist/extensions/plan/constants.d.ts.map +0 -1
  237. package/dist/extensions/plan/constants.js +0 -69
  238. package/dist/extensions/plan/constants.js.map +0 -1
  239. package/dist/extensions/plan/index.d.ts +0 -3
  240. package/dist/extensions/plan/index.d.ts.map +0 -1
  241. package/dist/extensions/plan/index.js +0 -323
  242. package/dist/extensions/plan/index.js.map +0 -1
  243. package/dist/extensions/plan/schema.d.ts +0 -15
  244. package/dist/extensions/plan/schema.d.ts.map +0 -1
  245. package/dist/extensions/plan/schema.js +0 -13
  246. package/dist/extensions/plan/schema.js.map +0 -1
  247. package/dist/extensions/plan/state.d.ts +0 -29
  248. package/dist/extensions/plan/state.d.ts.map +0 -1
  249. package/dist/extensions/plan/state.js +0 -74
  250. package/dist/extensions/plan/state.js.map +0 -1
  251. package/dist/extensions/plan/storage.d.ts +0 -18
  252. package/dist/extensions/plan/storage.d.ts.map +0 -1
  253. package/dist/extensions/plan/storage.js +0 -58
  254. package/dist/extensions/plan/storage.js.map +0 -1
  255. package/dist/extensions/plan/view.d.ts +0 -36
  256. package/dist/extensions/plan/view.d.ts.map +0 -1
  257. package/dist/extensions/plan/view.js +0 -74
  258. package/dist/extensions/plan/view.js.map +0 -1
  259. package/docs/bundled/extensions/README.md +0 -19
  260. package/docs/bundled/extensions/plan.md +0 -33
  261. package/examples/extensions/plan-mode/README.md +0 -66
  262. package/examples/extensions/plan-mode/index.ts +0 -390
  263. package/examples/extensions/plan-mode/utils.ts +0 -168
package/docs/tui.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  # TUI Components
4
4
 
5
- Extensions and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.
5
+ Extensions and custom tools can render custom terminal UI in interactive mode. This page documents the public TUI package API that Pi exposes to extensions, plus Pi's extension UI helpers.
6
6
 
7
- **Source:** [`@earendil-works/pi-tui@0.82.1`](https://github.com/earendil-works/pi/tree/v0.82.1/packages/tui)
7
+ **API reference:** the public declarations shipped by [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) are authoritative. Pi builds on the [earendil-works/pi project](https://github.com/earendil-works/pi); Pi-specific helpers below are exported by `@astralyn/pi`.
8
8
 
9
9
  ## Component Interface
10
10
 
11
- All components implement:
11
+ Every rendered component implements the public `Component` interface:
12
12
 
13
13
  ```typescript
14
14
  interface Component {
@@ -19,56 +19,51 @@ interface Component {
19
19
  }
20
20
  ```
21
21
 
22
- | Method | Description |
23
- |--------|-------------|
24
- | `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
25
- | `handleInput?(data)` | Receive keyboard input when component has focus. |
26
- | `wantsKeyRelease?` | If true, component receives key release events (Kitty protocol). Default: false. |
27
- | `invalidate()` | Clear cached render state. Called on theme changes. |
22
+ | Member | Description |
23
+ |---|---|
24
+ | `render(width)` | Return one string per line. Every line must be at most `width` terminal columns wide. |
25
+ | `handleInput?(data)` | Receives raw terminal input while this component has focus. |
26
+ | `wantsKeyRelease?` | Set to `true` only when the component needs Kitty keyboard key-release events. Releases are otherwise filtered. |
27
+ | `invalidate()` | Clear cached render state. TUI calls it when the theme changes; call it yourself before rendering changed cached state. |
28
28
 
29
- The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
29
+ `invalidate()` is required even for an uncached component; an empty implementation is fine. `invalidate()` does **not** schedule a frame. After application state changes, call the injected `tui.requestRender()`.
30
30
 
31
- ## Focusable Interface (IME Support)
31
+ ## Focusable Components and IME
32
32
 
33
- Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
33
+ A component that renders a text cursor and needs IME candidate-window placement implements `Focusable` as well as `Component`:
34
34
 
35
35
  ```typescript
36
36
  import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui";
37
37
 
38
- class MyInput implements Component, Focusable {
39
- focused: boolean = false; // Set by TUI when focus changes
40
-
38
+ class CursorCell implements Component, Focusable {
39
+ focused = false;
40
+
41
41
  render(width: number): string[] {
42
+ if (width < 1) return [];
43
+ // Put the zero-width marker immediately before the visual cursor.
42
44
  const marker = this.focused ? CURSOR_MARKER : "";
43
- // Emit marker right before the fake cursor
44
- return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
45
+ return [`${marker}\x1b[7m \x1b[27m`];
45
46
  }
47
+
48
+ invalidate(): void {}
46
49
  }
47
50
  ```
48
51
 
49
- When a `Focusable` component has focus, TUI:
50
- 1. Sets `focused = true` on the component
51
- 2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence)
52
- 3. Positions the hardware terminal cursor at that location
53
- 4. Shows the hardware cursor only when `showHardwareCursor` is enabled
54
-
55
- The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with `showHardwareCursor`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.
56
-
57
- ### Container Components with Embedded Inputs
52
+ When a focused component has a `focused` property, TUI sets it and looks for `CURSOR_MARKER` in its rendered lines to position the hardware cursor. `Input` and `Editor` already implement this. An extension can query or change the injected TUI's hardware-cursor setting with `tui.getShowHardwareCursor()` and `tui.setShowHardwareCursor(enabled)`; changing it affects the whole application UI.
58
53
 
59
- When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child. Otherwise, the hardware cursor won't be positioned correctly for IME input.
54
+ A container that owns an `Input` or `Editor` must forward both focus and input to its child. `Container` only groups and renders children; it does not route input for them.
60
55
 
61
56
  ```typescript
62
57
  import { Container, type Focusable, Input } from "@earendil-works/pi-tui";
63
58
 
64
59
  class SearchDialog extends Container implements Focusable {
65
- private searchInput: Input;
66
-
67
- // Focusable implementation - propagate to child input for IME cursor positioning
60
+ private readonly searchInput: Input;
68
61
  private _focused = false;
62
+
69
63
  get focused(): boolean {
70
64
  return this._focused;
71
65
  }
66
+
72
67
  set focused(value: boolean) {
73
68
  this._focused = value;
74
69
  this.searchInput.focused = value;
@@ -79,297 +74,255 @@ class SearchDialog extends Container implements Focusable {
79
74
  this.searchInput = new Input();
80
75
  this.addChild(this.searchInput);
81
76
  }
77
+
78
+ handleInput(data: string): void {
79
+ this.searchInput.handleInput(data);
80
+ }
82
81
  }
83
82
  ```
84
83
 
85
- Without this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position on screen.
84
+ Without that propagation, an IME can place its candidate window at the wrong screen location.
86
85
 
87
- ## Using Components
86
+ ## Show a Component in Pi
88
87
 
89
- **In extensions** via `ctx.ui.custom()`:
88
+ `ctx.ui.custom()` replaces the editor until its `done` callback is called. It is available only in TUI mode, so guard commands or tools that use it with `ctx.mode === "tui"`.
90
89
 
91
90
  ```typescript
92
- pi.on("session_start", async (_event, ctx) => {
93
- const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
94
- new MyComponent({
95
- theme,
96
- keybindings,
97
- onChange: () => tui.requestRender(),
98
- onSelect: (value) => done(value),
99
- onCancel: () => done(null),
100
- })
101
- );
102
- });
91
+ if (ctx.mode !== "tui") return;
92
+
93
+ const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
94
+ new MyComponent({
95
+ theme,
96
+ keybindings,
97
+ onChange: () => tui.requestRender(),
98
+ onSelect: (value) => done(value),
99
+ onCancel: () => done(null),
100
+ })
101
+ );
103
102
  ```
104
103
 
105
- **In custom tools** via `ctx.ui.custom()`:
104
+ The factory receives Pi's current `TUI`, semantic `Theme`, and configured `KeybindingsManager`. It may return a component synchronously or as a promise. The `custom()` promise resolves only when the component calls `done(result)`.
106
105
 
107
- ```typescript
108
- async execute(toolCallId, params, signal, onUpdate, ctx) {
109
- const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
110
- new MyComponent({
111
- theme,
112
- keybindings,
113
- onChange: () => tui.requestRender(),
114
- onSelect: (value) => done(value),
115
- onCancel: () => done(null),
116
- })
117
- );
118
- // Use result...
119
- }
120
- ```
106
+ ### Disposal
107
+
108
+ A component returned from `ctx.ui.custom()`, `setWidget()`, `setFooter()`, or `setHeader()` may also implement `dispose(): void`. Use it to stop timers, abort owned work, and unsubscribe listeners. Pi calls it after a custom component finishes, or when a widget, footer, or header is replaced or cleared.
109
+
110
+ Do not reuse a component after it has called `done`. Create a fresh instance if it must be shown again. In particular, an overlay handle's `hide()` only removes that overlay; it does not resolve the surrounding `ctx.ui.custom()` promise or dispose the component. Have the component call `done` to close its Pi UI lifecycle.
121
111
 
122
112
  ## Overlays
123
113
 
124
- Overlays render components on top of existing content without clearing the screen. Pass `{ overlay: true }` to `ctx.ui.custom()`:
114
+ Pass `{ overlay: true }` to leave the existing UI visible and show a component above it:
125
115
 
126
116
  ```typescript
127
117
  const result = await ctx.ui.custom<string | null>(
128
- (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
118
+ (_tui, theme, _keybindings, done) => new MyDialog({ theme, onClose: () => done(null) }),
129
119
  { overlay: true }
130
120
  );
131
121
  ```
132
122
 
133
- For positioning and sizing, use `overlayOptions`:
123
+ Use `overlayOptions` to choose its layout and focus behavior:
134
124
 
135
125
  ```typescript
136
- const result = await ctx.ui.custom<string | null>(
137
- (tui, theme, keybindings, done) => new SidePanel({ onClose: done }),
126
+ await ctx.ui.custom<void>(
127
+ (_tui, theme, _keybindings, done) => new SidePanel({ theme, onClose: () => done() }),
138
128
  {
139
129
  overlay: true,
140
130
  overlayOptions: {
141
- // Size: number or percentage string
142
- width: "50%", // 50% of terminal width
143
- minWidth: 40, // minimum 40 columns
144
- maxHeight: "80%", // max 80% of terminal height
145
-
146
- // Position: anchor-based (default: "center")
147
- anchor: "right-center", // 9 positions: center, top-left, top-center, etc.
148
- offsetX: -2, // offset from anchor
131
+ width: "50%", // number of columns or a percentage string
132
+ minWidth: 40,
133
+ maxHeight: "80%", // number of rows or a percentage string
134
+ anchor: "right-center", // center plus eight edge/corner anchors
135
+ offsetX: -2,
149
136
  offsetY: 0,
150
-
151
- // Or percentage/absolute positioning
152
- row: "25%", // 25% from top
153
- col: 10, // column 10
154
-
155
- // Margins
156
- margin: 2, // all sides, or { top, right, bottom, left }
157
-
158
- // Responsive: hide on narrow terminals
159
- visible: (termWidth, termHeight) => termWidth >= 80,
137
+ row: "25%", // absolute number or percentage from the top
138
+ col: 10, // absolute number or percentage from the left
139
+ margin: 2, // number, or { top, right, bottom, left }
140
+ visible: (termWidth, _termHeight) => termWidth >= 80,
141
+ nonCapturing: false, // set true for a visible overlay that never takes input
160
142
  },
161
- // Get handle for programmatic focus and visibility control
162
143
  onHandle: (handle) => {
163
- // handle.focus() - focus this overlay and bring it to the visual front
164
- // handle.unfocus() - release input to normal fallback
165
- // handle.unfocus({ target }) - release input to a specific component or null
166
- // handle.setHidden(true/false) - toggle visibility
167
- // handle.hide() - permanently remove
144
+ // handle.focus(), handle.unfocus(), handle.setHidden(), and handle.hide()
168
145
  },
169
146
  }
170
147
  );
171
148
  ```
172
149
 
173
- ### Overlay Focus
174
-
175
- A focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens another `ctx.ui.custom()` component without `{ overlay: true }`, that replacement UI receives input while it is active; when it closes, the focused overlay can reclaim input.
150
+ The anchors are `"center"`, `"top-left"`, `"top-center"`, `"top-right"`, `"left-center"`, `"right-center"`, `"bottom-left"`, `"bottom-center"`, and `"bottom-right"`. An `overlayOptions` function is also accepted when the options need to be computed at the time the overlay is shown.
176
151
 
177
- Use `handle.unfocus()` when a visible overlay should stop owning input and let TUI fall back to another visible capturing overlay or the previous focus target. Use `handle.unfocus({ target })` when a specific component should receive input while the overlay stays visible. Passing `{ target: null }` intentionally leaves no focused component until focus is set again.
152
+ `onHandle` receives the public `OverlayHandle` after the overlay is shown:
178
153
 
179
- ### Overlay Lifecycle
154
+ - `focus()` gives the overlay input and brings it to the visual front.
155
+ - `unfocus()` releases input to another visible capturing overlay or the prior focus target. Use `unfocus({ target })` to focus a particular component, or `unfocus({ target: null })` to leave nothing focused.
156
+ - `setHidden(true)` temporarily hides the overlay; `setHidden(false)` shows it again. `isHidden()` reports that state.
157
+ - `hide()` permanently removes the overlay. `isFocused()` reports whether it owns focus.
180
158
 
181
- Overlay components are disposed when closed. Don't reuse references - create fresh instances:
182
-
183
- ```typescript
184
- // Wrong - stale reference
185
- let menu: MenuComponent;
186
- await ctx.ui.custom((_, __, ___, done) => {
187
- menu = new MenuComponent(done);
188
- return menu;
189
- }, { overlay: true });
190
- setActiveComponent(menu); // Disposed
191
-
192
- // Correct - re-call to re-show
193
- const showMenu = () => ctx.ui.custom((_, __, ___, done) =>
194
- new MenuComponent(done), { overlay: true });
195
-
196
- await showMenu(); // First show
197
- await showMenu(); // "Back" = just call again
198
- ```
159
+ A visible focused overlay can regain focus after a temporary non-overlay `ctx.ui.custom()` view closes. Use `unfocus()` when it should stop capturing input instead.
199
160
 
200
- See [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for comprehensive examples covering anchors, margins, stacking, responsive visibility, and animation.
161
+ See [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for anchors, margins, stacking, responsive visibility, and focus examples.
201
162
 
202
163
  ## Built-in Components
203
164
 
204
- Import from `@earendil-works/pi-tui`:
165
+ Import TUI primitives from `@earendil-works/pi-tui`. In Pi extension factories, use the supplied `theme` and its semantic helpers rather than hard-coded ANSI colors.
205
166
 
206
- ```typescript
207
- import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";
208
- ```
209
-
210
- ### Text
211
-
212
- Multi-line text with word wrapping.
167
+ ### Layout and text
213
168
 
214
169
  ```typescript
215
- const text = new Text(
216
- "Hello World", // content
217
- 1, // paddingX (default: 1)
218
- 1, // paddingY (default: 1)
219
- (s) => bgGray(s) // optional background function
220
- );
221
- text.setText("Updated");
222
- ```
170
+ import { Box, Container, Spacer, Text, TruncatedText } from "@earendil-works/pi-tui";
223
171
 
224
- ### Box
172
+ const background = (text: string) => theme.bg("selectedBg", text);
225
173
 
226
- Container with padding and background color.
174
+ const text = new Text("Hello World", 1, 1, background);
175
+ text.setText("Updated");
176
+ text.setCustomBgFn((value: string) => theme.bg("toolPendingBg", value));
227
177
 
228
- ```typescript
229
- const box = new Box(
230
- 1, // paddingX
231
- 1, // paddingY
232
- (s) => bgGray(s) // background function
233
- );
178
+ const box = new Box(1, 1, background);
234
179
  box.addChild(new Text("Content", 0, 0));
235
- box.setBgFn((s) => bgBlue(s));
236
- ```
237
-
238
- ### Container
239
-
240
- Groups child components vertically.
180
+ box.setBgFn((value: string) => theme.bg("selectedBg", value));
241
181
 
242
- ```typescript
243
182
  const container = new Container();
244
- container.addChild(component1);
245
- container.addChild(component2);
246
- container.removeChild(component1);
247
- ```
248
-
249
- ### Spacer
250
-
251
- Empty vertical space.
183
+ container.addChild(box);
184
+ container.addChild(new Spacer(1));
185
+ container.removeChild(box);
186
+ container.clear();
252
187
 
253
- ```typescript
254
- const spacer = new Spacer(2); // 2 empty lines
188
+ const singleLine = new TruncatedText("A long status line", 0, 0);
255
189
  ```
256
190
 
257
- ### Markdown
191
+ `Text` wraps multi-line text. `TruncatedText` renders text constrained to one line. `Box` applies padding and an optional background function around its children. `Spacer(lines?)` creates empty lines and supports `setLines(lines)`.
258
192
 
259
- Renders markdown with syntax highlighting.
193
+ ### Markdown and images
260
194
 
261
- ```typescript
262
- const md = new Markdown(
263
- "# Title\n\nSome **bold** text",
264
- 1, // paddingX
265
- 1, // paddingY
266
- theme // MarkdownTheme (see below)
267
- );
268
- md.setText("Updated markdown");
269
- ```
195
+ Pi exports a semantic Markdown theme builder. `Markdown` requires its text, horizontal padding, vertical padding, and a `MarkdownTheme`.
270
196
 
271
- ### Image
197
+ ```typescript
198
+ import { getMarkdownTheme } from "@astralyn/pi";
199
+ import { Image, Markdown } from "@earendil-works/pi-tui";
272
200
 
273
- Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm, Warp).
201
+ const markdown = new Markdown("# Title\n\nSome **bold** text", 1, 1, getMarkdownTheme());
202
+ markdown.setText("Updated markdown");
274
203
 
275
- ```typescript
276
204
  const image = new Image(
277
- base64Data, // base64-encoded image
278
- "image/png", // MIME type
279
- theme, // ImageTheme
205
+ base64Data,
206
+ "image/png",
207
+ { fallbackColor: (text: string) => theme.fg("muted", text) },
280
208
  { maxWidthCells: 80, maxHeightCells: 24 }
281
209
  );
282
210
  ```
283
211
 
212
+ `Image` accepts base64 data, a MIME type, an `ImageTheme` with `fallbackColor`, and optional sizing, filename, or reusable `imageId` options. It renders a text fallback where inline images are unsupported.
213
+
214
+ ### Inputs, selectors, and loaders
215
+
216
+ `Input` is a single-line focused editor. `Editor` is the multi-line editor component; for Pi's main editor, extend Pi's `CustomEditor` instead of constructing a bare `Editor` so app shortcuts continue to work.
217
+
218
+ `SelectList` takes `(items, maxVisible, theme, layout?)`; `SettingsList` takes `(items, maxVisible, theme, onChange, onCancel, options?)`. Both use Pi's configured selection bindings. `Loader` takes `(tui, spinnerColorFn, messageColorFn, message?, indicator?)`; `CancellableLoader` adds `signal`, `aborted`, `onAbort`, and `dispose()`. Pi's `BorderedLoader` wraps the latter with Pi-native framing and cancellation hints; see [Pattern 2](#pattern-2-async-operation-with-cancel-borderedloader).
219
+
284
220
  ## Keyboard Input
285
221
 
286
- Use `matchesKey()` for key detection:
222
+ Use `matchesKey()` and `Key` for fixed, extension-specific keys. They handle legacy terminal sequences and Kitty keyboard protocol.
287
223
 
288
224
  ```typescript
289
- import { matchesKey, Key } from "@earendil-works/pi-tui";
225
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
290
226
 
291
- handleInput(data: string) {
227
+ handleInput(data: string): void {
292
228
  if (matchesKey(data, Key.up)) {
293
- this.selectedIndex--;
229
+ this.moveUp();
294
230
  } else if (matchesKey(data, Key.enter)) {
295
- this.onSelect?.(this.selectedIndex);
231
+ this.confirm();
296
232
  } else if (matchesKey(data, Key.escape)) {
297
- this.onCancel?.();
233
+ this.cancel();
298
234
  } else if (matchesKey(data, Key.ctrl("c"))) {
299
- // Ctrl+C
235
+ this.cancel();
236
+ }
237
+ }
238
+ ```
239
+
240
+ `Key` provides basic, navigation, function, and symbol keys, plus `ctrl`, `shift`, `alt`, `super`, and combined-modifier helpers. A `KeyId` string such as `"enter"`, `"ctrl+c"`, or `"ctrl+shift+p"` also works.
241
+
242
+ For behavior that follows a Pi-configurable binding, use the `keybindings` callback argument instead of checking a default key directly:
243
+
244
+ ```typescript
245
+ import type { KeybindingsManager } from "@astralyn/pi";
246
+
247
+ class MyDialog {
248
+ constructor(private readonly keybindings: KeybindingsManager) {}
249
+
250
+ handleInput(data: string): void {
251
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
252
+ this.close();
253
+ }
300
254
  }
301
255
  }
302
256
  ```
303
257
 
304
- **Key identifiers** (use `Key.*` for autocomplete, or string literals):
305
- - Basic keys: `Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end`
306
- - Arrow keys: `Key.up`, `Key.down`, `Key.left`, `Key.right`
307
- - With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
308
- - String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
258
+ Pi supplies `tui.*` IDs such as `tui.select.up`, `tui.select.confirm`, and `tui.select.cancel`, along with `app.*` IDs such as `app.interrupt`. Use Pi's `keyHint()` helper when displaying a configured binding in UI text; it formats the current binding rather than hard-coding a key label.
309
259
 
310
260
  ## Line Width
311
261
 
312
- **Critical:** Each line from `render()` must not exceed the `width` parameter.
262
+ Every string returned by `render(width)` must fit within `width` visible terminal columns. ANSI styling does not count toward display width.
313
263
 
314
264
  ```typescript
315
- import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
265
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
316
266
 
317
267
  render(width: number): string[] {
318
- // Truncate long lines
319
- return [truncateToWidth(this.text, width)];
268
+ const line = truncateToWidth(this.text, width);
269
+ const wrapped = wrapTextWithAnsi(this.longText, width);
270
+ return visibleWidth(line) <= width ? [line, ...wrapped] : [truncateToWidth(line, width)];
320
271
  }
321
272
  ```
322
273
 
323
- Utilities:
324
- - `visibleWidth(str)` - Get display width (ignores ANSI codes)
325
- - `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis
326
- - `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes
274
+ `truncateToWidth(text, width, ellipsis?, pad?)` preserves ANSI sequences and can use `""` as its ellipsis. `wrapTextWithAnsi()` preserves active ANSI styles across wrapped lines.
327
275
 
328
- ## Creating Custom Components
276
+ ## Creating a Custom Component
329
277
 
330
- Example: Interactive selector
278
+ This selector uses Pi's configured list bindings, semantic colors, width-safe output, caching, and explicit render requests without a forwarding wrapper:
331
279
 
332
280
  ```typescript
333
- import {
334
- matchesKey, Key,
335
- truncateToWidth, visibleWidth
336
- } from "@earendil-works/pi-tui";
281
+ import type { KeybindingsManager, Theme } from "@astralyn/pi";
282
+ import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
337
283
 
338
- class MySelector {
339
- private items: string[];
284
+ class MySelector implements Component {
340
285
  private selected = 0;
341
286
  private cachedWidth?: number;
342
287
  private cachedLines?: string[];
343
-
344
- public onSelect?: (item: string) => void;
345
- public onCancel?: () => void;
346
288
 
347
- constructor(items: string[]) {
348
- this.items = items;
349
- }
289
+ constructor(
290
+ private readonly items: readonly string[],
291
+ private readonly theme: Theme,
292
+ private readonly keybindings: KeybindingsManager,
293
+ private readonly requestRender: () => void,
294
+ private readonly onSelect: (item: string) => void,
295
+ private readonly onCancel: () => void,
296
+ ) {}
350
297
 
351
298
  handleInput(data: string): void {
352
- if (matchesKey(data, Key.up) && this.selected > 0) {
299
+ if (this.keybindings.matches(data, "tui.select.up") && this.selected > 0) {
353
300
  this.selected--;
354
- this.invalidate();
355
- } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {
301
+ } else if (this.keybindings.matches(data, "tui.select.down") && this.selected < this.items.length - 1) {
356
302
  this.selected++;
357
- this.invalidate();
358
- } else if (matchesKey(data, Key.enter)) {
359
- this.onSelect?.(this.items[this.selected]);
360
- } else if (matchesKey(data, Key.escape)) {
361
- this.onCancel?.();
303
+ } else if (this.keybindings.matches(data, "tui.select.confirm")) {
304
+ const item = this.items[this.selected];
305
+ if (item !== undefined) this.onSelect(item);
306
+ return;
307
+ } else if (this.keybindings.matches(data, "tui.select.cancel")) {
308
+ this.onCancel();
309
+ return;
310
+ } else {
311
+ return;
362
312
  }
313
+
314
+ this.invalidate();
315
+ this.requestRender();
363
316
  }
364
317
 
365
318
  render(width: number): string[] {
366
- if (this.cachedLines && this.cachedWidth === width) {
367
- return this.cachedLines;
368
- }
319
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
369
320
 
370
- this.cachedLines = this.items.map((item, i) => {
371
- const prefix = i === this.selected ? "> " : " ";
372
- return truncateToWidth(prefix + item, width);
321
+ this.cachedLines = this.items.map((item, index) => {
322
+ const selected = index === this.selected;
323
+ const prefix = selected ? this.theme.fg("accent", "› ") : " ";
324
+ const label = this.theme.fg(selected ? "accent" : "text", item);
325
+ return truncateToWidth(prefix + label, width);
373
326
  });
374
327
  this.cachedWidth = width;
375
328
  return this.cachedLines;
@@ -382,55 +335,34 @@ class MySelector {
382
335
  }
383
336
  ```
384
337
 
385
- Usage in an extension:
338
+ Use it directly from an extension command:
386
339
 
387
340
  ```typescript
388
- pi.registerCommand("pick", {
389
- description: "Pick an item",
390
- handler: async (_args, ctx) => {
391
- const items = ["Option A", "Option B", "Option C"];
392
- const selected = await ctx.ui.custom<string | null>((tui, _theme, _keybindings, done) => {
393
- const selector = new MySelector(items);
394
- selector.onSelect = done;
395
- selector.onCancel = () => done(null);
396
-
397
- return {
398
- render: (width) => selector.render(width),
399
- handleInput: (data) => {
400
- selector.handleInput(data);
401
- tui.requestRender();
402
- },
403
- invalidate: () => selector.invalidate(),
404
- };
405
- });
341
+ const items = ["Option A", "Option B", "Option C"];
342
+ const selected = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
343
+ new MySelector(items, theme, keybindings, () => tui.requestRender(), done, () => done(null))
344
+ );
406
345
 
407
- if (selected !== null) {
408
- ctx.ui.notify(`Selected: ${selected}`, "info");
409
- }
410
- }
411
- });
346
+ if (selected !== null) {
347
+ ctx.ui.notify(`Selected: ${selected}`, "info");
348
+ }
412
349
  ```
413
350
 
414
351
  ## Theming
415
352
 
416
- Components accept theme objects for styling.
417
-
418
- **In `renderCall`/`renderResult`**, use the `theme` parameter:
353
+ Use the `Theme` passed to a UI factory or renderer. `theme.fg(color, text)` and `theme.bg(color, text)` use the user's semantic theme instead of fixed ANSI colors.
419
354
 
420
355
  ```typescript
421
- renderResult(result, options, theme, context) {
422
- // Use theme.fg() for foreground colors
423
- return new Text(theme.fg("success", "Done!"), 0, 0);
424
-
425
- // Use theme.bg() for background colors
426
- const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
356
+ renderResult(_result, _options, theme, _context) {
357
+ const text = theme.bg("toolSuccessBg", theme.fg("success", "Done!"));
358
+ return new Text(text, 0, 0);
427
359
  }
428
360
  ```
429
361
 
430
- **Foreground colors** (`theme.fg(color, text)`):
362
+ Foreground color names:
431
363
 
432
364
  | Category | Colors |
433
- |----------|--------|
365
+ |---|---|
434
366
  | General | `text`, `accent`, `muted`, `dim` |
435
367
  | Status | `success`, `error`, `warning` |
436
368
  | Borders | `border`, `borderAccent`, `borderMuted` |
@@ -439,59 +371,66 @@ renderResult(result, options, theme, context) {
439
371
  | Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
440
372
  | Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
441
373
  | Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
442
- | Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax` |
443
- | Modes | `bashMode` |
374
+ | Thinking | `thinkingText`, `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax` |
375
+ | Mode | `bashMode` |
444
376
 
445
- **Background colors** (`theme.bg(color, text)`):
377
+ Background color names: `selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, and `toolErrorBg`.
446
378
 
447
- `selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`
448
-
449
- **For Markdown**, use `getMarkdownTheme()`:
379
+ For Markdown, use Pi's public helper:
450
380
 
451
381
  ```typescript
452
382
  import { getMarkdownTheme } from "@astralyn/pi";
453
383
  import { Markdown } from "@earendil-works/pi-tui";
454
384
 
455
- renderResult(result, options, theme, context) {
456
- const mdTheme = getMarkdownTheme();
457
- return new Markdown(result.details.markdown, 0, 0, mdTheme);
385
+ renderResult(result, _options, _theme, _context) {
386
+ return new Markdown(result.details.markdown, 0, 0, getMarkdownTheme());
458
387
  }
459
388
  ```
460
389
 
461
- **For custom components**, define your own theme interface:
390
+ ## Invalidation and Theme Changes
391
+
392
+ If a component caches lines that contain styled strings, it must clear that cache in `invalidate()`. If it creates a `Text`, `SettingsList`, or other child with already styled strings, clear the child cache **and recreate those strings** on invalidation. Calling `Container.invalidate()` alone cannot change ANSI sequences already stored in a child.
462
393
 
463
394
  ```typescript
464
- interface MyTheme {
465
- selected: (s: string) => string;
466
- normal: (s: string) => string;
467
- }
468
- ```
395
+ import { Container, Text } from "@earendil-works/pi-tui";
469
396
 
470
- ## Debug logging
397
+ // Inside a ctx.ui.custom() factory, where `theme` is supplied:
398
+ const container = new Container();
399
+ const title = new Text("", 1, 0);
400
+ container.addChild(title);
471
401
 
472
- Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.
402
+ const refreshTheme = () => {
403
+ title.setText(theme.fg("accent", theme.bold("Current title")));
404
+ };
473
405
 
474
- ```bash
475
- PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts
476
- ```
406
+ refreshTheme();
477
407
 
478
- ## Performance
408
+ const component = {
409
+ render: (width: number) => container.render(width),
410
+ invalidate: () => {
411
+ refreshTheme();
412
+ container.invalidate();
413
+ },
414
+ };
415
+ ```
479
416
 
480
- Cache rendered output when possible:
417
+ For a component that styles its output inside `render()`, simply clearing cached lines is enough:
481
418
 
482
419
  ```typescript
483
- class CachedComponent {
420
+ import type { Theme } from "@astralyn/pi";
421
+ import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
422
+
423
+ class CachedComponent implements Component {
484
424
  private cachedWidth?: number;
485
425
  private cachedLines?: string[];
486
426
 
427
+ constructor(private readonly theme: Theme, private readonly text: string) {}
428
+
487
429
  render(width: number): string[] {
488
- if (this.cachedLines && this.cachedWidth === width) {
489
- return this.cachedLines;
490
- }
491
- // ... compute lines ...
430
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
492
431
  this.cachedWidth = width;
493
- this.cachedLines = lines;
494
- return lines;
432
+ this.cachedLines = [truncateToWidth(this.theme.fg("muted", this.text), width)];
433
+ return this.cachedLines;
495
434
  }
496
435
 
497
436
  invalidate(): void {
@@ -501,192 +440,95 @@ class CachedComponent {
501
440
  }
502
441
  ```
503
442
 
504
- Call `invalidate()` when state changes, then use the injected `tui.requestRender()` to trigger re-render.
505
-
506
- ## Invalidation and Theme Changes
507
-
508
- When the theme changes, the TUI calls `invalidate()` on all components to clear their caches. Components must properly implement `invalidate()` to ensure theme changes take effect.
509
-
510
- ### The Problem
511
-
512
- If a component pre-bakes theme colors into strings (via `theme.fg()`, `theme.bg()`, etc.) and caches them, the cached strings contain ANSI escape codes from the old theme. Simply clearing the render cache isn't enough if the component stores the themed content separately.
513
-
514
- **Wrong approach** (theme colors won't update):
515
-
516
- ```typescript
517
- class BadComponent extends Container {
518
- private content: Text;
519
-
520
- constructor(message: string, theme: Theme) {
521
- super();
522
- // Pre-baked theme colors stored in Text component
523
- this.content = new Text(theme.fg("accent", message), 1, 0);
524
- this.addChild(this.content);
525
- }
526
- // No invalidate override - parent's invalidate only clears
527
- // child render caches, not the pre-baked content
528
- }
529
- ```
530
-
531
- ### The Solution
532
-
533
- Components that build content with theme colors must rebuild that content when `invalidate()` is called:
534
-
535
- ```typescript
536
- class GoodComponent extends Container {
537
- private message: string;
538
- private content: Text;
539
-
540
- constructor(message: string) {
541
- super();
542
- this.message = message;
543
- this.content = new Text("", 1, 0);
544
- this.addChild(this.content);
545
- this.updateDisplay();
546
- }
547
-
548
- private updateDisplay(): void {
549
- // Rebuild content with current theme
550
- this.content.setText(theme.fg("accent", this.message));
551
- }
552
-
553
- override invalidate(): void {
554
- super.invalidate(); // Clear child caches
555
- this.updateDisplay(); // Rebuild with new theme
556
- }
557
- }
558
- ```
559
-
560
- ### Pattern: Rebuild on Invalidate
561
-
562
- For components with complex content:
563
-
564
- ```typescript
565
- class ComplexComponent extends Container {
566
- private data: SomeData;
567
-
568
- constructor(data: SomeData) {
569
- super();
570
- this.data = data;
571
- this.rebuild();
572
- }
573
-
574
- private rebuild(): void {
575
- this.clear(); // Remove all children
576
-
577
- // Build UI with current theme
578
- this.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));
579
- this.addChild(new Spacer(1));
580
-
581
- for (const item of this.data.items) {
582
- const color = item.active ? "success" : "muted";
583
- this.addChild(new Text(theme.fg(color, item.label), 1, 0));
584
- }
585
- }
586
-
587
- override invalidate(): void {
588
- super.invalidate();
589
- this.rebuild();
590
- }
591
- }
592
- ```
593
-
594
- ### When This Matters
595
-
596
- This pattern is needed when:
597
-
598
- 1. **Pre-baking theme colors** - Using `theme.fg()` or `theme.bg()` to create styled strings stored in child components
599
- 2. **Syntax highlighting** - Using `highlightCode()` which applies theme-based syntax colors
600
- 3. **Complex layouts** - Building child component trees that embed theme colors
601
-
602
- This pattern is NOT needed when:
603
-
604
- 1. **Using theme callbacks** - Passing functions like `(text) => theme.fg("accent", text)` that are called during render
605
- 2. **Simple containers** - Just grouping other components without adding themed content
606
- 3. **Stateless render** - Computing themed output fresh in every `render()` call (no caching)
443
+ Call `invalidate()` and then `tui.requestRender()` whenever component state changes outside input handling, such as in a timer, event listener, or async completion. Implement `dispose()` as well when that state is driven by a timer or subscription.
607
444
 
608
445
  ## Common Patterns
609
446
 
610
- These patterns cover the most common UI needs in extensions. **Copy these patterns instead of building from scratch.**
611
-
612
- ### Pattern 1: Selection Dialog (SelectList)
613
-
614
- For letting users pick from a list of options. Use `SelectList` from `@earendil-works/pi-tui` with `DynamicBorder` for framing.
447
+ ### Pattern 1: Selection Dialog (`SelectList`)
615
448
 
616
449
  ```typescript
617
- import type { ExtensionAPI } from "@astralyn/pi";
618
- import { DynamicBorder } from "@astralyn/pi";
450
+ import { DynamicBorder, keyHint } from "@astralyn/pi";
619
451
  import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
620
452
 
621
453
  pi.registerCommand("pick", {
622
454
  handler: async (_args, ctx) => {
455
+ if (ctx.mode !== "tui") return;
456
+
623
457
  const items: SelectItem[] = [
624
458
  { value: "opt1", label: "Option 1", description: "First option" },
625
459
  { value: "opt2", label: "Option 2", description: "Second option" },
626
- { value: "opt3", label: "Option 3" }, // description is optional
460
+ { value: "opt3", label: "Option 3" },
627
461
  ];
628
462
 
629
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
463
+ const result = await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
630
464
  const container = new Container();
631
-
632
- // Top border
633
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
634
-
635
- // Title
636
- container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0));
637
-
638
- // SelectList with theme
465
+ const title = new Text("", 1, 0);
466
+ const hint = new Text("", 1, 0);
639
467
  const selectList = new SelectList(items, Math.min(items.length, 10), {
640
- selectedPrefix: (t) => theme.fg("accent", t),
641
- selectedText: (t) => theme.fg("accent", t),
642
- description: (t) => theme.fg("muted", t),
643
- scrollInfo: (t) => theme.fg("dim", t),
644
- noMatch: (t) => theme.fg("warning", t),
468
+ selectedPrefix: (text: string) => theme.fg("accent", text),
469
+ selectedText: (text: string) => theme.fg("accent", text),
470
+ description: (text: string) => theme.fg("muted", text),
471
+ scrollInfo: (text: string) => theme.fg("dim", text),
472
+ noMatch: (text: string) => theme.fg("warning", text),
645
473
  });
646
474
  selectList.onSelect = (item) => done(item.value);
647
475
  selectList.onCancel = () => done(null);
648
- container.addChild(selectList);
649
476
 
650
- // Help text
651
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
477
+ const refreshTheme = () => {
478
+ title.setText(theme.fg("accent", theme.bold("Pick an option")));
479
+ hint.setText(
480
+ [
481
+ keyHint("tui.select.up", "up"),
482
+ keyHint("tui.select.down", "down"),
483
+ keyHint("tui.select.confirm", "select"),
484
+ keyHint("tui.select.cancel", "cancel"),
485
+ ].join(theme.fg("dim", " • ")),
486
+ );
487
+ };
652
488
 
653
- // Bottom border
654
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
489
+ container.addChild(new DynamicBorder((text: string) => theme.fg("borderAccent", text)));
490
+ container.addChild(title);
491
+ container.addChild(selectList);
492
+ container.addChild(hint);
493
+ container.addChild(new DynamicBorder((text: string) => theme.fg("borderAccent", text)));
494
+ refreshTheme();
655
495
 
656
496
  return {
657
- render: (w) => container.render(w),
658
- invalidate: () => container.invalidate(),
659
- handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
497
+ render: (width: number) => container.render(width),
498
+ invalidate: () => {
499
+ refreshTheme();
500
+ container.invalidate();
501
+ },
502
+ handleInput: (data: string) => {
503
+ selectList.handleInput(data);
504
+ tui.requestRender();
505
+ },
660
506
  };
661
507
  });
662
508
 
663
- if (result) {
664
- ctx.ui.notify(`Selected: ${result}`, "info");
665
- }
509
+ if (result !== null) ctx.ui.notify(`Selected: ${result}`, "info");
666
510
  },
667
511
  });
668
512
  ```
669
513
 
670
514
  **Examples:** [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts)
671
515
 
672
- ### Pattern 2: Async Operation with Cancel (BorderedLoader)
516
+ ### Pattern 2: Async Operation with Cancel (`BorderedLoader`)
673
517
 
674
- For operations that take time and should be cancellable. `BorderedLoader` shows a spinner and handles escape to cancel.
518
+ `BorderedLoader` is Pi's public framed wrapper around the TUI package's cancellable loader. Its `dispose()` stops its loader, so returning it directly gives the operation a clean lifecycle.
675
519
 
676
520
  ```typescript
677
521
  import { BorderedLoader } from "@astralyn/pi";
678
522
 
679
523
  pi.registerCommand("fetch", {
680
524
  handler: async (_args, ctx) => {
681
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
525
+ if (ctx.mode !== "tui") return;
526
+
527
+ const result = await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
682
528
  const loader = new BorderedLoader(tui, theme, "Fetching data...");
683
529
  loader.onAbort = () => done(null);
684
530
 
685
- // Do async work
686
- fetchData(loader.signal)
687
- .then((data) => done(data))
688
- .catch(() => done(null));
689
-
531
+ void fetchData(loader.signal).then(done, () => done(null));
690
532
  return loader;
691
533
  });
692
534
 
@@ -701,9 +543,9 @@ pi.registerCommand("fetch", {
701
543
 
702
544
  **Examples:** [qna.ts](../examples/extensions/qna.ts), [handoff.ts](../examples/extensions/handoff.ts)
703
545
 
704
- ### Pattern 3: Settings/Toggles (SettingsList)
546
+ ### Pattern 3: Settings and Toggles (`SettingsList`)
705
547
 
706
- For toggling multiple settings. Use `SettingsList` from `@earendil-works/pi-tui` with `getSettingsListTheme()`.
548
+ Use Pi's semantic settings-list theme. Rebuild this small list on invalidation so its pre-styled cursor also follows a theme change.
707
549
 
708
550
  ```typescript
709
551
  import { getSettingsListTheme } from "@astralyn/pi";
@@ -711,32 +553,44 @@ import { Container, type SettingItem, SettingsList, Text } from "@earendil-works
711
553
 
712
554
  pi.registerCommand("settings", {
713
555
  handler: async (_args, ctx) => {
556
+ if (ctx.mode !== "tui") return;
557
+
714
558
  const items: SettingItem[] = [
715
559
  { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] },
716
560
  { id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] },
717
561
  ];
718
562
 
719
- await ctx.ui.custom((_tui, theme, _kb, done) => {
563
+ await ctx.ui.custom((tui, theme, _keybindings, done) => {
720
564
  const container = new Container();
721
- container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1));
722
-
723
- const settingsList = new SettingsList(
724
- items,
725
- Math.min(items.length + 2, 15),
726
- getSettingsListTheme(),
727
- (id, newValue) => {
728
- // Handle value change
729
- ctx.ui.notify(`${id} = ${newValue}`, "info");
730
- },
731
- () => done(undefined), // On close
732
- { enableSearch: true }, // Optional: enable fuzzy search by label
733
- );
734
- container.addChild(settingsList);
565
+ const title = new Text("", 1, 1);
566
+ container.addChild(title);
567
+ let settingsList: SettingsList | undefined;
568
+
569
+ const rebuild = () => {
570
+ title.setText(theme.fg("accent", theme.bold("Settings")));
571
+ if (settingsList) container.removeChild(settingsList);
572
+ settingsList = new SettingsList(
573
+ items,
574
+ Math.min(items.length + 2, 15),
575
+ getSettingsListTheme(),
576
+ (id, newValue) => ctx.ui.notify(`${id} = ${newValue}`, "info"),
577
+ () => done(undefined),
578
+ { enableSearch: true },
579
+ );
580
+ container.addChild(settingsList);
581
+ };
735
582
 
583
+ rebuild();
736
584
  return {
737
- render: (w) => container.render(w),
738
- invalidate: () => container.invalidate(),
739
- handleInput: (data) => settingsList.handleInput?.(data),
585
+ render: (width: number) => container.render(width),
586
+ invalidate: () => {
587
+ rebuild();
588
+ container.invalidate();
589
+ },
590
+ handleInput: (data: string) => {
591
+ settingsList?.handleInput(data);
592
+ tui.requestRender();
593
+ },
740
594
  };
741
595
  });
742
596
  },
@@ -747,27 +601,20 @@ pi.registerCommand("settings", {
747
601
 
748
602
  ### Pattern 4: Persistent Status Indicator
749
603
 
750
- Show status in the footer that persists across renders. Good for mode indicators.
751
-
752
604
  ```typescript
753
- // Set status (shown in footer)
754
- ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
755
-
756
- // Clear status
757
- ctx.ui.setStatus("my-ext", undefined);
605
+ ctx.ui.setStatus("my-extension", ctx.ui.theme.fg("accent", "● active"));
606
+ ctx.ui.setStatus("my-extension", undefined); // Clear
758
607
  ```
759
608
 
760
- **Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts)
609
+ **Examples:** [status-line.ts](../examples/extensions/status-line.ts), [preset.ts](../examples/extensions/preset.ts)
761
610
 
762
611
  ### Pattern 4b: Working Indicator Customization
763
612
 
764
- Customize the inline working indicator shown while pi is streaming a response.
765
-
766
613
  ```typescript
767
614
  // Static indicator
768
615
  ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
769
616
 
770
- // Custom animated indicator
617
+ // Custom animation. Frames are rendered verbatim, so style them explicitly.
771
618
  ctx.ui.setWorkingIndicator({
772
619
  frames: [
773
620
  ctx.ui.theme.fg("dim", "·"),
@@ -778,121 +625,145 @@ ctx.ui.setWorkingIndicator({
778
625
  intervalMs: 120,
779
626
  });
780
627
 
781
- // Hide the indicator entirely
782
- ctx.ui.setWorkingIndicator({ frames: [] });
783
-
784
- // Restore pi's default spinner
785
- ctx.ui.setWorkingIndicator();
628
+ ctx.ui.setWorkingIndicator({ frames: [] }); // Hide
629
+ ctx.ui.setWorkingIndicator(); // Restore Pi's default
786
630
  ```
787
631
 
788
- This only affects the normal streaming working indicator. Compaction and retry loaders keep their built-in styling. Custom frames are rendered verbatim, so extensions must add their own colors when needed.
789
-
790
632
  **Examples:** [working-indicator.ts](../examples/extensions/working-indicator.ts)
791
633
 
792
- ### Pattern 5: Widgets Above/Below Editor
793
-
794
- Show persistent content above or below the input editor. Good for todo lists, progress.
634
+ ### Pattern 5: Widgets Above or Below the Editor
795
635
 
796
636
  ```typescript
797
- // Simple string array (above editor by default)
798
- ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
799
-
800
- // Render below the editor
801
- ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
802
-
803
- // Or with theme
804
- ctx.ui.setWidget("my-widget", (_tui, theme) => {
805
- const lines = items.map((item, i) =>
806
- item.done
807
- ? theme.fg("success", "✓ ") + theme.fg("muted", item.text)
808
- : theme.fg("dim", "○ ") + item.text
809
- );
810
- return {
811
- render: () => lines,
812
- invalidate: () => {},
813
- };
814
- });
637
+ import { truncateToWidth } from "@earendil-works/pi-tui";
638
+
639
+ const tasks = [
640
+ { text: "Review changes", done: true },
641
+ { text: "Run checks", done: false },
642
+ ];
643
+
644
+ ctx.ui.setWidget("my-widget", (_tui, theme) => ({
645
+ render(width: number): string[] {
646
+ return tasks.map((task) =>
647
+ truncateToWidth(
648
+ task.done
649
+ ? theme.fg("success", "✓ ") + theme.fg("muted", task.text)
650
+ : theme.fg("dim", "○ ") + theme.fg("text", task.text),
651
+ width,
652
+ ),
653
+ );
654
+ },
655
+ invalidate(): void {},
656
+ }));
815
657
 
816
- // Clear
817
- ctx.ui.setWidget("my-widget", undefined);
658
+ ctx.ui.setWidget("my-widget", undefined); // Clear
659
+ ctx.ui.setWidget("my-widget", ["Below the editor"], { placement: "belowEditor" });
818
660
  ```
819
661
 
820
- **Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
662
+ The factory's component is disposed when that widget key is replaced or cleared. If mutable widget state changes, retain the factory's `tui` argument and call `tui.requestRender()`.
663
+
664
+ **Examples:** [widget-placement.ts](../examples/extensions/widget-placement.ts)
821
665
 
822
666
  ### Pattern 6: Custom Footer
823
667
 
824
- Replace the footer. `footerData` exposes data not otherwise accessible to extensions.
668
+ A footer factory receives branch and extension-status data unavailable elsewhere. Return the unsubscribe function as `dispose` so it is released when the footer changes.
825
669
 
826
670
  ```typescript
671
+ import { truncateToWidth } from "@earendil-works/pi-tui";
672
+
827
673
  ctx.ui.setFooter((tui, theme, footerData) => ({
828
- invalidate() {},
829
674
  render(width: number): string[] {
830
- // footerData.getGitBranch(): string | null
831
- // footerData.getExtensionStatuses(): ReadonlyMap<string, string>
832
- return [`${ctx.model?.id} (${footerData.getGitBranch() || "no git"})`];
675
+ const branch = footerData.getGitBranch() ?? "no git";
676
+ const text = theme.fg("dim", `${ctx.model?.id ?? "no model"} (${branch})`);
677
+ return [truncateToWidth(text, width)];
833
678
  },
834
- dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive
679
+ invalidate(): void {},
680
+ dispose: footerData.onBranchChange(() => tui.requestRender()),
835
681
  }));
836
682
 
837
- ctx.ui.setFooter(undefined); // restore default
683
+ ctx.ui.setFooter(undefined); // Restore Pi's default footer
838
684
  ```
839
685
 
840
- Token stats available via `ctx.sessionManager.getBranch()` and `ctx.model`.
686
+ `footerData.getExtensionStatuses()` returns the read-only status map set through `ctx.ui.setStatus()`. Session statistics are available from `ctx.sessionManager` and `ctx.model`.
841
687
 
842
688
  **Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts)
843
689
 
844
- ### Pattern 7: Custom Editor (vim mode, etc.)
690
+ ### Pattern 7: Custom Editor
845
691
 
846
- Replace the main input editor with a custom implementation. Useful for modal editing (vim), different keybindings (emacs), or specialized input handling.
692
+ Extend `CustomEditor` to preserve Pi's application keybindings. This example uses the configured `app.interrupt` binding to enter normal mode and `decodeKittyPrintable()` for vim's printable commands.
847
693
 
848
694
  ```typescript
849
- import { CustomEditor, type ExtensionAPI } from "@astralyn/pi";
850
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
695
+ import { CustomEditor, type ExtensionAPI, type KeybindingsManager } from "@astralyn/pi";
696
+ import {
697
+ decodeKittyPrintable,
698
+ truncateToWidth,
699
+ type EditorTheme,
700
+ type TUI,
701
+ visibleWidth,
702
+ } from "@earendil-works/pi-tui";
851
703
 
852
704
  type Mode = "normal" | "insert";
853
705
 
854
706
  class VimEditor extends CustomEditor {
855
707
  private mode: Mode = "insert";
856
708
 
709
+ constructor(tui: TUI, theme: EditorTheme, private readonly bindings: KeybindingsManager) {
710
+ super(tui, theme, bindings);
711
+ }
712
+
713
+ private setMode(mode: Mode): void {
714
+ if (this.mode === mode) return;
715
+ this.mode = mode;
716
+ this.invalidate();
717
+ this.tui.requestRender();
718
+ }
719
+
857
720
  handleInput(data: string): void {
858
- // Escape: switch to normal mode, or pass through for app handling
859
- if (matchesKey(data, "escape")) {
721
+ if (this.bindings.matches(data, "app.interrupt")) {
860
722
  if (this.mode === "insert") {
861
- this.mode = "normal";
862
- return;
723
+ this.setMode("normal");
724
+ } else {
725
+ super.handleInput(data);
863
726
  }
864
- // In normal mode, escape aborts agent (handled by CustomEditor)
865
- super.handleInput(data);
866
727
  return;
867
728
  }
868
729
 
869
- // Insert mode: pass everything to CustomEditor
870
730
  if (this.mode === "insert") {
871
731
  super.handleInput(data);
872
732
  return;
873
733
  }
874
734
 
875
- // Normal mode: vim-style navigation
876
- switch (data) {
877
- case "i": this.mode = "insert"; return;
878
- case "h": super.handleInput("\x1b[D"); return; // Left
879
- case "j": super.handleInput("\x1b[B"); return; // Down
880
- case "k": super.handleInput("\x1b[A"); return; // Up
881
- case "l": super.handleInput("\x1b[C"); return; // Right
735
+ const key = decodeKittyPrintable(data) ?? data;
736
+ switch (key) {
737
+ case "i":
738
+ this.setMode("insert");
739
+ return;
740
+ case "h":
741
+ super.handleInput("\x1b[D");
742
+ return;
743
+ case "j":
744
+ super.handleInput("\x1b[B");
745
+ return;
746
+ case "k":
747
+ super.handleInput("\x1b[A");
748
+ return;
749
+ case "l":
750
+ super.handleInput("\x1b[C");
751
+ return;
882
752
  }
883
- // Pass unhandled keys to super (ctrl+c, etc.), but filter printable chars
884
- if (data.length === 1 && data.charCodeAt(0) >= 32) return;
753
+
754
+ // Keep application controls, but do not insert normal-mode text.
755
+ if (key.length === 1 && key.charCodeAt(0) >= 32) return;
885
756
  super.handleInput(data);
886
757
  }
887
758
 
888
759
  render(width: number): string[] {
889
760
  const lines = super.render(width);
890
- // Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)
891
- if (lines.length > 0) {
892
- const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
893
- const lastLine = lines[lines.length - 1]!;
894
- // Pass "" as ellipsis to avoid adding "..." when truncating
895
- lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, "") + label;
761
+ const last = lines.length - 1;
762
+ if (last < 0) return lines;
763
+
764
+ const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
765
+ if (width >= label.length && visibleWidth(lines[last]!) >= label.length) {
766
+ lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label;
896
767
  }
897
768
  return lines;
898
769
  }
@@ -900,43 +771,34 @@ class VimEditor extends CustomEditor {
900
771
 
901
772
  export default function (pi: ExtensionAPI) {
902
773
  pi.on("session_start", (_event, ctx) => {
903
- // Factory receives the TUI, theme, and keybindings from the app
904
- ctx.ui.setEditorComponent((tui, theme, keybindings) =>
905
- new VimEditor(tui, theme, keybindings)
906
- );
774
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings));
907
775
  });
908
776
  }
909
777
  ```
910
778
 
911
- **Key points:**
912
-
913
- - **Extend `CustomEditor`** (not base `Editor`) to get app keybindings (escape to abort, ctrl+d to exit, model switching, etc.)
914
- - **Call `super.handleInput(data)`** for keys you don't handle
915
- - **Factory pattern**: `setEditorComponent` receives a factory function that gets `tui`, `theme`, and `keybindings`
916
- - **Pass `undefined`** to restore the default editor: `ctx.ui.setEditorComponent(undefined)`
779
+ Pass `undefined` to `ctx.ui.setEditorComponent()` to restore Pi's default editor.
917
780
 
918
781
  **Examples:** [modal-editor.ts](../examples/extensions/modal-editor.ts)
919
782
 
920
- ## Key Rules
921
-
922
- 1. **Always use theme from callback** - Don't import theme directly. Use `theme` from the `ctx.ui.custom((tui, theme, keybindings, done) => ...)` callback.
783
+ ## Debug Logging
923
784
 
924
- 2. **Always type DynamicBorder color param** - Write `(s: string) => theme.fg("accent", s)`, not `(s) => theme.fg("accent", s)`.
785
+ Set `PI_TUI_WRITE_LOG` to capture the ANSI stream written to stdout.
925
786
 
926
- 3. **Call tui.requestRender() after state changes** - In `handleInput`, call `tui.requestRender()` after updating state.
787
+ ```bash
788
+ PI_TUI_WRITE_LOG=/tmp/tui-ansi.log pi
789
+ ```
927
790
 
928
- 4. **Return the three-method object** - Custom components need `{ render, invalidate, handleInput }`.
791
+ ## Key Rules
929
792
 
930
- 5. **Use existing components** - `SelectList`, `SettingsList`, `BorderedLoader` cover 90% of cases. Don't rebuild them.
793
+ 1. Guard terminal-only UI with `ctx.mode === "tui"`.
794
+ 2. Use the callback `theme` or `ctx.ui.theme`; use semantic `fg()` and `bg()` helpers, never fixed ANSI colors.
795
+ 3. Use `keybindings.matches()` and `keyHint()` for configurable Pi actions. Reserve `matchesKey()` for deliberately fixed extension-specific keys.
796
+ 4. Keep every rendered line within the supplied width, including styled lines.
797
+ 5. Invalidate cached output and call `tui.requestRender()` after state changes.
798
+ 6. Implement `dispose()` for timers, subscriptions, or other owned resources.
799
+ 7. Prefer `SelectList`, `SettingsList`, and `BorderedLoader` over rebuilding their behavior.
931
800
 
932
- ## Examples
801
+ ## Additional examples
933
802
 
934
- - **Selection UI**: [examples/extensions/preset.ts](../examples/extensions/preset.ts) - SelectList with DynamicBorder framing
935
- - **Async with cancel**: [examples/extensions/qna.ts](../examples/extensions/qna.ts) - BorderedLoader for LLM calls
936
- - **Settings toggles**: [examples/extensions/tools.ts](../examples/extensions/tools.ts) - SettingsList for tool enable/disable
937
- - **Status indicators**: [examples/extensions/plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) - setStatus and setWidget
938
- - **Working indicator**: [examples/extensions/working-indicator.ts](../examples/extensions/working-indicator.ts) - setWorkingIndicator
939
- - **Custom footer**: [examples/extensions/custom-footer.ts](../examples/extensions/custom-footer.ts) - setFooter with stats
940
- - **Custom editor**: [examples/extensions/modal-editor.ts](../examples/extensions/modal-editor.ts) - Vim-like modal editing
941
- - **Snake game**: [examples/extensions/snake.ts](../examples/extensions/snake.ts) - Full game with keyboard input, game loop
942
- - **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - renderCall and renderResult
803
+ - **Game loop and disposal:** [examples/extensions/snake.ts](../examples/extensions/snake.ts)
804
+ - **Custom tool rendering:** [examples/extensions/todo.ts](../examples/extensions/todo.ts)