@diffexai/diffex 0.2.4 → 0.2.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 (82) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/AGENTS.md +0 -4
  4. package/dist/core/agent-session.d.ts +0 -1
  5. package/dist/core/agent-session.js +3 -10
  6. package/dist/core/sdk.js +1 -1
  7. package/dist/core/system-prompt-production.d.ts +7 -0
  8. package/dist/core/system-prompt-production.js +108 -0
  9. package/dist/core/system-prompt.d.ts +2 -2
  10. package/dist/core/system-prompt.js +34 -28
  11. package/dist/core/tools/subagents.js +22 -9
  12. package/dist/modes/print-mode.js +12 -14
  13. package/dist/node_modules/@diffexai/diffex-agent-core/distribution-components.json +4 -4
  14. package/dist/node_modules/@diffexai/diffex-agent-core/distribution-files.json +1 -1
  15. package/dist/node_modules/@diffexai/diffex-agent-core/package.json +1 -1
  16. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/.manifest.json +1 -1
  17. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/amazon-bedrock.json +1 -1
  18. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  19. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/fireworks.json +1 -1
  20. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/nvidia.json +1 -1
  21. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/opencode-go.json +1 -1
  22. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/opencode.json +1 -1
  23. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/openrouter.json +1 -1
  24. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/qwen-token-plan-cn.json +1 -1
  25. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/qwen-token-plan.json +1 -1
  26. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
  27. package/dist/node_modules/@diffexai/diffex-ai/distribution-components.json +3 -3
  28. package/dist/node_modules/@diffexai/diffex-ai/distribution-files.json +12 -12
  29. package/dist/node_modules/@diffexai/diffex-ai/package.json +1 -1
  30. package/dist/node_modules/@diffexai/diffex-client/distribution-components.json +3 -3
  31. package/dist/node_modules/@diffexai/diffex-client/distribution-files.json +1 -1
  32. package/dist/node_modules/@diffexai/diffex-client/package.json +1 -1
  33. package/dist/node_modules/@diffexai/diffex-harness-state/distribution-components.json +2 -2
  34. package/dist/node_modules/@diffexai/diffex-harness-state/distribution-files.json +1 -1
  35. package/dist/node_modules/@diffexai/diffex-harness-state/package.json +1 -1
  36. package/dist/node_modules/@diffexai/diffex-protocol/distribution-components.json +2 -2
  37. package/dist/node_modules/@diffexai/diffex-protocol/distribution-files.json +1 -1
  38. package/dist/node_modules/@diffexai/diffex-protocol/package.json +1 -1
  39. package/dist/node_modules/@diffexai/diffex-telemetry/distribution-components.json +2 -2
  40. package/dist/node_modules/@diffexai/diffex-telemetry/distribution-files.json +1 -1
  41. package/dist/node_modules/@diffexai/diffex-telemetry/package.json +1 -1
  42. package/dist/node_modules/@diffexai/diffex-tui/distribution-components.json +2 -2
  43. package/dist/node_modules/@diffexai/diffex-tui/distribution-files.json +1 -1
  44. package/dist/node_modules/@diffexai/diffex-tui/package.json +1 -1
  45. package/dist/server/create-harness.js +1 -1
  46. package/distribution-components.json +11 -11
  47. package/distribution-files.json +50 -42
  48. package/npm-shrinkwrap.json +2 -2
  49. package/package.json +1 -31
  50. package/release/distribution-manifest.json +4 -4
  51. package/release/install-package-lock.json +5 -5
  52. package/release/install-package.json +2 -2
  53. package/docs/compaction.md +0 -401
  54. package/docs/containerization.md +0 -84
  55. package/docs/custom-provider.md +0 -774
  56. package/docs/environment-variables.md +0 -88
  57. package/docs/evolution.md +0 -90
  58. package/docs/extensions.md +0 -2982
  59. package/docs/images/interactive-mode.png +0 -0
  60. package/docs/images/tree-view.png +0 -0
  61. package/docs/installation.md +0 -118
  62. package/docs/json.md +0 -91
  63. package/docs/keybindings.md +0 -241
  64. package/docs/llama-cpp.md +0 -99
  65. package/docs/models.md +0 -565
  66. package/docs/packages.md +0 -232
  67. package/docs/prompt-templates.md +0 -96
  68. package/docs/providers.md +0 -317
  69. package/docs/quickstart.md +0 -161
  70. package/docs/rpc.md +0 -1647
  71. package/docs/sdk.md +0 -1332
  72. package/docs/security.md +0 -66
  73. package/docs/session-format.md +0 -438
  74. package/docs/sessions.md +0 -162
  75. package/docs/settings.md +0 -341
  76. package/docs/shell-aliases.md +0 -13
  77. package/docs/skills.md +0 -227
  78. package/docs/terminal-setup.md +0 -152
  79. package/docs/themes.md +0 -326
  80. package/docs/tmux.md +0 -63
  81. package/docs/tui.md +0 -940
  82. package/docs/usage.md +0 -434
package/docs/tui.md DELETED
@@ -1,940 +0,0 @@
1
- > Diffex can create TUI components. Ask it to build one for your use case.
2
-
3
- # TUI Components
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.
6
-
7
- **Source:** [`@diffexai/diffex-tui`](https://github.com/diffexai/diffex/tree/main/packages/tui)
8
-
9
- ## Component Interface
10
-
11
- All components implement:
12
-
13
- ```typescript
14
- interface Component {
15
- render(width: number): string[];
16
- handleInput?(data: string): void;
17
- wantsKeyRelease?: boolean;
18
- invalidate(): void;
19
- }
20
- ```
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. |
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.
30
-
31
- ## Focusable Interface (IME Support)
32
-
33
- Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
34
-
35
- ```typescript
36
- import { CURSOR_MARKER, type Component, type Focusable } from "@diffexai/diffex-tui";
37
-
38
- class MyInput implements Component, Focusable {
39
- focused: boolean = false; // Set by TUI when focus changes
40
-
41
- render(width: number): string[] {
42
- 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
- }
46
- }
47
- ```
48
-
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 `DIFFEX_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.
56
-
57
- ### Container Components with Embedded Inputs
58
-
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.
60
-
61
- ```typescript
62
- import { Container, type Focusable, Input } from "@diffexai/diffex-tui";
63
-
64
- class SearchDialog extends Container implements Focusable {
65
- private searchInput: Input;
66
-
67
- // Focusable implementation - propagate to child input for IME cursor positioning
68
- private _focused = false;
69
- get focused(): boolean {
70
- return this._focused;
71
- }
72
- set focused(value: boolean) {
73
- this._focused = value;
74
- this.searchInput.focused = value;
75
- }
76
-
77
- constructor() {
78
- super();
79
- this.searchInput = new Input();
80
- this.addChild(this.searchInput);
81
- }
82
- }
83
- ```
84
-
85
- Without this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position on screen.
86
-
87
- ## Using Components
88
-
89
- **In extensions** via `ctx.ui.custom()`:
90
-
91
- ```typescript
92
- diffex.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
- });
103
- ```
104
-
105
- **In custom tools** via `ctx.ui.custom()`:
106
-
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
- ```
121
-
122
- ## Overlays
123
-
124
- Overlays render components on top of existing content without clearing the screen. Pass `{ overlay: true }` to `ctx.ui.custom()`:
125
-
126
- ```typescript
127
- const result = await ctx.ui.custom<string | null>(
128
- (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
129
- { overlay: true }
130
- );
131
- ```
132
-
133
- For positioning and sizing, use `overlayOptions`:
134
-
135
- ```typescript
136
- const result = await ctx.ui.custom<string | null>(
137
- (tui, theme, keybindings, done) => new SidePanel({ onClose: done }),
138
- {
139
- overlay: true,
140
- 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
149
- 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,
160
- },
161
- // Get handle for programmatic focus and visibility control
162
- 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
168
- },
169
- }
170
- );
171
- ```
172
-
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.
176
-
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.
178
-
179
- ### Overlay Lifecycle
180
-
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
- ```
199
-
200
- See [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for comprehensive examples covering anchors, margins, stacking, responsive visibility, and animation.
201
-
202
- ## Built-in Components
203
-
204
- Import from `@diffexai/diffex-tui`:
205
-
206
- ```typescript
207
- import { Text, Box, Container, Spacer, Markdown } from "@diffexai/diffex-tui";
208
- ```
209
-
210
- ### Text
211
-
212
- Multi-line text with word wrapping.
213
-
214
- ```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
- ```
223
-
224
- ### Box
225
-
226
- Container with padding and background color.
227
-
228
- ```typescript
229
- const box = new Box(
230
- 1, // paddingX
231
- 1, // paddingY
232
- (s) => bgGray(s) // background function
233
- );
234
- box.addChild(new Text("Content", 0, 0));
235
- box.setBgFn((s) => bgBlue(s));
236
- ```
237
-
238
- ### Container
239
-
240
- Groups child components vertically.
241
-
242
- ```typescript
243
- 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.
252
-
253
- ```typescript
254
- const spacer = new Spacer(2); // 2 empty lines
255
- ```
256
-
257
- ### Markdown
258
-
259
- Renders markdown with syntax highlighting.
260
-
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
- ```
270
-
271
- ### Image
272
-
273
- Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm, Warp).
274
-
275
- ```typescript
276
- const image = new Image(
277
- base64Data, // base64-encoded image
278
- "image/png", // MIME type
279
- theme, // ImageTheme
280
- { maxWidthCells: 80, maxHeightCells: 24 }
281
- );
282
- ```
283
-
284
- ## Keyboard Input
285
-
286
- Use `matchesKey()` for key detection:
287
-
288
- ```typescript
289
- import { matchesKey, Key } from "@diffexai/diffex-tui";
290
-
291
- handleInput(data: string) {
292
- if (matchesKey(data, Key.up)) {
293
- this.selectedIndex--;
294
- } else if (matchesKey(data, Key.enter)) {
295
- this.onSelect?.(this.selectedIndex);
296
- } else if (matchesKey(data, Key.escape)) {
297
- this.onCancel?.();
298
- } else if (matchesKey(data, Key.ctrl("c"))) {
299
- // Ctrl+C
300
- }
301
- }
302
- ```
303
-
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"`
309
-
310
- ## Line Width
311
-
312
- **Critical:** Each line from `render()` must not exceed the `width` parameter.
313
-
314
- ```typescript
315
- import { visibleWidth, truncateToWidth } from "@diffexai/diffex-tui";
316
-
317
- render(width: number): string[] {
318
- // Truncate long lines
319
- return [truncateToWidth(this.text, width)];
320
- }
321
- ```
322
-
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
327
-
328
- ## Creating Custom Components
329
-
330
- Example: Interactive selector
331
-
332
- ```typescript
333
- import {
334
- matchesKey, Key,
335
- truncateToWidth, visibleWidth
336
- } from "@diffexai/diffex-tui";
337
-
338
- class MySelector {
339
- private items: string[];
340
- private selected = 0;
341
- private cachedWidth?: number;
342
- private cachedLines?: string[];
343
-
344
- public onSelect?: (item: string) => void;
345
- public onCancel?: () => void;
346
-
347
- constructor(items: string[]) {
348
- this.items = items;
349
- }
350
-
351
- handleInput(data: string): void {
352
- if (matchesKey(data, Key.up) && this.selected > 0) {
353
- this.selected--;
354
- this.invalidate();
355
- } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {
356
- 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?.();
362
- }
363
- }
364
-
365
- render(width: number): string[] {
366
- if (this.cachedLines && this.cachedWidth === width) {
367
- return this.cachedLines;
368
- }
369
-
370
- this.cachedLines = this.items.map((item, i) => {
371
- const prefix = i === this.selected ? "> " : " ";
372
- return truncateToWidth(prefix + item, width);
373
- });
374
- this.cachedWidth = width;
375
- return this.cachedLines;
376
- }
377
-
378
- invalidate(): void {
379
- this.cachedWidth = undefined;
380
- this.cachedLines = undefined;
381
- }
382
- }
383
- ```
384
-
385
- Usage in an extension:
386
-
387
- ```typescript
388
- diffex.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
- });
406
-
407
- if (selected !== null) {
408
- ctx.ui.notify(`Selected: ${selected}`, "info");
409
- }
410
- }
411
- });
412
- ```
413
-
414
- ## Theming
415
-
416
- Components accept theme objects for styling.
417
-
418
- **In `renderCall`/`renderResult`**, use the `theme` parameter:
419
-
420
- ```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"));
427
- }
428
- ```
429
-
430
- **Foreground colors** (`theme.fg(color, text)`):
431
-
432
- | Category | Colors |
433
- |----------|--------|
434
- | General | `text`, `accent`, `muted`, `dim` |
435
- | Status | `success`, `error`, `warning` |
436
- | Borders | `border`, `borderAccent`, `borderMuted` |
437
- | Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
438
- | Tools | `toolTitle`, `toolOutput` |
439
- | Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
440
- | Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
441
- | Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
442
- | Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `thinkingMax` |
443
- | Footer | `footerModel`, `footerContext`, `footerWindow` |
444
- | Modes | `bashMode` |
445
-
446
- **Background colors** (`theme.bg(color, text)`):
447
-
448
- `selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`
449
-
450
- **For Markdown**, use `getMarkdownTheme()`:
451
-
452
- ```typescript
453
- import { getMarkdownTheme } from "@diffexai/diffex";
454
- import { Markdown } from "@diffexai/diffex-tui";
455
-
456
- renderResult(result, options, theme, context) {
457
- const mdTheme = getMarkdownTheme();
458
- return new Markdown(result.details.markdown, 0, 0, mdTheme);
459
- }
460
- ```
461
-
462
- **For custom components**, define your own theme interface:
463
-
464
- ```typescript
465
- interface MyTheme {
466
- selected: (s: string) => string;
467
- normal: (s: string) => string;
468
- }
469
- ```
470
-
471
- ## Debug logging
472
-
473
- Set `DIFFEX_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.
474
-
475
- ```bash
476
- DIFFEX_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts
477
- ```
478
-
479
- ## Performance
480
-
481
- Cache rendered output when possible:
482
-
483
- ```typescript
484
- class CachedComponent {
485
- private cachedWidth?: number;
486
- private cachedLines?: string[];
487
-
488
- render(width: number): string[] {
489
- if (this.cachedLines && this.cachedWidth === width) {
490
- return this.cachedLines;
491
- }
492
- // ... compute lines ...
493
- this.cachedWidth = width;
494
- this.cachedLines = lines;
495
- return lines;
496
- }
497
-
498
- invalidate(): void {
499
- this.cachedWidth = undefined;
500
- this.cachedLines = undefined;
501
- }
502
- }
503
- ```
504
-
505
- Call `invalidate()` when state changes, then use the injected `tui.requestRender()` to trigger re-render.
506
-
507
- ## Invalidation and Theme Changes
508
-
509
- 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.
510
-
511
- ### The Problem
512
-
513
- 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.
514
-
515
- **Wrong approach** (theme colors won't update):
516
-
517
- ```typescript
518
- class BadComponent extends Container {
519
- private content: Text;
520
-
521
- constructor(message: string, theme: Theme) {
522
- super();
523
- // Pre-baked theme colors stored in Text component
524
- this.content = new Text(theme.fg("accent", message), 1, 0);
525
- this.addChild(this.content);
526
- }
527
- // No invalidate override - parent's invalidate only clears
528
- // child render caches, not the pre-baked content
529
- }
530
- ```
531
-
532
- ### The Solution
533
-
534
- Components that build content with theme colors must rebuild that content when `invalidate()` is called:
535
-
536
- ```typescript
537
- class GoodComponent extends Container {
538
- private message: string;
539
- private content: Text;
540
-
541
- constructor(message: string) {
542
- super();
543
- this.message = message;
544
- this.content = new Text("", 1, 0);
545
- this.addChild(this.content);
546
- this.updateDisplay();
547
- }
548
-
549
- private updateDisplay(): void {
550
- // Rebuild content with current theme
551
- this.content.setText(theme.fg("accent", this.message));
552
- }
553
-
554
- override invalidate(): void {
555
- super.invalidate(); // Clear child caches
556
- this.updateDisplay(); // Rebuild with new theme
557
- }
558
- }
559
- ```
560
-
561
- ### Pattern: Rebuild on Invalidate
562
-
563
- For components with complex content:
564
-
565
- ```typescript
566
- class ComplexComponent extends Container {
567
- private data: SomeData;
568
-
569
- constructor(data: SomeData) {
570
- super();
571
- this.data = data;
572
- this.rebuild();
573
- }
574
-
575
- private rebuild(): void {
576
- this.clear(); // Remove all children
577
-
578
- // Build UI with current theme
579
- this.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));
580
- this.addChild(new Spacer(1));
581
-
582
- for (const item of this.data.items) {
583
- const color = item.active ? "success" : "muted";
584
- this.addChild(new Text(theme.fg(color, item.label), 1, 0));
585
- }
586
- }
587
-
588
- override invalidate(): void {
589
- super.invalidate();
590
- this.rebuild();
591
- }
592
- }
593
- ```
594
-
595
- ### When This Matters
596
-
597
- This pattern is needed when:
598
-
599
- 1. **Pre-baking theme colors** - Using `theme.fg()` or `theme.bg()` to create styled strings stored in child components
600
- 2. **Syntax highlighting** - Using `highlightCode()` which applies theme-based syntax colors
601
- 3. **Complex layouts** - Building child component trees that embed theme colors
602
-
603
- This pattern is NOT needed when:
604
-
605
- 1. **Using theme callbacks** - Passing functions like `(text) => theme.fg("accent", text)` that are called during render
606
- 2. **Simple containers** - Just grouping other components without adding themed content
607
- 3. **Stateless render** - Computing themed output fresh in every `render()` call (no caching)
608
-
609
- ## Common Patterns
610
-
611
- These patterns cover the most common UI needs in extensions. **Copy these patterns instead of building from scratch.**
612
-
613
- ### Pattern 1: Selection Dialog (SelectList)
614
-
615
- For letting users pick from a list of options. Use `SelectList` from `@diffexai/diffex-tui` with `DynamicBorder` for framing.
616
-
617
- ```typescript
618
- import type { ExtensionAPI } from "@diffexai/diffex";
619
- import { DynamicBorder } from "@diffexai/diffex";
620
- import { Container, type SelectItem, SelectList, Text } from "@diffexai/diffex-tui";
621
-
622
- diffex.registerCommand("pick", {
623
- handler: async (_args, ctx) => {
624
- const items: SelectItem[] = [
625
- { value: "opt1", label: "Option 1", description: "First option" },
626
- { value: "opt2", label: "Option 2", description: "Second option" },
627
- { value: "opt3", label: "Option 3" }, // description is optional
628
- ];
629
-
630
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
631
- const container = new Container();
632
-
633
- // Top border
634
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
635
-
636
- // Title
637
- container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0));
638
-
639
- // SelectList with theme
640
- const selectList = new SelectList(items, Math.min(items.length, 10), {
641
- selectedPrefix: (t) => theme.fg("accent", t),
642
- selectedText: (t) => theme.fg("accent", t),
643
- description: (t) => theme.fg("muted", t),
644
- scrollInfo: (t) => theme.fg("dim", t),
645
- noMatch: (t) => theme.fg("warning", t),
646
- });
647
- selectList.onSelect = (item) => done(item.value);
648
- selectList.onCancel = () => done(null);
649
- container.addChild(selectList);
650
-
651
- // Help text
652
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
653
-
654
- // Bottom border
655
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
656
-
657
- return {
658
- render: (w) => container.render(w),
659
- invalidate: () => container.invalidate(),
660
- handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
661
- };
662
- });
663
-
664
- if (result) {
665
- ctx.ui.notify(`Selected: ${result}`, "info");
666
- }
667
- },
668
- });
669
- ```
670
-
671
- **Examples:** [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts)
672
-
673
- ### Pattern 2: Async Operation with Cancel (BorderedLoader)
674
-
675
- For operations that take time and should be cancellable. `BorderedLoader` shows a spinner and handles escape to cancel.
676
-
677
- ```typescript
678
- import { BorderedLoader } from "@diffexai/diffex";
679
-
680
- diffex.registerCommand("fetch", {
681
- handler: async (_args, ctx) => {
682
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
683
- const loader = new BorderedLoader(tui, theme, "Fetching data...");
684
- loader.onAbort = () => done(null);
685
-
686
- // Do async work
687
- fetchData(loader.signal)
688
- .then((data) => done(data))
689
- .catch(() => done(null));
690
-
691
- return loader;
692
- });
693
-
694
- if (result === null) {
695
- ctx.ui.notify("Cancelled", "info");
696
- } else {
697
- ctx.ui.setEditorText(result);
698
- }
699
- },
700
- });
701
- ```
702
-
703
- **Examples:** [qna.ts](../examples/extensions/qna.ts), [handoff.ts](../examples/extensions/handoff.ts)
704
-
705
- ### Pattern 3: Settings/Toggles (SettingsList)
706
-
707
- For toggling multiple settings. Use `SettingsList` from `@diffexai/diffex-tui` with `getSettingsListTheme()`.
708
-
709
- ```typescript
710
- import { getSettingsListTheme } from "@diffexai/diffex";
711
- import { Container, type SettingItem, SettingsList, Text } from "@diffexai/diffex-tui";
712
-
713
- diffex.registerCommand("settings", {
714
- handler: async (_args, ctx) => {
715
- const items: SettingItem[] = [
716
- { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] },
717
- { id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] },
718
- ];
719
-
720
- await ctx.ui.custom((_tui, theme, _kb, done) => {
721
- const container = new Container();
722
- container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1));
723
-
724
- const settingsList = new SettingsList(
725
- items,
726
- Math.min(items.length + 2, 15),
727
- getSettingsListTheme(),
728
- (id, newValue) => {
729
- // Handle value change
730
- ctx.ui.notify(`${id} = ${newValue}`, "info");
731
- },
732
- () => done(undefined), // On close
733
- { enableSearch: true }, // Optional: enable fuzzy search by label
734
- );
735
- container.addChild(settingsList);
736
-
737
- return {
738
- render: (w) => container.render(w),
739
- invalidate: () => container.invalidate(),
740
- handleInput: (data) => settingsList.handleInput?.(data),
741
- };
742
- });
743
- },
744
- });
745
- ```
746
-
747
- **Examples:** [tools.ts](../examples/extensions/tools.ts)
748
-
749
- ### Pattern 4: Persistent Status Indicator
750
-
751
- Show status in the footer that persists across renders. Good for mode indicators.
752
-
753
- ```typescript
754
- // Set status (shown in footer)
755
- ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));
756
-
757
- // Clear status
758
- ctx.ui.setStatus("my-ext", undefined);
759
- ```
760
-
761
- **Examples:** [status-line.ts](../examples/extensions/status-line.ts), [preset.ts](../examples/extensions/preset.ts)
762
-
763
- ### Pattern 4b: Working Indicator Customization
764
-
765
- Customize the inline working indicator shown while Diffex is streaming a response.
766
-
767
- ```typescript
768
- // Static indicator
769
- ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
770
-
771
- // Custom animated indicator
772
- ctx.ui.setWorkingIndicator({
773
- frames: [
774
- ctx.ui.theme.fg("dim", "·"),
775
- ctx.ui.theme.fg("muted", "•"),
776
- ctx.ui.theme.fg("accent", "●"),
777
- ctx.ui.theme.fg("muted", "•"),
778
- ],
779
- intervalMs: 120,
780
- });
781
-
782
- // Hide the indicator entirely
783
- ctx.ui.setWorkingIndicator({ frames: [] });
784
-
785
- // Restore Diffex's default spinner
786
- ctx.ui.setWorkingIndicator();
787
- ```
788
-
789
- 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.
790
-
791
- **Examples:** [working-indicator.ts](../examples/extensions/working-indicator.ts)
792
-
793
- ### Pattern 5: Widgets Above/Below Editor
794
-
795
- Show persistent content above or below the input editor. Good for todo lists, progress.
796
-
797
- ```typescript
798
- // Simple string array (above editor by default)
799
- ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
800
-
801
- // Render below the editor
802
- ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
803
-
804
- // Or with theme
805
- ctx.ui.setWidget("my-widget", (_tui, theme) => {
806
- const lines = items.map((item, i) =>
807
- item.done
808
- ? theme.fg("success", "✓ ") + theme.fg("muted", item.text)
809
- : theme.fg("dim", "○ ") + item.text
810
- );
811
- return {
812
- render: () => lines,
813
- invalidate: () => {},
814
- };
815
- });
816
-
817
- // Clear
818
- ctx.ui.setWidget("my-widget", undefined);
819
- ```
820
-
821
- ### Pattern 6: Custom Footer
822
-
823
- Replace the footer. `footerData` exposes data not otherwise accessible to extensions.
824
-
825
- ```typescript
826
- ctx.ui.setFooter((tui, theme, footerData) => ({
827
- invalidate() {},
828
- render(width: number): string[] {
829
- // footerData.getGitBranch(): string | null
830
- // footerData.getExtensionStatuses(): ReadonlyMap<string, string>
831
- return [`${ctx.model?.id} (${footerData.getGitBranch() || "no git"})`];
832
- },
833
- dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive
834
- }));
835
-
836
- ctx.ui.setFooter(undefined); // restore default
837
- ```
838
-
839
- Token stats available via `ctx.sessionManager.getBranch()` and `ctx.model`.
840
-
841
- **Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts)
842
-
843
- ### Pattern 7: Custom Editor (vim mode, etc.)
844
-
845
- Replace the main input editor with a custom implementation. Useful for modal editing (vim), different keybindings (emacs), or specialized input handling.
846
-
847
- ```typescript
848
- import { CustomEditor, type ExtensionAPI } from "@diffexai/diffex";
849
- import { matchesKey, truncateToWidth } from "@diffexai/diffex-tui";
850
-
851
- type Mode = "normal" | "insert";
852
-
853
- class VimEditor extends CustomEditor {
854
- private mode: Mode = "insert";
855
-
856
- handleInput(data: string): void {
857
- // Escape: switch to normal mode, or pass through for app handling
858
- if (matchesKey(data, "escape")) {
859
- if (this.mode === "insert") {
860
- this.mode = "normal";
861
- return;
862
- }
863
- // In normal mode, escape aborts agent (handled by CustomEditor)
864
- super.handleInput(data);
865
- return;
866
- }
867
-
868
- // Insert mode: pass everything to CustomEditor
869
- if (this.mode === "insert") {
870
- super.handleInput(data);
871
- return;
872
- }
873
-
874
- // Normal mode: vim-style navigation
875
- switch (data) {
876
- case "i": this.mode = "insert"; return;
877
- case "h": super.handleInput("\x1b[D"); return; // Left
878
- case "j": super.handleInput("\x1b[B"); return; // Down
879
- case "k": super.handleInput("\x1b[A"); return; // Up
880
- case "l": super.handleInput("\x1b[C"); return; // Right
881
- }
882
- // Pass unhandled keys to super (ctrl+c, etc.), but filter printable chars
883
- if (data.length === 1 && data.charCodeAt(0) >= 32) return;
884
- super.handleInput(data);
885
- }
886
-
887
- render(width: number): string[] {
888
- const lines = super.render(width);
889
- // Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)
890
- if (lines.length > 0) {
891
- const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
892
- const lastLine = lines[lines.length - 1]!;
893
- // Pass "" as ellipsis to avoid adding "..." when truncating
894
- lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, "") + label;
895
- }
896
- return lines;
897
- }
898
- }
899
-
900
- export default function (diffex: ExtensionAPI) {
901
- diffex.on("session_start", (_event, ctx) => {
902
- // Factory receives the TUI, theme, and keybindings from the app
903
- ctx.ui.setEditorComponent((tui, theme, keybindings) =>
904
- new VimEditor(tui, theme, keybindings)
905
- );
906
- });
907
- }
908
- ```
909
-
910
- **Key points:**
911
-
912
- - **Extend `CustomEditor`** (not base `Editor`) to get app keybindings (escape to abort, ctrl+d to exit, model switching, etc.)
913
- - **Call `super.handleInput(data)`** for keys you don't handle
914
- - **Factory pattern**: `setEditorComponent` receives a factory function that gets `tui`, `theme`, and `keybindings`
915
- - **Pass `undefined`** to restore the default editor: `ctx.ui.setEditorComponent(undefined)`
916
-
917
- **Examples:** [modal-editor.ts](../examples/extensions/modal-editor.ts)
918
-
919
- ## Key Rules
920
-
921
- 1. **Always use theme from callback** - Don't import theme directly. Use `theme` from the `ctx.ui.custom((tui, theme, keybindings, done) => ...)` callback.
922
-
923
- 2. **Always type DynamicBorder color param** - Write `(s: string) => theme.fg("accent", s)`, not `(s) => theme.fg("accent", s)`.
924
-
925
- 3. **Call tui.requestRender() after state changes** - In `handleInput`, call `tui.requestRender()` after updating state.
926
-
927
- 4. **Return the three-method object** - Custom components need `{ render, invalidate, handleInput }`.
928
-
929
- 5. **Use existing components** - `SelectList`, `SettingsList`, `BorderedLoader` cover 90% of cases. Don't rebuild them.
930
-
931
- ## Examples
932
-
933
- - **Selection UI**: [examples/extensions/preset.ts](../examples/extensions/preset.ts) - SelectList with DynamicBorder framing
934
- - **Async with cancel**: [examples/extensions/qna.ts](../examples/extensions/qna.ts) - BorderedLoader for LLM calls
935
- - **Settings toggles**: [examples/extensions/tools.ts](../examples/extensions/tools.ts) - SettingsList for tool enable/disable
936
- - **Working indicator**: [examples/extensions/working-indicator.ts](../examples/extensions/working-indicator.ts) - setWorkingIndicator
937
- - **Custom footer**: [examples/extensions/custom-footer.ts](../examples/extensions/custom-footer.ts) - setFooter with stats
938
- - **Custom editor**: [examples/extensions/modal-editor.ts](../examples/extensions/modal-editor.ts) - Vim-like modal editing
939
- - **Snake game**: [examples/extensions/snake.ts](../examples/extensions/snake.ts) - Full game with keyboard input, game loop
940
- - **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - renderCall and renderResult