agent-sh 0.14.9 → 0.14.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +47 -20
  2. package/dist/agent/agent-loop.js +20 -15
  3. package/dist/agent/events.d.ts +2 -1
  4. package/dist/agent/index.js +44 -7
  5. package/dist/agent/live-view.d.ts +3 -3
  6. package/dist/agent/live-view.js +15 -7
  7. package/dist/agent/providers/ollama.d.ts +11 -0
  8. package/dist/agent/providers/ollama.js +72 -0
  9. package/dist/agent/providers/opencode.d.ts +10 -0
  10. package/dist/agent/providers/opencode.js +112 -0
  11. package/dist/agent/providers/openrouter.js +9 -0
  12. package/dist/agent/providers/zai-coding-plan.d.ts +5 -0
  13. package/dist/agent/providers/zai-coding-plan.js +26 -0
  14. package/dist/agent/subagent.js +1 -1
  15. package/dist/cli/args.js +2 -2
  16. package/dist/cli/install.js +10 -1
  17. package/dist/shell/events.d.ts +3 -0
  18. package/dist/shell/shell.js +3 -0
  19. package/dist/utils/diff-renderer.d.ts +4 -0
  20. package/dist/utils/diff-renderer.js +15 -20
  21. package/examples/extensions/ads/SKILL.md +170 -0
  22. package/examples/extensions/ads/index.ts +695 -0
  23. package/examples/extensions/ash-scheme/index.ts +339 -605
  24. package/examples/extensions/ash-scheme/package.json +1 -1
  25. package/examples/extensions/ashi/EXTENDING.md +116 -0
  26. package/examples/extensions/ashi/README.md +10 -54
  27. package/examples/extensions/ashi/package.json +6 -2
  28. package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
  29. package/examples/extensions/ashi/src/autocomplete.ts +1 -23
  30. package/examples/extensions/ashi/src/capture.ts +9 -3
  31. package/examples/extensions/ashi/src/chat/assistant.ts +87 -0
  32. package/examples/extensions/ashi/src/chat/lines.ts +20 -0
  33. package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
  34. package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
  35. package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
  36. package/examples/extensions/ashi/src/cli.ts +58 -12
  37. package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
  38. package/examples/extensions/ashi/src/commands.ts +11 -1
  39. package/examples/extensions/ashi/src/display-config.ts +9 -1
  40. package/examples/extensions/ashi/src/frontend.ts +340 -259
  41. package/examples/extensions/ashi/src/hooks.ts +33 -40
  42. package/examples/extensions/ashi/src/renderer.ts +222 -0
  43. package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
  44. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +23 -0
  45. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +133 -0
  46. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +193 -0
  47. package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
  48. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
  49. package/examples/extensions/ashi/src/schema.ts +43 -205
  50. package/examples/extensions/ashi/src/status-footer.ts +15 -23
  51. package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
  52. package/examples/extensions/ashi/src/theme.ts +1 -47
  53. package/examples/extensions/ashi-ink/README.md +59 -0
  54. package/examples/extensions/ashi-ink/package.json +30 -0
  55. package/examples/extensions/ashi-ink/src/index.ts +6 -0
  56. package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
  57. package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
  58. package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
  59. package/examples/extensions/ashi-ink/tsconfig.json +14 -0
  60. package/examples/extensions/ashi-scheme-render.ts +4 -10
  61. package/examples/extensions/ashi-shell-passthrough.ts +95 -0
  62. package/examples/extensions/latex-images.ts +22 -19
  63. package/examples/extensions/terminal-buffer.ts +4 -2
  64. package/package.json +3 -9
  65. package/examples/extensions/ashi/src/components.ts +0 -238
  66. package/examples/extensions/ollama.ts +0 -108
  67. package/examples/extensions/opencode-provider.ts +0 -251
  68. package/examples/extensions/zai-coding-plan.ts +0 -35
@@ -1,44 +1,40 @@
1
- import type { Component } from "@earendil-works/pi-tui";
2
1
  import type { ExtensionContext } from "agent-sh/types";
3
- import { AssistantMessage, ThinkingBlock, UserMessage } from "./components.js";
2
+ import type { Renderer, RenderNodes, ToolCallView, ToolResultView } from "./renderer.js";
3
+ import { UserMessage } from "./chat/user-message.js";
4
+ import { AssistantMessage, type EquationRenderer } from "./chat/assistant.js";
5
+ import { ThinkingBlock } from "./chat/thinking.js";
4
6
  import { loadDisplayResolver, type ToolResultMode } from "./display-config.js";
5
- import { isRenderModel, mountCall, mountResult, type RenderModel } from "./schema.js";
7
+ import { isRenderModel, type RenderModel } from "./schema.js";
6
8
 
7
9
  export interface RenderState {
8
10
  state: Record<string, unknown>;
9
11
  invalidate: () => void;
12
+ nodes: RenderNodes;
10
13
  }
11
14
 
12
15
  export interface UserMessageArgs extends RenderState { text: string }
13
16
  export interface AssistantArgs extends RenderState { text: string }
14
17
  export interface ThinkingArgs extends RenderState { text: string; hidden: boolean }
15
18
 
16
- /** The contract ashi's frontend mutates on the call-side component. Schema
17
- * renderers satisfy this via SchemaCallComponent — extension authors don't
18
- * implement it directly. */
19
- export interface ToolCallView extends Component {
20
- setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void;
21
- toggleExpanded?(): void;
22
- }
23
-
24
- /** Result-side counterpart. setDiffRenderer receives the width-aware diff
25
- * closure produced by the edit/write tool at finalize. */
26
- export interface ToolResultView extends Component {
27
- appendChunk(chunk: string): void;
28
- setDiffRenderer(fn: (width: number) => string[]): void;
29
- finalize(opts: { exitCode: number | null; summary?: string }): void;
30
- toggleExpanded(): void;
31
- }
32
-
33
19
  const SCHEMA_PREFIX = "ashi:render-tool:";
34
20
 
35
- export function registerRenderDefaults(ctx: ExtensionContext): void {
36
- ctx.define("ashi:render-user-message", (args: UserMessageArgs): Component => {
37
- return new UserMessage(args.text);
38
- });
21
+ export function registerRenderDefaults(ctx: ExtensionContext, renderer: Renderer): void {
22
+ // Cache the PNG, not the node: finalize/rehydrate can render an equation twice.
23
+ const equationPng = new Map<string, Buffer | null>();
24
+ const renderEquation: EquationRenderer = (src) => {
25
+ if (!equationPng.has(src)) {
26
+ equationPng.set(src, (ctx.call("latex:render-equation", src) as Buffer | null) ?? null);
27
+ }
28
+ const png = equationPng.get(src) ?? null;
29
+ return png && renderer.capabilities.images ? renderer.image(png) : null;
30
+ };
31
+
32
+ ctx.define("ashi:render-user-message", (args: UserMessageArgs) =>
33
+ new UserMessage(renderer, args.text));
39
34
 
40
- ctx.define("ashi:render-assistant", (args: AssistantArgs): Component => {
41
- const msg = new AssistantMessage();
35
+ ctx.define("ashi:render-assistant", (args: AssistantArgs) => {
36
+ const eq = ctx.list().includes("latex:render-equation") ? renderEquation : undefined;
37
+ const msg = new AssistantMessage(renderer, eq);
42
38
  if (args.text) {
43
39
  msg.appendText(args.text);
44
40
  msg.finalize();
@@ -46,8 +42,8 @@ export function registerRenderDefaults(ctx: ExtensionContext): void {
46
42
  return msg;
47
43
  });
48
44
 
49
- ctx.define("ashi:render-thinking", (args: ThinkingArgs): Component => {
50
- const tb = new ThinkingBlock();
45
+ ctx.define("ashi:render-thinking", (args: ThinkingArgs) => {
46
+ const tb = new ThinkingBlock(renderer);
51
47
  if (args.text) {
52
48
  tb.appendText(args.text);
53
49
  tb.finalize();
@@ -76,14 +72,12 @@ export interface ToolResultResolveArgs {
76
72
  export interface ToolHookResolver {
77
73
  call: (args: ToolCallResolveArgs) => ToolCallView;
78
74
  result: (args: ToolResultResolveArgs) => ToolResultView;
79
- modeFor: (name: string) => { mode: ToolResultMode; previewLines: number };
75
+ modeFor: (name: string) => { mode: ToolResultMode; previewLines: number; expandedLines: number };
80
76
  }
81
77
 
82
- /** Resolves a tool name to a schema RenderModel — :{name} first, then :default.
83
- * Cache the registered-handler set; callers can `refresh()` after extensions
84
- * register new tool-specific renderers. */
85
78
  export function createToolHookResolver(
86
79
  ctx: ExtensionContext,
80
+ renderer: Renderer,
87
81
  ): ToolHookResolver & { refresh: () => void } {
88
82
  const resolver = loadDisplayResolver();
89
83
  let registered = new Set(ctx.list());
@@ -97,7 +91,6 @@ export function createToolHookResolver(
97
91
  throw new Error(`no render model for tool "${name}" and no ${SCHEMA_PREFIX}default registered`);
98
92
  };
99
93
 
100
- // SchemaResultComponent.render(width) corrects env.width on the first frame.
101
94
  const initialWidth = (): number => process.stdout.columns ?? 80;
102
95
 
103
96
  return {
@@ -106,17 +99,17 @@ export function createToolHookResolver(
106
99
  },
107
100
  modeFor(name: string) {
108
101
  const e = resolver.resolve(name, schemaModel(name).display);
109
- return { mode: e.result, previewLines: e.previewLines };
102
+ return { mode: e.result, previewLines: e.previewLines, expandedLines: e.expandedLines };
110
103
  },
111
104
  call(args) {
112
- const { mode, previewLines } = this.modeFor(args.name);
113
- return mountCall(schemaModel(args.name), args,
114
- { width: initialWidth(), mode, previewLines }) as ToolCallView;
105
+ const { mode, previewLines, expandedLines } = this.modeFor(args.name);
106
+ return renderer.mountToolCall(schemaModel(args.name), args,
107
+ { width: initialWidth(), mode, previewLines, expandedLines });
115
108
  },
116
109
  result(args) {
117
- const { mode, previewLines } = this.modeFor(args.name);
118
- return mountResult(schemaModel(args.name), { ...args, title: args.name },
119
- { width: initialWidth(), mode, previewLines }) as ToolResultView;
110
+ const { mode, previewLines, expandedLines } = this.modeFor(args.name);
111
+ return renderer.mountToolResult(schemaModel(args.name), { ...args, title: args.name },
112
+ { width: initialWidth(), mode, previewLines, expandedLines });
120
113
  },
121
114
  };
122
115
  }
@@ -0,0 +1,222 @@
1
+ import { segmentsToString, type MountArgs, type MountEnv, type RenderModel, type Segment } from "./schema.js";
2
+
3
+ declare const nodeBrand: unique symbol;
4
+ export interface RenderNode {
5
+ readonly [nodeBrand]: true;
6
+ }
7
+
8
+ export interface StyledSink {
9
+ /** Pre-styled lines, painted verbatim (no reflow). */
10
+ setLines(lines: string[]): void;
11
+ setText(text: string): void;
12
+ }
13
+
14
+ export interface TextView extends StyledSink {
15
+ node: RenderNode;
16
+ setRenderFn(fn: ((width: number) => string[]) | null): void;
17
+ }
18
+
19
+ export interface MarkdownOptions {
20
+ color?: (t: string) => string;
21
+ bgColor?: (t: string) => string;
22
+ paddingX?: number;
23
+ paddingY?: number;
24
+ osc133Zones?: boolean;
25
+ /** Primary assistant response — renderers may show a role bullet; others ignore. */
26
+ bullet?: boolean;
27
+ }
28
+
29
+ /** Streaming: ashi pushes the full buffer each update; renderer reflows. */
30
+ export interface MarkdownView {
31
+ node: RenderNode;
32
+ setText(full: string): void;
33
+ }
34
+
35
+ export interface ContainerView {
36
+ node: RenderNode;
37
+ addChild(child: RenderNode): void;
38
+ removeChild(child: RenderNode): void;
39
+ clear(): void;
40
+ }
41
+
42
+ export interface RenderNodes {
43
+ text(opts?: { paddingX?: number; paddingY?: number }): TextView;
44
+ markdown(opts?: MarkdownOptions): MarkdownView;
45
+ /** Null when the renderer can't show images. */
46
+ image(png: Buffer): RenderNode | null;
47
+ container(): ContainerView;
48
+ spacer(rows: number): RenderNode;
49
+ }
50
+
51
+ export interface KeyEvent {
52
+ matches(name: string): boolean;
53
+ isRelease(): boolean;
54
+ isRepeat(): boolean;
55
+ }
56
+
57
+ export type KeyHandler = (key: KeyEvent) => { consume: boolean } | void;
58
+
59
+ export interface AutocompleteItem {
60
+ value: string;
61
+ label: string;
62
+ description?: string;
63
+ }
64
+
65
+ export interface AutocompleteSuggestions {
66
+ items: AutocompleteItem[];
67
+ prefix: string;
68
+ }
69
+
70
+ export interface AutocompleteProvider {
71
+ getSuggestions(
72
+ lines: string[],
73
+ cursorLine: number,
74
+ cursorCol: number,
75
+ ): Promise<AutocompleteSuggestions | null>;
76
+ }
77
+
78
+ export interface InputView {
79
+ node: RenderNode;
80
+ getText(): string;
81
+ setText(text: string): void;
82
+ /** Cursor position — the substrate's autocomplete queries the provider in line/col. */
83
+ getCursor(): { line: number; col: number };
84
+ /** Apply a completion: delete `count` chars before the cursor, insert `text`, cursor after. */
85
+ replaceBeforeCursor(count: number, text: string): void;
86
+ onChange(fn: (text: string) => void): void;
87
+ onSubmit(fn: (text: string) => void): void;
88
+ /** Default border color fn, so callers can restore after a temporary change. */
89
+ readonly defaultBorderColor: (t: string) => string;
90
+ setBorderColor(fn: (t: string) => string): void;
91
+ invalidate(): void;
92
+ }
93
+
94
+ export interface SelectItem {
95
+ value: string;
96
+ label: string;
97
+ description?: string;
98
+ }
99
+
100
+ export interface SelectView {
101
+ node: RenderNode;
102
+ setSelectedIndex(i: number): void;
103
+ getSelectedItem(): SelectItem | undefined;
104
+ onSelect(fn: (item: SelectItem) => void | Promise<void>): void;
105
+ onCancel(fn: () => void): void;
106
+ }
107
+
108
+ export interface LoaderView {
109
+ node: RenderNode;
110
+ stop(): void;
111
+ }
112
+
113
+ export interface App {
114
+ scrollback: ContainerView;
115
+ footerSlot: ContainerView;
116
+ queueSlot: ContainerView;
117
+ input: InputView;
118
+ /** Slot rendered directly beneath the input (e.g. the autocomplete suggestion list). */
119
+ belowInput: ContainerView;
120
+ status: TextView;
121
+ setFocus(target: RenderNode): void;
122
+ focusInput(): void;
123
+ requestRender(force?: boolean): void;
124
+ /** Marks current scrollback as settled; the boundary for renderers that commit finished turns to native scrollback (Ink's <Static>). Others ignore it. */
125
+ commitScrollback?(): void;
126
+ start(): void;
127
+ stop(): void;
128
+ onKey(handler: KeyHandler): void;
129
+ createSelectList(items: SelectItem[], opts: { visibleRows: number }): SelectView;
130
+ createLoader(label: string, color: (t: string) => string, muted: (t: string) => string): LoaderView;
131
+ }
132
+
133
+ export interface ToolCallView {
134
+ node: RenderNode;
135
+ setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void;
136
+ toggleExpanded?(): void;
137
+ }
138
+
139
+ export interface ToolResultView {
140
+ node: RenderNode;
141
+ appendChunk(chunk: string): void;
142
+ /** Width-aware diff closure produced by the edit/write tool at finalize. */
143
+ setDiffRenderer(fn: (width: number) => string[]): void;
144
+ finalize(opts: { exitCode: number | null; summary?: string }): void;
145
+ toggleExpanded(): void;
146
+ }
147
+
148
+ export interface ToolGroupChild {
149
+ name: string;
150
+ detail: string;
151
+ status?: { exitCode: number | null; summary?: string };
152
+ }
153
+
154
+ /** A run of same-kind tool calls: substrate owns the state, renderer owns the look. */
155
+ export interface ToolGroupModel {
156
+ kind: string;
157
+ icon: string;
158
+ children: ToolGroupChild[];
159
+ hidden: { count: number; ok: boolean } | null;
160
+ expanded: boolean;
161
+ open: boolean;
162
+ }
163
+
164
+ export interface ToolGroupView {
165
+ node: RenderNode;
166
+ update(model: ToolGroupModel): void;
167
+ }
168
+
169
+ /** Default tool-group rendering (`├`/`└` tree): one styled line per row, no indent. */
170
+ export function renderToolGroupLines(model: ToolGroupModel): string[] {
171
+ const lines: string[] = [
172
+ segmentsToString([
173
+ { text: model.icon, style: { color: "warning" } },
174
+ " ",
175
+ { text: model.kind, style: { bold: true, color: "toolTitle" } },
176
+ ]),
177
+ ];
178
+ if (model.hidden) {
179
+ const noun = model.hidden.count === 1 ? "earlier call" : "earlier calls";
180
+ lines.push(segmentsToString([
181
+ { text: "├", style: { color: "muted" } }, " ",
182
+ { text: "⋯", style: { color: "muted" } }, " ",
183
+ { text: `${model.hidden.count} ${noun}`, style: { color: "muted" } }, " ",
184
+ { text: model.hidden.ok ? "✓" : "✗", style: { color: model.hidden.ok ? "success" : "error" } },
185
+ ]));
186
+ }
187
+ model.children.forEach((child, idx) => {
188
+ const segs: Segment[] = [{ text: idx === model.children.length - 1 ? "└" : "├", style: { color: "muted" } }, " "];
189
+ if (child.name !== model.kind) segs.push({ text: child.name, style: { bold: true, color: "toolTitle" } }, " ");
190
+ segs.push({ text: child.detail, style: { color: "muted" } }, " ");
191
+ if (!child.status) {
192
+ segs.push(" ", { text: "…", style: { color: "muted" } });
193
+ } else {
194
+ const ok = child.status.exitCode === null || child.status.exitCode === 0;
195
+ segs.push(" ", { text: ok ? "✓" : "✗", style: { color: ok ? "success" : "error" } });
196
+ if (child.status.summary) segs.push(" ", { text: child.status.summary, style: { color: "muted" } });
197
+ }
198
+ lines.push(segmentsToString(segs));
199
+ });
200
+ return lines;
201
+ }
202
+
203
+ export interface RendererCapabilities {
204
+ images: boolean;
205
+ /** When false, assistant/thinking fall back to plain styled text. */
206
+ markdownStreaming: boolean;
207
+ /** True only if the renderer emits its own carriage returns and wants the raw (OPOST-off) terminal; substrate keeps OPOST on otherwise so lone-`\n` renderers don't staircase. */
208
+ rawOutput?: boolean;
209
+ /** Default (omitted) frames the diff in a box; set false to receive bare hunk lines for a self-drawn gutter. */
210
+ diffFrame?: boolean;
211
+ }
212
+
213
+ export interface Renderer extends RenderNodes {
214
+ readonly capabilities: RendererCapabilities;
215
+ /** Visible (ANSI-aware) width of a styled string. */
216
+ measureWidth(text: string): number;
217
+ mountToolCall(model: RenderModel<unknown>, args: MountArgs, env: MountEnv): ToolCallView;
218
+ mountToolResult(model: RenderModel<unknown>, args: MountArgs, env: MountEnv): ToolResultView;
219
+ /** Omitting this opts out of grouping; substrate then renders same-kind calls individually. */
220
+ mountToolGroup?(): ToolGroupView;
221
+ mount(): App;
222
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ Editor,
3
+ isKeyRelease,
4
+ isKeyRepeat,
5
+ Loader,
6
+ matchesKey,
7
+ ProcessTerminal,
8
+ SelectList,
9
+ TUI,
10
+ type Component,
11
+ type SelectItem as PiSelectItem,
12
+ } from "@earendil-works/pi-tui";
13
+ import { editorTheme, selectListTheme } from "./theme-adapters.js";
14
+ import { createNodes, footerContainer } from "./nodes.js";
15
+ import type {
16
+ App,
17
+ InputView,
18
+ KeyEvent,
19
+ LoaderView,
20
+ RenderNode,
21
+ SelectItem,
22
+ SelectView,
23
+ } from "../../renderer.js";
24
+
25
+ const asComponent = (n: RenderNode): Component => n as unknown as Component;
26
+ const asNode = (c: Component): RenderNode => c as unknown as RenderNode;
27
+
28
+ function makeInput(editor: Editor): InputView {
29
+ return {
30
+ node: asNode(editor),
31
+ getText: () => editor.getText(),
32
+ setText: (t) => editor.setText(t),
33
+ getCursor: () => editor.getCursor(),
34
+ replaceBeforeCursor: (count, text) => {
35
+ for (let i = 0; i < count; i++) editor.handleInput("\x7f");
36
+ editor.insertTextAtCursor(text);
37
+ },
38
+ onChange: (fn) => { editor.onChange = fn; },
39
+ onSubmit: (fn) => { editor.onSubmit = fn; },
40
+ defaultBorderColor: editor.borderColor,
41
+ setBorderColor: (fn) => { editor.borderColor = fn; },
42
+ invalidate: () => editor.invalidate(),
43
+ };
44
+ }
45
+
46
+ function makeSelect(items: SelectItem[], visibleRows: number): SelectView {
47
+ const picker = new SelectList(items as PiSelectItem[], visibleRows, selectListTheme());
48
+ return {
49
+ node: asNode(picker),
50
+ setSelectedIndex: (i) => picker.setSelectedIndex(i),
51
+ getSelectedItem: () => picker.getSelectedItem() as SelectItem | undefined,
52
+ onSelect: (fn) => { picker.onSelect = fn; },
53
+ onCancel: (fn) => { picker.onCancel = fn; },
54
+ };
55
+ }
56
+
57
+ class FlushLoader extends Loader {
58
+ override render(width: number): string[] {
59
+ const lines = super.render(width);
60
+ return lines[0] === "" ? lines.slice(1) : lines;
61
+ }
62
+ }
63
+
64
+ function makeLoader(
65
+ tui: TUI,
66
+ label: string,
67
+ color: (t: string) => string,
68
+ muted: (t: string) => string,
69
+ ): LoaderView {
70
+ const loader = new FlushLoader(tui, color, muted, label);
71
+ return { node: asNode(loader), stop: () => loader.stop() };
72
+ }
73
+
74
+ export function createApp(): App {
75
+ const nodes = createNodes();
76
+ const terminal = new ProcessTerminal();
77
+ const tui = new TUI(terminal);
78
+
79
+ const scrollback = nodes.container();
80
+ const footerSlot = footerContainer(
81
+ () => (scrollback.node as unknown as { children: unknown[] }).children.length > 0,
82
+ );
83
+ const queueSlot = nodes.container();
84
+ const status = nodes.text({ paddingX: 1 });
85
+ const belowInput = nodes.container();
86
+ const editor = new Editor(tui, editorTheme(), { paddingX: 1 });
87
+ const input = makeInput(editor);
88
+
89
+ tui.addChild(asComponent(scrollback.node));
90
+ tui.addChild(asComponent(footerSlot.node));
91
+ tui.addChild(asComponent(queueSlot.node));
92
+ tui.addChild(editor);
93
+ tui.addChild(asComponent(belowInput.node));
94
+ tui.addChild(asComponent(status.node));
95
+ tui.setFocus(editor);
96
+
97
+ return {
98
+ scrollback,
99
+ footerSlot,
100
+ queueSlot,
101
+ input,
102
+ belowInput,
103
+ status,
104
+ setFocus: (target) => tui.setFocus(asComponent(target)),
105
+ focusInput: () => tui.setFocus(editor),
106
+ requestRender: (force) => tui.requestRender(force),
107
+ commitScrollback: () => {},
108
+ start: () => tui.start(),
109
+ stop: () => tui.stop(),
110
+ onKey: (handler) =>
111
+ tui.addInputListener((data) => {
112
+ const key: KeyEvent = {
113
+ matches: (name) => matchesKey(data, name as Parameters<typeof matchesKey>[1]),
114
+ isRelease: () => isKeyRelease(data),
115
+ isRepeat: () => isKeyRepeat(data),
116
+ };
117
+ return handler(key) ?? undefined;
118
+ }),
119
+ createSelectList: (items, opts) => makeSelect(items, opts.visibleRows),
120
+ createLoader: (label, color, muted) => makeLoader(tui, label, color, muted),
121
+ };
122
+ }
@@ -0,0 +1,23 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { Renderer } from "../../renderer.js";
3
+ import { createNodes } from "./nodes.js";
4
+ import { createApp } from "./app.js";
5
+ import { mountCall, mountResult } from "./schema-mount.js";
6
+ import { createPiTuiToolGroup } from "./tool-group.js";
7
+
8
+ export function createPiTuiRenderer(): Renderer {
9
+ const nodes = createNodes();
10
+ return {
11
+ ...nodes,
12
+ capabilities: {
13
+ images: true,
14
+ markdownStreaming: true,
15
+ rawOutput: true,
16
+ },
17
+ measureWidth: (text) => visibleWidth(text),
18
+ mountToolCall: (model, args, env) => mountCall(model, args, env),
19
+ mountToolResult: (model, args, env) => mountResult(model, args, env),
20
+ mountToolGroup: () => createPiTuiToolGroup(),
21
+ mount: () => createApp(),
22
+ };
23
+ }
@@ -0,0 +1,133 @@
1
+ import {
2
+ Container,
3
+ getImageDimensions,
4
+ Image,
5
+ Markdown,
6
+ Spacer,
7
+ Text,
8
+ type Component,
9
+ } from "@earendil-works/pi-tui";
10
+ import { theme } from "../../theme.js";
11
+ import { markdownTheme } from "./theme-adapters.js";
12
+ import type {
13
+ ContainerView,
14
+ MarkdownOptions,
15
+ MarkdownView,
16
+ RenderNode,
17
+ RenderNodes,
18
+ TextView,
19
+ } from "../../renderer.js";
20
+
21
+ const asNode = (c: Component): RenderNode => c as unknown as RenderNode;
22
+ const asComponent = (n: RenderNode): Component => n as unknown as Component;
23
+
24
+ class MeasuredText extends Text {
25
+ private fn: ((width: number) => string[]) | null = null;
26
+ private lastWidth = -1;
27
+
28
+ setRenderFn(fn: ((width: number) => string[]) | null): void {
29
+ this.fn = fn;
30
+ this.lastWidth = -1;
31
+ }
32
+
33
+ override render(width: number): string[] {
34
+ if (this.fn && width !== this.lastWidth) {
35
+ this.lastWidth = width;
36
+ this.setText(this.fn(width).join("\n"));
37
+ }
38
+ return super.render(width);
39
+ }
40
+ }
41
+
42
+ const OSC133_ZONE_START = "\x1b]133;A\x07";
43
+ const OSC133_ZONE_END = "\x1b]133;B\x07";
44
+ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
45
+
46
+ // OSC 133 zone brackets let terminals navigate between user prompts.
47
+ class ZonedMarkdown extends Markdown {
48
+ override render(width: number): string[] {
49
+ const base = super.render(width);
50
+ if (base.length === 0) return base;
51
+ const lines = base.slice();
52
+ lines[0] = OSC133_ZONE_START + lines[0];
53
+ lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
54
+ return lines;
55
+ }
56
+ }
57
+
58
+ class FooterSlot extends Container {
59
+ constructor(private readonly hasContentAbove: () => boolean) {
60
+ super();
61
+ }
62
+ override render(width: number): string[] {
63
+ if (this.children.length > 0) return super.render(width);
64
+ return this.hasContentAbove() ? [""] : [];
65
+ }
66
+ }
67
+
68
+ export function footerContainer(hasContentAbove: () => boolean): ContainerView {
69
+ const c = new FooterSlot(hasContentAbove);
70
+ return {
71
+ node: asNode(c),
72
+ addChild: (child) => c.addChild(asComponent(child)),
73
+ removeChild: (child) => c.removeChild(asComponent(child)),
74
+ clear: () => c.clear(),
75
+ };
76
+ }
77
+
78
+ export function createNodes(): RenderNodes {
79
+ return {
80
+ text(opts) {
81
+ const t = new MeasuredText("", opts?.paddingX ?? 0, opts?.paddingY ?? 0);
82
+ const view: TextView = {
83
+ node: asNode(t),
84
+ setText: (s) => t.setText(s),
85
+ setLines: (lines) => t.setText(lines.join("\n")),
86
+ setRenderFn: (fn) => t.setRenderFn(fn),
87
+ };
88
+ return view;
89
+ },
90
+
91
+ markdown(opts?: MarkdownOptions) {
92
+ const colorOpts =
93
+ opts?.color || opts?.bgColor
94
+ ? { ...(opts.color ? { color: opts.color } : {}), ...(opts.bgColor ? { bgColor: opts.bgColor } : {}) }
95
+ : undefined;
96
+ const Ctor = opts?.osc133Zones ? ZonedMarkdown : Markdown;
97
+ const md = new Ctor("", opts?.paddingX ?? 0, opts?.paddingY ?? 0, markdownTheme(), colorOpts);
98
+ const view: MarkdownView = {
99
+ node: asNode(md),
100
+ setText: (full) => md.setText(full),
101
+ };
102
+ return view;
103
+ },
104
+
105
+ image(png: Buffer): RenderNode | null {
106
+ const base64 = png.toString("base64");
107
+ const dims = getImageDimensions(base64, "image/png");
108
+ if (!dims) return null;
109
+ const img = new Image(
110
+ base64,
111
+ "image/png",
112
+ { fallbackColor: (t) => theme.fg("muted", t) },
113
+ { maxWidthCells: 60, maxHeightCells: 20 },
114
+ dims,
115
+ );
116
+ return asNode(img);
117
+ },
118
+
119
+ container(): ContainerView {
120
+ const c = new Container();
121
+ return {
122
+ node: asNode(c),
123
+ addChild: (child) => c.addChild(asComponent(child)),
124
+ removeChild: (child) => c.removeChild(asComponent(child)),
125
+ clear: () => c.clear(),
126
+ };
127
+ },
128
+
129
+ spacer(rows: number): RenderNode {
130
+ return asNode(new Spacer(rows));
131
+ },
132
+ };
133
+ }