agent-sh 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +5 -1
  2. package/dist/agent/agent-loop.d.ts +2 -2
  3. package/dist/agent/agent-loop.js +106 -13
  4. package/dist/agent/conversation-state.d.ts +39 -9
  5. package/dist/agent/conversation-state.js +336 -17
  6. package/dist/agent/history-file.d.ts +36 -0
  7. package/dist/agent/history-file.js +167 -0
  8. package/dist/agent/nuclear-form.d.ts +41 -0
  9. package/dist/agent/nuclear-form.js +175 -0
  10. package/dist/agent/system-prompt.d.ts +2 -2
  11. package/dist/agent/system-prompt.js +25 -4
  12. package/dist/agent/tools/user-shell.js +4 -1
  13. package/dist/context-manager.d.ts +3 -2
  14. package/dist/context-manager.js +16 -111
  15. package/dist/core.js +30 -1
  16. package/dist/event-bus.d.ts +37 -0
  17. package/dist/extensions/overlay-agent.d.ts +14 -0
  18. package/dist/extensions/overlay-agent.js +147 -0
  19. package/dist/extensions/slash-commands.js +28 -0
  20. package/dist/extensions/terminal-buffer.d.ts +14 -0
  21. package/dist/extensions/terminal-buffer.js +125 -0
  22. package/dist/extensions/tui-renderer.js +122 -84
  23. package/dist/index.js +4 -0
  24. package/dist/input-handler.js +6 -1
  25. package/dist/output-parser.js +8 -0
  26. package/dist/settings.d.ts +19 -2
  27. package/dist/settings.js +21 -3
  28. package/dist/shell.d.ts +5 -0
  29. package/dist/shell.js +31 -2
  30. package/dist/token-budget.d.ts +13 -0
  31. package/dist/token-budget.js +50 -0
  32. package/dist/types.d.ts +13 -22
  33. package/dist/utils/ansi.d.ts +10 -0
  34. package/dist/utils/ansi.js +27 -0
  35. package/dist/utils/floating-panel.d.ts +227 -0
  36. package/dist/utils/floating-panel.js +807 -0
  37. package/dist/utils/line-editor.d.ts +9 -0
  38. package/dist/utils/line-editor.js +44 -0
  39. package/dist/utils/markdown.js +3 -3
  40. package/dist/utils/output-writer.d.ts +14 -0
  41. package/dist/utils/output-writer.js +16 -0
  42. package/dist/utils/terminal-buffer.d.ts +69 -0
  43. package/dist/utils/terminal-buffer.js +179 -0
  44. package/dist/utils/tool-display.d.ts +1 -0
  45. package/dist/utils/tool-display.js +1 -1
  46. package/examples/extensions/claude-code-bridge/index.ts +77 -1
  47. package/examples/extensions/overlay-agent.ts +70 -0
  48. package/examples/extensions/pi-bridge/index.ts +87 -2
  49. package/examples/extensions/terminal-buffer.ts +184 -0
  50. package/package.json +5 -1
@@ -27,6 +27,9 @@ export declare class LineEditor {
27
27
  buffer: string;
28
28
  cursor: number;
29
29
  private pendingSeq;
30
+ private history;
31
+ private historyIndex;
32
+ private savedBuffer;
30
33
  /** Process raw terminal input, return actions for the consumer. */
31
34
  feed(data: string): LineEditAction[];
32
35
  /** Check if there's a pending incomplete escape sequence. */
@@ -34,6 +37,12 @@ export declare class LineEditor {
34
37
  /** Flush a pending sequence — treat bare \x1b as cancel, discard incomplete CSI. */
35
38
  flushPendingEscape(): LineEditAction[];
36
39
  clear(): void;
40
+ /** Add a line to history (most recent first). */
41
+ pushHistory(line: string): void;
42
+ /** Navigate to a previous history entry. Returns changed action or null. */
43
+ historyBack(): LineEditAction | null;
44
+ /** Navigate to a more recent history entry. Returns changed action or null. */
45
+ historyForward(): LineEditAction | null;
37
46
  private readonly bindings;
38
47
  /** Resolve a key name from the bindings table and execute it. */
39
48
  private dispatch;
@@ -14,6 +14,10 @@ export class LineEditor {
14
14
  buffer = "";
15
15
  cursor = 0;
16
16
  pendingSeq = ""; // buffered incomplete escape sequence
17
+ // ── History ──────────────────────────────────────────────────
18
+ history = [];
19
+ historyIndex = -1; // -1 = current input, 0..N = history entries (newest first)
20
+ savedBuffer = ""; // saves current input when browsing history
17
21
  /** Process raw terminal input, return actions for the consumer. */
18
22
  feed(data) {
19
23
  // If we had a pending incomplete escape sequence, prepend it
@@ -147,6 +151,46 @@ export class LineEditor {
147
151
  this.buffer = "";
148
152
  this.cursor = 0;
149
153
  this.pendingSeq = "";
154
+ this.historyIndex = -1;
155
+ this.savedBuffer = "";
156
+ }
157
+ /** Add a line to history (most recent first). */
158
+ pushHistory(line) {
159
+ if (!line.trim())
160
+ return;
161
+ // Deduplicate: remove if already at top
162
+ if (this.history.length > 0 && this.history[0] === line)
163
+ return;
164
+ this.history.unshift(line);
165
+ // Cap history size
166
+ if (this.history.length > 100)
167
+ this.history.pop();
168
+ }
169
+ /** Navigate to a previous history entry. Returns changed action or null. */
170
+ historyBack() {
171
+ if (this.historyIndex + 1 >= this.history.length)
172
+ return null;
173
+ if (this.historyIndex === -1) {
174
+ this.savedBuffer = this.buffer; // save current input
175
+ }
176
+ this.historyIndex++;
177
+ this.buffer = this.history[this.historyIndex];
178
+ this.cursor = this.buffer.length;
179
+ return { action: "changed" };
180
+ }
181
+ /** Navigate to a more recent history entry. Returns changed action or null. */
182
+ historyForward() {
183
+ if (this.historyIndex <= -1)
184
+ return null;
185
+ this.historyIndex--;
186
+ if (this.historyIndex === -1) {
187
+ this.buffer = this.savedBuffer;
188
+ }
189
+ else {
190
+ this.buffer = this.history[this.historyIndex];
191
+ }
192
+ this.cursor = this.buffer.length;
193
+ return { action: "changed" };
150
194
  }
151
195
  // ── Key bindings ────────────────────────────────────────────
152
196
  //
@@ -1,4 +1,4 @@
1
- import { visibleLen } from "./ansi.js";
1
+ import { visibleLen, truncateToWidth, padEndToWidth } from "./ansi.js";
2
2
  import { palette as p } from "./palette.js";
3
3
  const MAX_CONTENT_WIDTH = 90;
4
4
  /**
@@ -177,7 +177,7 @@ export class MarkdownRenderer {
177
177
  const colWidths = new Array(numCols).fill(0);
178
178
  for (const row of dataRows) {
179
179
  for (let c = 0; c < numCols; c++) {
180
- colWidths[c] = Math.max(colWidths[c], row[c].length);
180
+ colWidths[c] = Math.max(colWidths[c], visibleLen(row[c]));
181
181
  }
182
182
  }
183
183
  // Shrink columns proportionally if total exceeds content width
@@ -201,7 +201,7 @@ export class MarkdownRenderer {
201
201
  const isHeader = hasHeader && i === 0;
202
202
  const cells = row.map((cell, c) => {
203
203
  const w = colWidths[c];
204
- const text = cell.length > w ? cell.slice(0, w - 1) + "…" : cell.padEnd(w);
204
+ const text = visibleLen(cell) > w ? truncateToWidth(cell, w) : padEndToWidth(cell, w);
205
205
  return isHeader ? `${p.bold}${text}${p.reset}` : text;
206
206
  });
207
207
  this.writeLine(`${p.dim}│${p.reset} ${cells.join(` ${p.dim}│${p.reset} `)} ${p.dim}│${p.reset}`);
@@ -5,12 +5,26 @@
5
5
  * process.stdout.write directly. This enables testing (BufferWriter),
6
6
  * alternative frontends, and a single point of control for output.
7
7
  */
8
+ /** Simple ref-counted counter. Increment/decrement never goes below zero. */
9
+ export declare class RefCounter {
10
+ private count;
11
+ increment(): void;
12
+ decrement(): void;
13
+ reset(): void;
14
+ get active(): boolean;
15
+ get value(): number;
16
+ }
8
17
  export interface OutputWriter {
9
18
  write(text: string): void;
10
19
  get columns(): number;
11
20
  }
12
21
  /** Default writer that forwards to process.stdout. */
13
22
  export declare class StdoutWriter implements OutputWriter {
23
+ /** When > 0, all writes are silently dropped. Ref-counted. */
24
+ private readonly _hold;
25
+ hold(): void;
26
+ release(): void;
27
+ get held(): boolean;
14
28
  write(text: string): void;
15
29
  get columns(): number;
16
30
  }
@@ -5,9 +5,25 @@
5
5
  * process.stdout.write directly. This enables testing (BufferWriter),
6
6
  * alternative frontends, and a single point of control for output.
7
7
  */
8
+ /** Simple ref-counted counter. Increment/decrement never goes below zero. */
9
+ export class RefCounter {
10
+ count = 0;
11
+ increment() { this.count++; }
12
+ decrement() { this.count = Math.max(0, this.count - 1); }
13
+ reset() { this.count = 0; }
14
+ get active() { return this.count > 0; }
15
+ get value() { return this.count; }
16
+ }
8
17
  /** Default writer that forwards to process.stdout. */
9
18
  export class StdoutWriter {
19
+ /** When > 0, all writes are silently dropped. Ref-counted. */
20
+ _hold = new RefCounter();
21
+ hold() { this._hold.increment(); }
22
+ release() { this._hold.decrement(); }
23
+ get held() { return this._hold.active; }
10
24
  write(text) {
25
+ if (this._hold.active)
26
+ return;
11
27
  if (process.stdout.writable) {
12
28
  try {
13
29
  process.stdout.write(text);
@@ -0,0 +1,69 @@
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
+ /** Flush pending drip-feed data (set by createWired). */
32
+ _flushPending: (() => void) | null;
33
+ private constructor();
34
+ /**
35
+ * Create a new TerminalBuffer. Returns null if xterm is not installed.
36
+ */
37
+ static create(config?: TerminalBufferConfig): TerminalBuffer | null;
38
+ /**
39
+ * Create a TerminalBuffer and wire it to a bus's shell:pty-data event.
40
+ * Returns null if xterm is not installed.
41
+ */
42
+ static createWired(bus: EventBus, config?: TerminalBufferConfig): TerminalBuffer | null;
43
+ /** Flush any pending drip-feed data into the virtual terminal. */
44
+ flush(): void;
45
+ /** Write raw data into the virtual terminal. */
46
+ write(data: string): void;
47
+ /** Get the raw serialized terminal output (includes ANSI sequences). */
48
+ serialize(): string;
49
+ /** Read clean screen text with metadata. */
50
+ readScreen(): ScreenSnapshot;
51
+ /**
52
+ * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
53
+ * Clean text only (ANSI stripped). Reads from the active buffer's
54
+ * viewport (not scrollback), so it works correctly on both the normal
55
+ * and alternate screen buffers.
56
+ */
57
+ getScreenLines(rows?: number): string[];
58
+ /** Read visible viewport lines from a buffer. */
59
+ private readViewportLines;
60
+ /** Get cursor position. */
61
+ getCursor(): {
62
+ x: number;
63
+ y: number;
64
+ };
65
+ /** Resize the virtual terminal. */
66
+ resize(cols: number, rows: number): void;
67
+ /** Whether the alternate screen buffer is active. */
68
+ get altScreen(): boolean;
69
+ }
@@ -0,0 +1,179 @@
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
+ /** Flush pending drip-feed data (set by createWired). */
69
+ _flushPending = null;
70
+ constructor(term, serialize) {
71
+ this.term = term;
72
+ this.serializeAddon = serialize;
73
+ }
74
+ /**
75
+ * Create a new TerminalBuffer. Returns null if xterm is not installed.
76
+ */
77
+ static create(config) {
78
+ if (!ensureXterm())
79
+ return null;
80
+ const cols = config?.cols ?? (process.stdout.columns || 80);
81
+ const rows = config?.rows ?? (process.stdout.rows || 24);
82
+ const scrollback = config?.scrollback ?? 200;
83
+ const term = new TerminalCtor({ cols, rows, allowProposedApi: true, scrollback });
84
+ const serialize = new SerializeAddonCtor();
85
+ term.loadAddon(serialize);
86
+ return new TerminalBuffer(term, serialize);
87
+ }
88
+ /**
89
+ * Create a TerminalBuffer and wire it to a bus's shell:pty-data event.
90
+ * Returns null if xterm is not installed.
91
+ */
92
+ static createWired(bus, config) {
93
+ const tb = TerminalBuffer.create(config);
94
+ if (!tb)
95
+ return null;
96
+ // Buffer PTY data and drip-feed to xterm in the background.
97
+ // Synchronous term.write() in the pty-data handler introduces enough
98
+ // latency to change PTY read coalescing, causing visual artifacts.
99
+ let pending = "";
100
+ bus.on("shell:pty-data", ({ raw }) => { pending += raw; });
101
+ setInterval(() => {
102
+ if (pending) {
103
+ const d = pending;
104
+ pending = "";
105
+ tb.write(d);
106
+ }
107
+ }, 50);
108
+ tb._flushPending = () => {
109
+ if (pending) {
110
+ const d = pending;
111
+ pending = "";
112
+ tb.write(d);
113
+ }
114
+ };
115
+ process.stdout.on("resize", () => {
116
+ tb.resize(process.stdout.columns || 80, process.stdout.rows || 24);
117
+ });
118
+ return tb;
119
+ }
120
+ /** Flush any pending drip-feed data into the virtual terminal. */
121
+ flush() {
122
+ this._flushPending?.();
123
+ }
124
+ /** Write raw data into the virtual terminal. */
125
+ write(data) {
126
+ this.term.write(data);
127
+ }
128
+ /** Get the raw serialized terminal output (includes ANSI sequences). */
129
+ serialize() {
130
+ return this.serializeAddon.serialize();
131
+ }
132
+ /** Read clean screen text with metadata. */
133
+ readScreen() {
134
+ const buf = this.term.buffer.active;
135
+ const lines = this.readViewportLines(buf);
136
+ return {
137
+ text: lines.join("\n"),
138
+ altScreen: buf.type === "alternate",
139
+ cursorX: buf.cursorX,
140
+ cursorY: buf.cursorY,
141
+ };
142
+ }
143
+ /**
144
+ * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
145
+ * Clean text only (ANSI stripped). Reads from the active buffer's
146
+ * viewport (not scrollback), so it works correctly on both the normal
147
+ * and alternate screen buffers.
148
+ */
149
+ getScreenLines(rows) {
150
+ const targetRows = rows ?? (process.stdout.rows || 24);
151
+ return this.readViewportLines(this.term.buffer.active, targetRows);
152
+ }
153
+ /** Read visible viewport lines from a buffer. */
154
+ readViewportLines(buf, rows) {
155
+ const targetRows = rows ?? buf.length;
156
+ const base = buf.baseY ?? 0;
157
+ const lines = [];
158
+ for (let y = 0; y < targetRows; y++) {
159
+ const line = buf.getLine(base + y);
160
+ lines.push(line ? line.translateToString(true) : "");
161
+ }
162
+ return lines;
163
+ }
164
+ /** Get cursor position. */
165
+ getCursor() {
166
+ return {
167
+ x: this.term.buffer.active.cursorX,
168
+ y: this.term.buffer.active.cursorY,
169
+ };
170
+ }
171
+ /** Resize the virtual terminal. */
172
+ resize(cols, rows) {
173
+ this.term.resize(cols, rows);
174
+ }
175
+ /** Whether the alternate screen buffer is active. */
176
+ get altScreen() {
177
+ return this.term.buffer.active.type === "alternate";
178
+ }
179
+ }
@@ -30,6 +30,7 @@ export declare function selectToolDisplayMode(width: number): ToolDisplayMode;
30
30
  export declare function renderToolCall(tool: ToolCallRender, width: number): string[];
31
31
  export declare function renderToolResult(result: ToolResultRender, width: number): string[];
32
32
  export declare function formatElapsed(ms: number): string;
33
+ export declare const SPINNER_FRAMES: string[];
33
34
  export interface SpinnerState {
34
35
  frame: number;
35
36
  startTime: number;
@@ -205,7 +205,7 @@ export function formatElapsed(ms) {
205
205
  return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
206
206
  }
207
207
  // ── Spinner with elapsed timer ───────────────────────────────────
208
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
208
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
209
209
  export function createSpinner(opts) {
210
210
  return { frame: 0, startTime: opts?.startTime || Date.now() };
211
211
  }
@@ -23,6 +23,23 @@ import { z } from "zod";
23
23
  import type { ExtensionContext } from "../../src/types.js";
24
24
  import type { EventBus } from "../../src/event-bus.js";
25
25
 
26
+ // ── Helpers ──────────────────────────────────────────────────────
27
+ function interpretEscapes(str: string): string {
28
+ return str.replace(/\\(x[0-9a-fA-F]{2}|r|n|t|\\|0)/g, (_, seq: string) => {
29
+ if (seq === "r") return "\r";
30
+ if (seq === "n") return "\n";
31
+ if (seq === "t") return "\t";
32
+ if (seq === "\\") return "\\";
33
+ if (seq === "0") return "\0";
34
+ if (seq.startsWith("x")) return String.fromCharCode(parseInt(seq.slice(1), 16));
35
+ return seq;
36
+ });
37
+ }
38
+
39
+ function settle(ms = 100): Promise<void> {
40
+ return new Promise((resolve) => setTimeout(resolve, ms));
41
+ }
42
+
26
43
  // ── user_shell MCP tool ───────────────────────────────────────────
27
44
  function createUserShellTool(bus: EventBus) {
28
45
  let liveCwd = process.cwd();
@@ -56,15 +73,72 @@ function createUserShellTool(bus: EventBus) {
56
73
  );
57
74
  }
58
75
 
76
+ // ── terminal_read MCP tool ────────────────────────────────────────
77
+ function createTerminalReadTool(ctx: ExtensionContext) {
78
+ return tool(
79
+ "terminal_read",
80
+ "Read the current terminal screen contents. Returns clean text (ANSI stripped) " +
81
+ "with cursor position and whether an alternate-screen program (vim, htop, less) is active. " +
82
+ "Use this to see what the user sees before sending keystrokes with terminal_keys.",
83
+ {},
84
+ async () => {
85
+ const tb = ctx.terminalBuffer;
86
+ if (!tb) return { content: [{ type: "text" as const, text: "terminal buffer not available" }] };
87
+ const { text, altScreen, cursorX, cursorY } = tb.readScreen();
88
+ const info = [
89
+ altScreen ? "mode: alternate screen" : "mode: normal",
90
+ `cursor: row=${cursorY} col=${cursorX}`,
91
+ ].join(", ");
92
+ return { content: [{ type: "text" as const, text: `[${info}]\n\n${text}` }] };
93
+ },
94
+ );
95
+ }
96
+
97
+ // ── terminal_keys MCP tool ───────────────────────────────────────
98
+ function createTerminalKeysTool(bus: EventBus, ctx: ExtensionContext) {
99
+ return tool(
100
+ "terminal_keys",
101
+ "Send keystrokes to the user's live terminal. The keys are written directly to the PTY " +
102
+ "as if the user typed them. Use escape sequences for special keys:\n" +
103
+ " - Escape: \\x1b - Enter: \\r - Tab: \\t\n" +
104
+ " - Ctrl+C: \\x03 - Arrow keys: \\x1b[A/B/C/D - Backspace: \\x7f\n" +
105
+ "Example: to quit vim without saving, send keys=\"\\x1b:q!\\r\".\n" +
106
+ "Always call terminal_read after sending keys to verify the result.",
107
+ {
108
+ keys: z.string().describe("Keystrokes to send (use \\x1b for Escape, \\r for Enter, etc.)"),
109
+ settle_ms: z.number().optional().describe("Wait time in ms after sending keys (default: 150)"),
110
+ },
111
+ async (args) => {
112
+ const keys = interpretEscapes(args.keys);
113
+ const settleMs = args.settle_ms ?? 150;
114
+ bus.emit("shell:stdout-show", {});
115
+ process.stdout.write("\n");
116
+ bus.emit("shell:pty-write", { data: keys });
117
+ await settle(settleMs);
118
+
119
+ const tb = ctx.terminalBuffer;
120
+ if (!tb) return { content: [{ type: "text" as const, text: "Keys sent." }] };
121
+ const { text, altScreen, cursorX, cursorY } = tb.readScreen();
122
+ const info = [
123
+ altScreen ? "mode: alternate screen" : "mode: normal",
124
+ `cursor: row=${cursorY} col=${cursorX}`,
125
+ ].join(", ");
126
+ return { content: [{ type: "text" as const, text: `Keys sent. Screen after:\n[${info}]\n\n${text}` }] };
127
+ },
128
+ );
129
+ }
130
+
59
131
  // ── Extension entry point ─────────────────────────────────────────
60
132
  export default function activate(ctx: ExtensionContext): void {
61
133
  const { bus } = ctx;
62
134
 
63
135
  const shellTool = createUserShellTool(bus);
136
+ const termReadTool = createTerminalReadTool(ctx);
137
+ const termKeysTool = createTerminalKeysTool(bus, ctx);
64
138
  const shellServer = createSdkMcpServer({
65
139
  name: "agent-sh",
66
140
  version: "1.0.0",
67
- tools: [shellTool],
141
+ tools: [shellTool, termReadTool, termKeysTool],
68
142
  });
69
143
 
70
144
  let activeQuery: Query | null = null;
@@ -95,6 +169,8 @@ export default function activate(ctx: ExtensionContext): void {
95
169
  mcpServers: { "agent-sh": shellServer },
96
170
  allowedTools: [
97
171
  "mcp__agent-sh__user_shell",
172
+ "mcp__agent-sh__terminal_read",
173
+ "mcp__agent-sh__terminal_keys",
98
174
  "Read", "Edit", "Write", "Bash", "Glob", "Grep",
99
175
  ],
100
176
  permissionMode: "acceptEdits",
@@ -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
+ }