agent-sh 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ import type { EventBus } from "../event-bus.js";
2
+ /** Check if @xterm/headless is installed without loading it. */
3
+ export declare function isXtermAvailable(): boolean;
4
+ export interface TerminalBufferConfig {
5
+ /** Terminal width in columns. Default: process.stdout.columns || 80. */
6
+ cols?: number;
7
+ /** Terminal height in rows. Default: process.stdout.rows || 24. */
8
+ rows?: number;
9
+ /** Scrollback buffer size. Default: 200. */
10
+ scrollback?: number;
11
+ }
12
+ export interface ScreenSnapshot {
13
+ /** Clean text with ANSI sequences stripped. */
14
+ text: string;
15
+ /** Whether the alternate screen buffer is active (vim, htop, etc.). */
16
+ altScreen: boolean;
17
+ /** Cursor position. */
18
+ cursorX: number;
19
+ cursorY: number;
20
+ }
21
+ /**
22
+ * Format a screen snapshot as an XML context block for agent injection.
23
+ * Trims, caps to `maxLines` (from the bottom), and wraps in `<terminal_buffer>`.
24
+ * Returns the combined context string (baseContext + section), or just
25
+ * baseContext if the screen is empty.
26
+ */
27
+ export declare function formatScreenContext(screen: ScreenSnapshot, maxLines?: number, baseContext?: string): string;
28
+ export declare class TerminalBuffer {
29
+ private readonly term;
30
+ private readonly serializeAddon;
31
+ private constructor();
32
+ /**
33
+ * Create a new TerminalBuffer. Returns null if xterm is not installed.
34
+ */
35
+ static create(config?: TerminalBufferConfig): TerminalBuffer | null;
36
+ /**
37
+ * Create a TerminalBuffer and wire it to a bus's shell:pty-data event.
38
+ * Returns null if xterm is not installed.
39
+ */
40
+ static createWired(bus: EventBus, config?: TerminalBufferConfig): TerminalBuffer | null;
41
+ /** Write raw data into the virtual terminal. */
42
+ write(data: string): void;
43
+ /** Get the raw serialized terminal output (includes ANSI sequences). */
44
+ serialize(): string;
45
+ /** Read clean screen text with metadata. */
46
+ readScreen(): ScreenSnapshot;
47
+ /**
48
+ * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
49
+ * Clean text only (ANSI stripped). Reads from the active buffer's
50
+ * viewport (not scrollback), so it works correctly on both the normal
51
+ * and alternate screen buffers.
52
+ */
53
+ getScreenLines(rows?: number): string[];
54
+ /** Read visible viewport lines from a buffer. */
55
+ private readViewportLines;
56
+ /** Get cursor position. */
57
+ getCursor(): {
58
+ x: number;
59
+ y: number;
60
+ };
61
+ /** Resize the virtual terminal. */
62
+ resize(cols: number, rows: number): void;
63
+ /** Whether the alternate screen buffer is active. */
64
+ get altScreen(): boolean;
65
+ }
@@ -0,0 +1,166 @@
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
+ * The xterm dependency is loaded lazily on first use. If @xterm/headless
14
+ * is not installed, create() returns null.
15
+ *
16
+ * Install (optional):
17
+ * npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
18
+ */
19
+ import { createRequire } from "module";
20
+ // ── Lazy xterm loader ───────────────────────────────────────────
21
+ const require = createRequire(import.meta.url);
22
+ let loadAttempted = false;
23
+ let available = false;
24
+ let TerminalCtor;
25
+ let SerializeAddonCtor;
26
+ function ensureXterm() {
27
+ if (loadAttempted)
28
+ return available;
29
+ loadAttempted = true;
30
+ try {
31
+ TerminalCtor = require("@xterm/headless").Terminal;
32
+ SerializeAddonCtor = require("@xterm/addon-serialize").SerializeAddon;
33
+ available = true;
34
+ }
35
+ catch {
36
+ available = false;
37
+ }
38
+ return available;
39
+ }
40
+ /** Check if @xterm/headless is installed without loading it. */
41
+ export function isXtermAvailable() {
42
+ return ensureXterm();
43
+ }
44
+ /**
45
+ * Format a screen snapshot as an XML context block for agent injection.
46
+ * Trims, caps to `maxLines` (from the bottom), and wraps in `<terminal_buffer>`.
47
+ * Returns the combined context string (baseContext + section), or just
48
+ * baseContext if the screen is empty.
49
+ */
50
+ export function formatScreenContext(screen, maxLines = 80, baseContext) {
51
+ const trimmed = screen.text.trim();
52
+ if (!trimmed)
53
+ return baseContext ?? "";
54
+ const lines = trimmed.split("\n");
55
+ const capped = lines.length > maxLines
56
+ ? lines.slice(-maxLines).join("\n")
57
+ : trimmed;
58
+ const header = screen.altScreen
59
+ ? "<terminal_buffer mode=\"alternate\">"
60
+ : "<terminal_buffer>";
61
+ const section = `${header}\n${capped}\n</terminal_buffer>`;
62
+ return baseContext ? baseContext + "\n" + section : section;
63
+ }
64
+ // ── TerminalBuffer ──────────────────────────────────────────────
65
+ export class TerminalBuffer {
66
+ term;
67
+ serializeAddon;
68
+ constructor(term, serialize) {
69
+ this.term = term;
70
+ this.serializeAddon = serialize;
71
+ }
72
+ /**
73
+ * Create a new TerminalBuffer. Returns null if xterm is not installed.
74
+ */
75
+ static create(config) {
76
+ if (!ensureXterm())
77
+ return null;
78
+ const cols = config?.cols ?? (process.stdout.columns || 80);
79
+ const rows = config?.rows ?? (process.stdout.rows || 24);
80
+ const scrollback = config?.scrollback ?? 200;
81
+ const term = new TerminalCtor({ cols, rows, allowProposedApi: true, scrollback });
82
+ const serialize = new SerializeAddonCtor();
83
+ term.loadAddon(serialize);
84
+ return new TerminalBuffer(term, serialize);
85
+ }
86
+ /**
87
+ * Create a TerminalBuffer and wire it to a bus's shell:pty-data event.
88
+ * Returns null if xterm is not installed.
89
+ */
90
+ static createWired(bus, config) {
91
+ const tb = TerminalBuffer.create(config);
92
+ if (!tb)
93
+ return null;
94
+ // Buffer PTY data and drip-feed to xterm in the background.
95
+ // Synchronous term.write() in the pty-data handler introduces enough
96
+ // latency to change PTY read coalescing, causing visual artifacts.
97
+ let pending = "";
98
+ bus.on("shell:pty-data", ({ raw }) => { pending += raw; });
99
+ setInterval(() => {
100
+ if (pending) {
101
+ const d = pending;
102
+ pending = "";
103
+ tb.write(d);
104
+ }
105
+ }, 50);
106
+ process.stdout.on("resize", () => {
107
+ tb.resize(process.stdout.columns || 80, process.stdout.rows || 24);
108
+ });
109
+ return tb;
110
+ }
111
+ /** Write raw data into the virtual terminal. */
112
+ write(data) {
113
+ this.term.write(data);
114
+ }
115
+ /** Get the raw serialized terminal output (includes ANSI sequences). */
116
+ serialize() {
117
+ return this.serializeAddon.serialize();
118
+ }
119
+ /** Read clean screen text with metadata. */
120
+ readScreen() {
121
+ const buf = this.term.buffer.active;
122
+ const lines = this.readViewportLines(buf);
123
+ return {
124
+ text: lines.join("\n"),
125
+ altScreen: buf.type === "alternate",
126
+ cursorX: buf.cursorX,
127
+ cursorY: buf.cursorY,
128
+ };
129
+ }
130
+ /**
131
+ * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
132
+ * Clean text only (ANSI stripped). Reads from the active buffer's
133
+ * viewport (not scrollback), so it works correctly on both the normal
134
+ * and alternate screen buffers.
135
+ */
136
+ getScreenLines(rows) {
137
+ const targetRows = rows ?? (process.stdout.rows || 24);
138
+ return this.readViewportLines(this.term.buffer.active, targetRows);
139
+ }
140
+ /** Read visible viewport lines from a buffer. */
141
+ readViewportLines(buf, rows) {
142
+ const targetRows = rows ?? buf.length;
143
+ const base = buf.baseY ?? 0;
144
+ const lines = [];
145
+ for (let y = 0; y < targetRows; y++) {
146
+ const line = buf.getLine(base + y);
147
+ lines.push(line ? line.translateToString(true) : "");
148
+ }
149
+ return lines;
150
+ }
151
+ /** Get cursor position. */
152
+ getCursor() {
153
+ return {
154
+ x: this.term.buffer.active.cursorX,
155
+ y: this.term.buffer.active.cursorY,
156
+ };
157
+ }
158
+ /** Resize the virtual terminal. */
159
+ resize(cols, rows) {
160
+ this.term.resize(cols, rows);
161
+ }
162
+ /** Whether the alternate screen buffer is active. */
163
+ get altScreen() {
164
+ return this.term.buffer.active.type === "alternate";
165
+ }
166
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Overlay agent extension.
3
+ *
4
+ * Provides a hotkey (Ctrl+\) to summon the agent from anywhere — even
5
+ * inside vim, htop, or ssh. Composites a floating response box on top
6
+ * of the current terminal content.
7
+ *
8
+ * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
9
+ *
10
+ * Usage:
11
+ * agent-sh -e ./examples/extensions/overlay-agent.ts
12
+ *
13
+ * # Or copy to ~/.agent-sh/extensions/ for permanent use:
14
+ * cp examples/extensions/overlay-agent.ts ~/.agent-sh/extensions/
15
+ */
16
+ import type { ExtensionContext } from "agent-sh/types";
17
+ import { formatScreenContext } from "agent-sh/utils/terminal-buffer.js";
18
+
19
+ const BOLD = "\x1b[1m";
20
+ const CYAN = "\x1b[36m";
21
+ const RESET = "\x1b[0m";
22
+
23
+ export default function activate({ bus, advise, createFloatingPanel, terminalBuffer }: ExtensionContext): void {
24
+ const panel = createFloatingPanel({
25
+ trigger: "\x1c", // Ctrl+\
26
+ dimBackground: true,
27
+ autoDismissMs: 2000,
28
+ });
29
+
30
+ // ── Inject terminal buffer into agent context ──────────────
31
+ if (terminalBuffer) {
32
+ advise("context:build-extra", (next: () => string) =>
33
+ formatScreenContext(terminalBuffer.readScreen(), 80, next()),
34
+ );
35
+ }
36
+
37
+ // ── Panel lifecycle ────────────────────────────────────────
38
+ panel.handlers.advise("panel:submit", (_next, query: string) => {
39
+ panel.setActive();
40
+ panel.appendLine(`${CYAN}${BOLD}❯${RESET} ${query}`);
41
+ panel.appendLine("");
42
+ bus.emit("agent:submit", { query });
43
+ });
44
+
45
+ // ── Stream agent response into panel ───────────────────────
46
+ bus.on("agent:response-chunk", (e) => {
47
+ if (!panel.active) return;
48
+ for (const block of e.blocks) {
49
+ if (block.type === "text" && block.text) {
50
+ panel.appendText(block.text);
51
+ }
52
+ }
53
+ });
54
+
55
+ bus.on("agent:tool-started", (e) => {
56
+ if (!panel.active) return;
57
+ panel.appendLine(`▶ ${e.title}${e.displayDetail ? " " + e.displayDetail : ""}`);
58
+ });
59
+
60
+ bus.on("agent:tool-completed", (e) => {
61
+ if (!panel.active) return;
62
+ const mark = e.exitCode === 0 ? " ✓" : ` ✗ exit ${e.exitCode}`;
63
+ panel.updateLastLine((line) => line + mark);
64
+ });
65
+
66
+ bus.on("agent:processing-done", () => {
67
+ if (!panel.active) return;
68
+ panel.setDone();
69
+ });
70
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Terminal buffer extension.
3
+ *
4
+ * Maintains a headless xterm.js terminal fed from raw PTY data.
5
+ * Provides an accurate, clean-text snapshot of the terminal screen
6
+ * that the agent can use for context — handling ANSI codes, cursor
7
+ * movement, alternate screen (vim/htop), and line wrapping correctly.
8
+ *
9
+ * Registers two agent tools:
10
+ * - terminal_read: get the current screen contents + cursor position
11
+ * - terminal_keys: send raw keystrokes into the user's live PTY
12
+ *
13
+ * Together these let the agent operate inside interactive programs
14
+ * (vim, htop, less, etc.) by reading the screen and typing keys.
15
+ *
16
+ * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
17
+ *
18
+ * Usage:
19
+ * agent-sh -e ./examples/extensions/terminal-buffer.ts
20
+ *
21
+ * # Or copy to ~/.agent-sh/extensions/ for permanent use:
22
+ * cp examples/extensions/terminal-buffer.ts ~/.agent-sh/extensions/
23
+ */
24
+ import type { ExtensionContext } from "agent-sh/types";
25
+
26
+ /** Wait for PTY output to settle after sending keystrokes. */
27
+ function settle(ms = 100): Promise<void> {
28
+ return new Promise((resolve) => setTimeout(resolve, ms));
29
+ }
30
+
31
+ /** Interpret C-style escape sequences in a string (e.g. \r → CR, \x1b → ESC). */
32
+ function interpretEscapes(str: string): string {
33
+ return str.replace(/\\(x[0-9a-fA-F]{2}|r|n|t|\\|0)/g, (_, seq: string) => {
34
+ if (seq === "r") return "\r";
35
+ if (seq === "n") return "\n";
36
+ if (seq === "t") return "\t";
37
+ if (seq === "\\") return "\\";
38
+ if (seq === "0") return "\0";
39
+ if (seq.startsWith("x")) return String.fromCharCode(parseInt(seq.slice(1), 16));
40
+ return seq;
41
+ });
42
+ }
43
+
44
+ export default function activate({ bus, terminalBuffer: tb, registerTool }: ExtensionContext): void {
45
+ if (!tb) {
46
+ console.warn("terminal-buffer: @xterm/headless not installed — extension disabled");
47
+ return;
48
+ }
49
+
50
+ // ── Agent tools ─────────────────────────────────────────────
51
+ // Context injection is intentionally NOT done here — the terminal
52
+ // buffer content would bloat every agent message. The agent can
53
+ // call terminal_read on demand, and the overlay extension injects
54
+ // context only when the overlay is active.
55
+
56
+ registerTool({
57
+ name: "terminal_read",
58
+ description:
59
+ "Read the current terminal screen contents. Returns clean text (ANSI stripped) " +
60
+ "with cursor position and whether an alternate-screen program (vim, htop, less) is active. " +
61
+ "Use this to see what the user sees before sending keystrokes with terminal_keys.",
62
+ input_schema: {
63
+ type: "object",
64
+ properties: {},
65
+ },
66
+ showOutput: true,
67
+
68
+ getDisplayInfo: () => ({
69
+ kind: "read" as const,
70
+ icon: "⊞",
71
+ locations: [],
72
+ }),
73
+
74
+ async execute() {
75
+ const { text, altScreen, cursorX, cursorY } = tb.readScreen();
76
+ const info = [
77
+ altScreen ? "mode: alternate screen" : "mode: normal",
78
+ `cursor: row=${cursorY} col=${cursorX}`,
79
+ ].join(", ");
80
+
81
+ return {
82
+ content: `[${info}]\n\n${text}`,
83
+ exitCode: 0,
84
+ isError: false,
85
+ };
86
+ },
87
+ });
88
+
89
+ registerTool({
90
+ name: "terminal_keys",
91
+ description:
92
+ "Send keystrokes to the user's live terminal. The keys are written directly to the PTY " +
93
+ "as if the user typed them. Use escape sequences for special keys:\n" +
94
+ " - Escape: \\x1b\n" +
95
+ " - Enter/Return: \\r\n" +
96
+ " - Tab: \\t\n" +
97
+ " - Ctrl+C: \\x03\n" +
98
+ " - Ctrl+D: \\x04\n" +
99
+ " - Ctrl+Z: \\x1a\n" +
100
+ " - Arrow keys: \\x1b[A (up), \\x1b[B (down), \\x1b[C (right), \\x1b[D (left)\n" +
101
+ " - Backspace: \\x7f\n\n" +
102
+ "Example: to quit vim without saving, send keys=\"\\x1b:q!\\r\" (Escape, :q!, Enter).\n" +
103
+ "Always call terminal_read after sending keys to verify the result.",
104
+ input_schema: {
105
+ type: "object",
106
+ properties: {
107
+ keys: {
108
+ type: "string",
109
+ description:
110
+ "The keystrokes to send. Use \\x1b for Escape, \\r for Enter, \\t for Tab, " +
111
+ "\\x03 for Ctrl+C, etc. Regular characters are sent as-is.",
112
+ },
113
+ settle_ms: {
114
+ type: "number",
115
+ description:
116
+ "Milliseconds to wait after sending keys for the terminal to settle before " +
117
+ "returning (default: 150). Increase for slow programs.",
118
+ },
119
+ },
120
+ required: ["keys"],
121
+ },
122
+ showOutput: false,
123
+
124
+ getDisplayInfo: (args) => ({
125
+ kind: "execute" as const,
126
+ icon: "⌨",
127
+ locations: [],
128
+ }),
129
+
130
+ formatCall: (args) => {
131
+ const keys = args.keys as string;
132
+ // Show a readable version of the keys — handle both literal
133
+ // escape strings (\\x1b) and actual bytes (\x1b)
134
+ return keys
135
+ .replace(/\\x1b|\x1b/g, "ESC")
136
+ .replace(/\\r|\r/g, "⏎")
137
+ .replace(/\\n|\n/g, "↵")
138
+ .replace(/\\t|\t/g, "TAB")
139
+ .replace(/\\x03|\x03/g, "^C")
140
+ .replace(/\\x04|\x04/g, "^D")
141
+ .replace(/\\x7f|\x7f/g, "BS");
142
+ },
143
+
144
+ async execute(args) {
145
+ const raw = args.keys as string;
146
+ const keys = interpretEscapes(raw);
147
+ const settleMs = (args.settle_ms as number) ?? 150;
148
+
149
+ // Force PTY output visible so the user sees the program's response.
150
+ // Stays visible for the rest of agent processing — Shell resets
151
+ // paused=false on processing-done anyway.
152
+ bus.emit("shell:stdout-show", {});
153
+ process.stdout.write("\n");
154
+ bus.emit("shell:pty-write", { data: keys });
155
+
156
+ // Wait for the terminal to process the keystrokes and render
157
+ await settle(settleMs);
158
+
159
+ // Return the screen state after the keystrokes
160
+ const { text, altScreen, cursorX, cursorY } = tb.readScreen();
161
+ const info = [
162
+ altScreen ? "mode: alternate screen" : "mode: normal",
163
+ `cursor: row=${cursorY} col=${cursorX}`,
164
+ ].join(", ");
165
+
166
+ return {
167
+ content: `Keys sent. Screen after:\n[${info}]\n\n${text}`,
168
+ exitCode: 0,
169
+ isError: false,
170
+ };
171
+ },
172
+ });
173
+
174
+ // ── Bus snapshot for other extensions ───────────────────────
175
+
176
+ bus.on("shell:buffer-request", () => {
177
+ const { text, altScreen, cursorX, cursorY } = tb.readScreen();
178
+ bus.emit("shell:buffer-snapshot", {
179
+ text,
180
+ altScreen,
181
+ cursor: { x: cursorX, y: cursorY },
182
+ });
183
+ });
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -29,6 +29,10 @@
29
29
  "./utils/stream-transform": {
30
30
  "types": "./dist/utils/stream-transform.d.ts",
31
31
  "default": "./dist/utils/stream-transform.js"
32
+ },
33
+ "./utils/terminal-buffer": {
34
+ "types": "./dist/utils/terminal-buffer.d.ts",
35
+ "default": "./dist/utils/terminal-buffer.js"
32
36
  }
33
37
  },
34
38
  "files": [