agent-sh 0.15.0 → 0.15.2

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 (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Spill long shell outputs to per-session tempfiles.
3
+ *
4
+ * Captured PTY output that exceeds the truncation threshold is written to
5
+ * `<tmpdir>/agent-sh-<pid>/<id>.out`. The in-memory exchange keeps only a
6
+ * head+tail stub pointing at that path, so the agent can fetch the full
7
+ * text via `read_file` on demand. The session dir is removed on process
8
+ * exit; stale dirs from dead processes are swept lazily on first use.
9
+ */
10
+ import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ const DIR_PREFIX = "agent-sh-";
15
+
16
+ let sessionDir: string | null = null;
17
+ let cleanupRegistered = false;
18
+
19
+ export function getSessionDir(): string {
20
+ if (sessionDir) return sessionDir;
21
+ sessionDir = join(tmpdir(), `${DIR_PREFIX}${process.pid}`);
22
+ mkdirSync(sessionDir, { recursive: true });
23
+ sweepStaleDirs();
24
+ if (!cleanupRegistered) {
25
+ cleanupRegistered = true;
26
+ const cleanup = () => {
27
+ if (!sessionDir) return;
28
+ try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
29
+ sessionDir = null;
30
+ };
31
+ process.on("exit", cleanup);
32
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
33
+ process.on(sig, () => { cleanup(); process.exit(128); });
34
+ }
35
+ }
36
+ return sessionDir;
37
+ }
38
+
39
+ export function spillOutput(id: number, text: string): string {
40
+ const path = join(getSessionDir(), `${id}.out`);
41
+ writeFileSync(path, text);
42
+ return path;
43
+ }
44
+
45
+ function sweepStaleDirs(): void {
46
+ const base = tmpdir();
47
+ let entries: string[];
48
+ try {
49
+ entries = readdirSync(base);
50
+ } catch {
51
+ return;
52
+ }
53
+ for (const name of entries) {
54
+ if (!name.startsWith(DIR_PREFIX)) continue;
55
+ const pid = Number(name.slice(DIR_PREFIX.length));
56
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
57
+ if (isProcessAlive(pid)) continue;
58
+ const full = join(base, name);
59
+ try {
60
+ // Small safety check: only remove directories.
61
+ if (statSync(full).isDirectory()) {
62
+ rmSync(full, { recursive: true, force: true });
63
+ }
64
+ } catch {}
65
+ }
66
+ }
67
+
68
+ function isProcessAlive(pid: number): boolean {
69
+ try {
70
+ process.kill(pid, 0);
71
+ return true;
72
+ } catch (e) {
73
+ // ESRCH = no such process; EPERM = exists but we can't signal it
74
+ return (e as NodeJS.ErrnoException).code === "EPERM";
75
+ }
76
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Stream transform helpers for content pipeline extensions.
3
+ *
4
+ * Handles the boilerplate of buffering across chunk boundaries,
5
+ * pattern matching, and flush-on-done coordination.
6
+ */
7
+ import type { EventBus, ContentBlock } from "../core/event-bus.js";
8
+
9
+ export interface BlockTransformOptions {
10
+ /** Opening delimiter (e.g. "$$") */
11
+ open: string;
12
+ /** Closing delimiter (e.g. "$$") */
13
+ close: string;
14
+ /**
15
+ * Transform the content between delimiters.
16
+ * Return a ContentBlock (text, image, or raw) or null to keep original.
17
+ */
18
+ transform: (content: string) => ContentBlock | ContentBlock[] | null;
19
+ }
20
+
21
+ /**
22
+ * Register a delimiter-based block transform on the content pipeline.
23
+ *
24
+ * Automatically handles:
25
+ * - Buffering across chunk boundaries
26
+ * - Safe boundary detection (only emits text outside open delimiters)
27
+ * - Flush on response-done
28
+ *
29
+ * Example:
30
+ * createBlockTransform(bus, {
31
+ * open: "$$",
32
+ * close: "$$",
33
+ * transform(latex) {
34
+ * const png = renderLatex(latex);
35
+ * return png ? { type: "image", data: png } : null;
36
+ * },
37
+ * });
38
+ */
39
+ export function createBlockTransform(
40
+ bus: EventBus,
41
+ opts: BlockTransformOptions,
42
+ ): void {
43
+ let buffer = "";
44
+
45
+ bus.onPipe("agent:response-chunk", (e) => {
46
+ const outBlocks: ContentBlock[] = [];
47
+
48
+ for (const block of e.blocks) {
49
+ if (block.type === "text") {
50
+ buffer += block.text;
51
+ const { blocks: parsed, pending } = processBuffer(buffer, opts);
52
+ buffer = pending;
53
+ outBlocks.push(...parsed);
54
+ } else {
55
+ outBlocks.push(block);
56
+ }
57
+ }
58
+
59
+ return { blocks: outBlocks };
60
+ });
61
+
62
+ bus.onPipe("agent:response-done", (e) => {
63
+ if (buffer) {
64
+ bus.emitTransform("agent:response-chunk", {
65
+ blocks: [{ type: "text", text: buffer }],
66
+ });
67
+ buffer = "";
68
+ }
69
+ return e;
70
+ });
71
+ }
72
+
73
+ // ── Fenced block transform ────────────────────────────────────────
74
+
75
+ export interface FencedBlockTransformOptions {
76
+ /** Regex matching the opening fence line. Captures are passed to transform. */
77
+ open: RegExp;
78
+ /** Regex matching the closing fence line. */
79
+ close: RegExp;
80
+ /**
81
+ * Transform a complete fenced block.
82
+ * Receives the opening fence match and the content between fences.
83
+ * Return ContentBlock(s), or null to produce a default code-block.
84
+ */
85
+ transform: (openMatch: RegExpMatchArray, content: string) => ContentBlock | ContentBlock[] | null;
86
+ }
87
+
88
+ /**
89
+ * Register a line-delimited fenced block transform on the content pipeline.
90
+ *
91
+ * Detects patterns like ```lang\n...\n``` in the streaming text,
92
+ * buffers the content line-by-line, and produces ContentBlocks when
93
+ * the closing fence arrives.
94
+ *
95
+ * Example:
96
+ * createFencedBlockTransform(bus, {
97
+ * open: /^```(\w*)\s*$/,
98
+ * close: /^```\s*$/,
99
+ * transform(match, content) {
100
+ * return { type: "code-block", language: match[1] || "", code: content };
101
+ * },
102
+ * });
103
+ */
104
+ export interface FencedBlockTransformHandle {
105
+ /** Flush any buffered text (e.g. before tool calls, to preserve interleaving). */
106
+ flush(): void;
107
+ }
108
+
109
+ export function createFencedBlockTransform(
110
+ bus: EventBus,
111
+ opts: FencedBlockTransformOptions,
112
+ ): FencedBlockTransformHandle {
113
+ let buffer = "";
114
+ let inFence = false;
115
+ let fenceMatch: RegExpMatchArray | null = null;
116
+ let fenceLines: string[] = [];
117
+ let flushing = false;
118
+
119
+ bus.onPipe("agent:response-chunk", (e) => {
120
+ if (flushing) return e; // pass through during flush to avoid re-buffering
121
+
122
+ // Separate text blocks (to buffer) from non-text blocks (pass through)
123
+ let incoming = "";
124
+ const passthrough: ContentBlock[] = [];
125
+ for (const block of e.blocks) {
126
+ if (block.type === "text") {
127
+ incoming += block.text;
128
+ } else {
129
+ passthrough.push(block);
130
+ }
131
+ }
132
+
133
+ const { blocks, pending } = processFencedBuffer(buffer + incoming, opts, inFence, fenceMatch, fenceLines);
134
+ buffer = pending.text;
135
+ inFence = pending.inFence;
136
+ fenceMatch = pending.fenceMatch;
137
+ fenceLines = pending.fenceLines;
138
+
139
+ return { blocks: [...passthrough, ...blocks] };
140
+ });
141
+
142
+ function flushBuffer(): void {
143
+ if (!buffer && !inFence) return;
144
+ let remaining = buffer;
145
+ if (inFence) {
146
+ remaining = (fenceMatch?.[0] ?? "") + "\n" + fenceLines.join("\n") + (remaining ? "\n" + remaining : "");
147
+ inFence = false;
148
+ fenceMatch = null;
149
+ fenceLines = [];
150
+ }
151
+ buffer = "";
152
+ if (remaining) {
153
+ flushing = true;
154
+ bus.emitTransform("agent:response-chunk", {
155
+ blocks: [{ type: "text", text: remaining }],
156
+ });
157
+ flushing = false;
158
+ }
159
+ }
160
+
161
+ bus.onPipe("agent:response-done", (e) => {
162
+ flushBuffer();
163
+ return e;
164
+ });
165
+
166
+ return { flush: flushBuffer };
167
+ }
168
+
169
+ interface FencedPendingState {
170
+ text: string;
171
+ inFence: boolean;
172
+ fenceMatch: RegExpMatchArray | null;
173
+ fenceLines: string[];
174
+ }
175
+
176
+ function processFencedBuffer(
177
+ text: string,
178
+ opts: FencedBlockTransformOptions,
179
+ inFence: boolean,
180
+ fenceMatch: RegExpMatchArray | null,
181
+ fenceLines: string[],
182
+ ): { blocks: ContentBlock[]; pending: FencedPendingState } {
183
+ const blocks: ContentBlock[] = [];
184
+ const lines = text.split("\n");
185
+ // Last element might be an incomplete line — hold it back
186
+ const incompleteLine = lines.pop()!;
187
+
188
+ let textAccum = ""; // accumulate non-fence text as one block
189
+
190
+ for (const line of lines) {
191
+ if (inFence) {
192
+ // Check for closing fence
193
+ if (opts.close.test(line)) {
194
+ const content = fenceLines.join("\n");
195
+ const result = opts.transform(fenceMatch!, content);
196
+ if (result === null) {
197
+ const lang = fenceMatch?.[1] ?? "";
198
+ blocks.push({ type: "code-block", language: lang, code: content });
199
+ } else if (Array.isArray(result)) {
200
+ blocks.push(...result);
201
+ } else {
202
+ blocks.push(result);
203
+ }
204
+ inFence = false;
205
+ fenceMatch = null;
206
+ fenceLines = [];
207
+ } else {
208
+ fenceLines.push(line);
209
+ }
210
+ } else {
211
+ // Check for opening fence
212
+ const match = line.match(opts.open);
213
+ if (match) {
214
+ // Flush accumulated text before the fence
215
+ if (textAccum) {
216
+ blocks.push({ type: "text", text: textAccum });
217
+ textAccum = "";
218
+ }
219
+ inFence = true;
220
+ fenceMatch = match;
221
+ fenceLines = [];
222
+ } else {
223
+ // Accumulate non-fence text (keep contiguous for downstream transforms)
224
+ textAccum += line + "\n";
225
+ }
226
+ }
227
+ }
228
+
229
+ // Flush remaining accumulated text
230
+ if (textAccum) {
231
+ blocks.push({ type: "text", text: textAccum });
232
+ }
233
+
234
+ return {
235
+ blocks,
236
+ pending: {
237
+ text: incompleteLine,
238
+ inFence,
239
+ fenceMatch,
240
+ fenceLines,
241
+ },
242
+ };
243
+ }
244
+
245
+ // ── Inline delimiter block transform ─────────────────────────────
246
+
247
+ function processBuffer(
248
+ text: string,
249
+ opts: BlockTransformOptions,
250
+ ): { blocks: ContentBlock[]; pending: string } {
251
+ const blocks: ContentBlock[] = [];
252
+ let i = 0;
253
+
254
+ while (i < text.length) {
255
+ const openIdx = text.indexOf(opts.open, i);
256
+ if (openIdx === -1) {
257
+ // No more delimiters — everything is safe text
258
+ const remainder = text.slice(i);
259
+ if (remainder) blocks.push({ type: "text", text: remainder });
260
+ return { blocks, pending: "" };
261
+ }
262
+
263
+ const searchFrom = openIdx + opts.open.length;
264
+ const closeIdx = text.indexOf(opts.close, searchFrom);
265
+ if (closeIdx === -1) {
266
+ // Unclosed delimiter — emit text before, hold back from delimiter
267
+ const before = text.slice(i, openIdx);
268
+ if (before) blocks.push({ type: "text", text: before });
269
+ return { blocks, pending: text.slice(openIdx) };
270
+ }
271
+
272
+ // Complete match
273
+ const before = text.slice(i, openIdx);
274
+ if (before) blocks.push({ type: "text", text: before });
275
+
276
+ const inner = text.slice(searchFrom, closeIdx).trim();
277
+ const result = opts.transform(inner);
278
+
279
+ if (result === null) {
280
+ // Transform declined — keep original text with delimiters
281
+ blocks.push({ type: "text", text: opts.open + inner + opts.close });
282
+ } else if (Array.isArray(result)) {
283
+ blocks.push(...result);
284
+ } else {
285
+ blocks.push(result);
286
+ }
287
+
288
+ i = closeIdx + opts.close.length;
289
+ }
290
+
291
+ return { blocks, pending: "" };
292
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Headless terminal buffer backed by xterm.js.
3
+ *
4
+ * Provides accurate terminal screen capture — correctly handles ANSI
5
+ * codes, cursor movement, alternate screen (vim/htop), line wrapping,
6
+ * and scrollback.
7
+ *
8
+ * Used by:
9
+ * - floating-panel.ts: composited overlay rendering + screen restore
10
+ * - terminal-buffer extension: agent tools (terminal_read, terminal_keys)
11
+ * - Any extension needing a virtual terminal snapshot
12
+ */
13
+ // xterm is loaded lazily on first TerminalBuffer.create(). Subcommands
14
+ // (init/install/list) and non-shell frontends (web bridges) import this
15
+ // file transitively but never instantiate a buffer; they shouldn't pay
16
+ // the xterm parse cost at startup.
17
+ import { createRequire } from "module";
18
+ import type { Terminal, IBuffer } from "@xterm/headless";
19
+ import type { SerializeAddon } from "@xterm/addon-serialize";
20
+ import type { EventBus } from "../core/event-bus.js";
21
+
22
+ const require = createRequire(import.meta.url);
23
+
24
+ // Node's require cache memoizes the first hit; subsequent calls are
25
+ // just a hashmap lookup, so this stays lazy without our own caching.
26
+ const loadXterm = (): { Terminal: typeof Terminal; SerializeAddon: typeof SerializeAddon } => ({
27
+ Terminal: require("@xterm/headless").Terminal,
28
+ SerializeAddon: require("@xterm/addon-serialize").SerializeAddon,
29
+ });
30
+
31
+ // ── Types ───────────────────────────────────────────────────────
32
+
33
+ export interface TerminalBufferConfig {
34
+ /** Terminal width in columns. Default: process.stdout.columns || 80. */
35
+ cols?: number;
36
+ /** Terminal height in rows. Default: process.stdout.rows || 24. */
37
+ rows?: number;
38
+ /** Scrollback buffer size. Default: 200. */
39
+ scrollback?: number;
40
+ }
41
+
42
+ export interface ScreenSnapshot {
43
+ /** Clean text with ANSI sequences stripped. */
44
+ text: string;
45
+ /** Whether the alternate screen buffer is active (vim, htop, etc.). */
46
+ altScreen: boolean;
47
+ /** Cursor position. */
48
+ cursorX: number;
49
+ cursorY: number;
50
+ }
51
+
52
+ /**
53
+ * Format a screen snapshot as an XML context block for agent injection.
54
+ * Trims, caps to `maxLines` (from the bottom), and wraps in `<terminal_buffer>`.
55
+ * Returns the combined context string (baseContext + section), or just
56
+ * baseContext if the screen is empty.
57
+ */
58
+ export function formatScreenContext(
59
+ screen: ScreenSnapshot,
60
+ maxLines = 80,
61
+ baseContext?: string,
62
+ ): string {
63
+ const trimmed = screen.text.trim();
64
+ if (!trimmed) return baseContext ?? "";
65
+
66
+ const lines = trimmed.split("\n");
67
+ const capped = lines.length > maxLines
68
+ ? lines.slice(-maxLines).join("\n")
69
+ : trimmed;
70
+
71
+ const header = screen.altScreen
72
+ ? "<terminal_buffer mode=\"alternate\">"
73
+ : "<terminal_buffer>";
74
+ const section = `${header}\n${capped}\n</terminal_buffer>`;
75
+ return baseContext ? baseContext + "\n" + section : section;
76
+ }
77
+
78
+ // ── TerminalBuffer ──────────────────────────────────────────────
79
+
80
+ export class TerminalBuffer {
81
+ private readonly term: Terminal;
82
+ private readonly serializeAddon: SerializeAddon;
83
+
84
+ /** Flush pending drip-feed data (set by createWired). */
85
+ _flushPending: (() => void) | null = null;
86
+
87
+ private constructor(term: Terminal, serialize: SerializeAddon) {
88
+ this.term = term;
89
+ this.serializeAddon = serialize;
90
+ }
91
+
92
+ static create(config?: TerminalBufferConfig): TerminalBuffer {
93
+ const { Terminal, SerializeAddon } = loadXterm();
94
+ const cols = config?.cols ?? (process.stdout.columns || 80);
95
+ const rows = config?.rows ?? (process.stdout.rows || 24);
96
+ const scrollback = config?.scrollback ?? 200;
97
+
98
+ const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback });
99
+ const serialize = new SerializeAddon();
100
+ term.loadAddon(serialize);
101
+ return new TerminalBuffer(term, serialize);
102
+ }
103
+
104
+ /**
105
+ * Create a TerminalBuffer wired to a bus's `shell:pty-data` event.
106
+ * Drip-feeds writes asynchronously: synchronous `term.write()` in the
107
+ * pty-data handler changes PTY read coalescing enough to introduce
108
+ * visual artifacts.
109
+ */
110
+ static createWired(bus: EventBus, config?: TerminalBufferConfig): TerminalBuffer {
111
+ const tb = TerminalBuffer.create(config);
112
+ let pending = "";
113
+ const drain = (): void => {
114
+ if (pending) { const d = pending; pending = ""; tb.write(d); }
115
+ };
116
+ bus.on("shell:pty-data", ({ raw }) => { pending += raw; });
117
+ setInterval(drain, 50);
118
+ tb._flushPending = drain;
119
+ process.stdout.on("resize", () => {
120
+ tb.resize(process.stdout.columns || 80, process.stdout.rows || 24);
121
+ });
122
+ return tb;
123
+ }
124
+
125
+ /** Flush any pending drip-feed data into the virtual terminal. */
126
+ flush(): void {
127
+ this._flushPending?.();
128
+ }
129
+
130
+ /** Write raw data into the virtual terminal. */
131
+ write(data: string): void {
132
+ this.term.write(data);
133
+ }
134
+
135
+ /** Get the raw serialized terminal output (includes ANSI sequences). */
136
+ serialize(): string {
137
+ return this.serializeAddon.serialize();
138
+ }
139
+
140
+ /** Read clean screen text with metadata. */
141
+ readScreen(opts?: { includeScrollback?: boolean }): ScreenSnapshot {
142
+ const buf = this.term.buffer.active;
143
+ const lines = opts?.includeScrollback
144
+ ? this.readAllLines(buf)
145
+ : this.readViewportLines(buf);
146
+ return {
147
+ text: lines.join("\n"),
148
+ altScreen: buf.type === "alternate",
149
+ cursorX: buf.cursorX,
150
+ cursorY: buf.cursorY,
151
+ };
152
+ }
153
+
154
+ /** Read the screen and wrap it as a `<terminal_buffer>` context block. */
155
+ formatScreen(maxLines?: number, baseContext?: string): string {
156
+ return formatScreenContext(this.readScreen(), maxLines, baseContext);
157
+ }
158
+
159
+ /**
160
+ * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
161
+ * Clean text only (ANSI stripped). Reads from the active buffer's
162
+ * viewport (not scrollback), so it works correctly on both the normal
163
+ * and alternate screen buffers.
164
+ */
165
+ getScreenLines(rows?: number): string[] {
166
+ const targetRows = rows ?? (process.stdout.rows || 24);
167
+ return this.readViewportLines(this.term.buffer.active, targetRows);
168
+ }
169
+
170
+ /** Read visible viewport lines from a buffer. */
171
+ private readViewportLines(buf: IBuffer, rows?: number): string[] {
172
+ const targetRows = rows ?? buf.length;
173
+ const base = buf.baseY ?? 0;
174
+ const lines: string[] = [];
175
+ for (let y = 0; y < targetRows; y++) {
176
+ const line = buf.getLine(base + y);
177
+ lines.push(line ? line.translateToString(true) : "");
178
+ }
179
+ return lines;
180
+ }
181
+
182
+ /** Read all lines including scrollback from a buffer. */
183
+ private readAllLines(buf: IBuffer): string[] {
184
+ const total = (buf.baseY ?? 0) + buf.length;
185
+ const lines: string[] = [];
186
+ for (let y = 0; y < total; y++) {
187
+ const line = buf.getLine(y);
188
+ lines.push(line ? line.translateToString(true) : "");
189
+ }
190
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
191
+ lines.pop();
192
+ }
193
+ return lines;
194
+ }
195
+
196
+ /** Get cursor position. */
197
+ getCursor(): { x: number; y: number } {
198
+ return {
199
+ x: this.term.buffer.active.cursorX,
200
+ y: this.term.buffer.active.cursorY,
201
+ };
202
+ }
203
+
204
+ /** Resize the virtual terminal. */
205
+ resize(cols: number, rows: number): void {
206
+ this.term.resize(cols, rows);
207
+ }
208
+
209
+ /** Whether the alternate screen buffer is active. */
210
+ get altScreen(): boolean {
211
+ return this.term.buffer.active.type === "alternate";
212
+ }
213
+ }