agent-sh 0.7.0 → 0.9.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 (86) hide show
  1. package/README.md +28 -33
  2. package/dist/agent/agent-loop.d.ts +31 -8
  3. package/dist/agent/agent-loop.js +277 -66
  4. package/dist/agent/conversation-state.d.ts +41 -9
  5. package/dist/agent/conversation-state.js +340 -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 +176 -0
  10. package/dist/agent/system-prompt.d.ts +4 -5
  11. package/dist/agent/system-prompt.js +16 -11
  12. package/dist/agent/token-budget.d.ts +13 -0
  13. package/dist/agent/token-budget.js +50 -0
  14. package/dist/agent/tool-protocol.d.ts +83 -0
  15. package/dist/agent/tool-protocol.js +386 -0
  16. package/dist/agent/tools/user-shell.js +4 -1
  17. package/dist/agent/types.d.ts +21 -1
  18. package/dist/context-manager.d.ts +0 -1
  19. package/dist/context-manager.js +5 -110
  20. package/dist/core.d.ts +7 -7
  21. package/dist/core.js +76 -180
  22. package/dist/event-bus.d.ts +40 -0
  23. package/dist/event-bus.js +20 -1
  24. package/dist/extension-loader.d.ts +5 -0
  25. package/dist/extension-loader.js +104 -17
  26. package/dist/extensions/agent-backend.d.ts +13 -0
  27. package/dist/extensions/agent-backend.js +167 -0
  28. package/dist/extensions/command-suggest.d.ts +3 -3
  29. package/dist/extensions/command-suggest.js +4 -3
  30. package/dist/extensions/index.d.ts +19 -0
  31. package/dist/extensions/index.js +25 -0
  32. package/dist/extensions/slash-commands.d.ts +1 -1
  33. package/dist/extensions/slash-commands.js +44 -1
  34. package/dist/extensions/terminal-buffer.d.ts +1 -1
  35. package/dist/extensions/terminal-buffer.js +22 -8
  36. package/dist/extensions/tui-renderer.js +177 -122
  37. package/dist/index.js +14 -20
  38. package/dist/settings.d.ts +25 -2
  39. package/dist/settings.js +25 -4
  40. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  41. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  42. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  43. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  44. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  45. package/dist/{shell.js → shell/shell.js} +24 -6
  46. package/dist/types.d.ts +49 -32
  47. package/dist/utils/ansi.d.ts +10 -0
  48. package/dist/utils/ansi.js +27 -0
  49. package/dist/utils/compositor.d.ts +62 -0
  50. package/dist/utils/compositor.js +88 -0
  51. package/dist/utils/diff-renderer.js +92 -4
  52. package/dist/utils/floating-panel.d.ts +34 -3
  53. package/dist/utils/floating-panel.js +315 -82
  54. package/dist/utils/handler-registry.d.ts +26 -10
  55. package/dist/utils/handler-registry.js +52 -16
  56. package/dist/utils/line-editor.d.ts +32 -3
  57. package/dist/utils/line-editor.js +218 -36
  58. package/dist/utils/markdown.d.ts +1 -0
  59. package/dist/utils/markdown.js +4 -4
  60. package/dist/utils/message-utils.d.ts +35 -0
  61. package/dist/utils/message-utils.js +75 -0
  62. package/dist/utils/terminal-buffer.d.ts +9 -1
  63. package/dist/utils/terminal-buffer.js +31 -2
  64. package/dist/utils/tool-display.d.ts +1 -0
  65. package/dist/utils/tool-display.js +1 -1
  66. package/dist/utils/tool-interactive.d.ts +12 -0
  67. package/dist/utils/tool-interactive.js +53 -0
  68. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  69. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  70. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  71. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  72. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  73. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  74. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  75. package/examples/extensions/claude-code-bridge/index.ts +77 -1
  76. package/examples/extensions/interactive-prompts.ts +82 -110
  77. package/examples/extensions/overlay-agent.ts +84 -38
  78. package/examples/extensions/peer-mesh.ts +450 -0
  79. package/examples/extensions/pi-bridge/index.ts +87 -2
  80. package/examples/extensions/questionnaire.ts +249 -0
  81. package/examples/extensions/tmux-pane.ts +307 -0
  82. package/examples/extensions/web-access.ts +327 -0
  83. package/package.json +9 -1
  84. package/dist/extensions/overlay-agent.d.ts +0 -11
  85. package/dist/extensions/overlay-agent.js +0 -43
  86. package/examples/extensions/terminal-buffer.ts +0 -184
@@ -0,0 +1,154 @@
1
+ /**
2
+ * MCP Bridge — connects external MCP servers to agent-sh.
3
+ *
4
+ * Spawns MCP servers as child processes, discovers their tools,
5
+ * and registers each tool as an agent-sh ToolDefinition.
6
+ *
7
+ * Configure in ~/.agent-sh/settings.json:
8
+ *
9
+ * {
10
+ * "extensions": ["./path/to/mcp-bridge"],
11
+ * "mcp-bridge": {
12
+ * "servers": {
13
+ * "vision": {
14
+ * "command": "npx",
15
+ * "args": ["-y", "@z_ai/mcp-server"],
16
+ * "env": {
17
+ * "Z_AI_API_KEY": "your-key",
18
+ * "Z_AI_MODE": "ZAI"
19
+ * }
20
+ * }
21
+ * }
22
+ * }
23
+ * }
24
+ */
25
+
26
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
27
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
28
+
29
+ interface McpServerConfig {
30
+ command: string;
31
+ args?: string[];
32
+ env?: Record<string, string>;
33
+ }
34
+
35
+ interface McpBridgeSettings {
36
+ servers: Record<string, McpServerConfig>;
37
+ }
38
+
39
+ interface ConnectedServer {
40
+ name: string;
41
+ client: Client;
42
+ transport: StdioClientTransport;
43
+ }
44
+
45
+ export default async function activate(ctx: any): Promise<void> {
46
+ const { bus } = ctx;
47
+
48
+ const settings = ctx.getExtensionSettings("mcp-bridge", {
49
+ servers: {},
50
+ }) as McpBridgeSettings;
51
+
52
+ const serverEntries = Object.entries(settings.servers);
53
+ if (serverEntries.length === 0) return;
54
+
55
+ const connected: ConnectedServer[] = [];
56
+
57
+ for (const [name, config] of serverEntries) {
58
+ try {
59
+ const server = await connectServer(name, config, ctx);
60
+ connected.push(server);
61
+ } catch (err: any) {
62
+ bus.emit("ui:info", {
63
+ message: `mcp-bridge: failed to connect "${name}": ${err.message}`,
64
+ });
65
+ }
66
+ }
67
+
68
+ // Clean up on exit
69
+ bus.on("app:quit", () => {
70
+ for (const server of connected) {
71
+ try {
72
+ server.transport.close();
73
+ } catch {}
74
+ }
75
+ });
76
+ }
77
+
78
+ async function connectServer(
79
+ name: string,
80
+ config: McpServerConfig,
81
+ ctx: any,
82
+ ): Promise<ConnectedServer> {
83
+ const transport = new StdioClientTransport({
84
+ command: config.command,
85
+ args: config.args,
86
+ env: { ...process.env, ...config.env } as Record<string, string>,
87
+ });
88
+
89
+ const client = new Client({ name: `ash-${name}`, version: "0.1.0" });
90
+ await client.connect(transport);
91
+
92
+ // Discover and register tools
93
+ const { tools } = await client.listTools();
94
+ for (const tool of tools) {
95
+ const toolName = `mcp_${name}_${tool.name}`;
96
+ ctx.registerTool({
97
+ name: toolName,
98
+ displayName: tool.name,
99
+ description: `[${name}] ${tool.description ?? ""}`,
100
+ input_schema: tool.inputSchema as Record<string, unknown>,
101
+
102
+ async execute(args: Record<string, unknown>) {
103
+ try {
104
+ const result = await client.callTool({
105
+ name: tool.name,
106
+ arguments: args,
107
+ });
108
+
109
+ const text = (result.content as any[])
110
+ .map((c: any) => {
111
+ if (c.type === "text") return c.text;
112
+ if (c.type === "image") return `[image: ${c.mimeType}]`;
113
+ return JSON.stringify(c);
114
+ })
115
+ .join("\n");
116
+
117
+ return {
118
+ content: text,
119
+ exitCode: result.isError ? 1 : 0,
120
+ isError: !!result.isError,
121
+ };
122
+ } catch (err: any) {
123
+ return {
124
+ content: `MCP error: ${err.message}`,
125
+ exitCode: 1,
126
+ isError: true,
127
+ };
128
+ }
129
+ },
130
+
131
+ getDisplayInfo() {
132
+ return { kind: "execute" as const };
133
+ },
134
+
135
+ formatCall(args: Record<string, unknown>) {
136
+ // Show a compact summary of the args
137
+ const keys = Object.keys(args);
138
+ if (keys.length === 0) return tool.name;
139
+ const first = args[keys[0]];
140
+ const preview =
141
+ typeof first === "string"
142
+ ? first.slice(0, 60) + (first.length > 60 ? "…" : "")
143
+ : JSON.stringify(first).slice(0, 60);
144
+ return `${tool.name}: ${preview}`;
145
+ },
146
+ });
147
+ }
148
+
149
+ ctx.bus.emit("ui:info", {
150
+ message: `mcp-bridge: "${name}" connected (${tools.length} tools)`,
151
+ });
152
+
153
+ return { name, client, transport };
154
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "ash-mcp-bridge",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@modelcontextprotocol/sdk": "^1.12.1"
8
+ }
9
+ }
@@ -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",
@@ -4,34 +4,36 @@
4
4
  * Adds permission gates for tool calls and file writes.
5
5
  * Without this extension, agent-sh runs in yolo mode (auto-approve).
6
6
  *
7
+ * Uses the interactive UI primitive for compositor-aware, themed rendering.
8
+ *
7
9
  * Usage:
8
- * # Load by short name (built-in):
9
- * agent-sh --extensions interactive-prompts
10
+ * agent-sh -e ./examples/extensions/interactive-prompts.ts
10
11
  *
11
12
  * # Or copy to ~/.agent-sh/extensions/ for permanent use:
12
13
  * cp examples/extensions/interactive-prompts.ts ~/.agent-sh/extensions/
13
- *
14
- * # Or install as an npm package and load by name:
15
- * agent-sh --extensions my-prompts-package
16
14
  */
17
15
  import { renderDiff } from "agent-sh/utils/diff-renderer.js";
18
16
  import { renderBoxFrame } from "agent-sh/utils/box-frame.js";
19
17
  import { palette as p } from "agent-sh/utils/palette.js";
20
18
  import type { ExtensionContext } from "agent-sh/types";
19
+ import type { ToolUI } from "agent-sh/agent/types.js";
21
20
 
22
21
  export default function activate({ bus }: ExtensionContext) {
23
22
  let autoApproveWrites = false;
24
23
 
25
24
  bus.onPipeAsync("permission:request", async (payload) => {
25
+ const ui = payload.ui as ToolUI | undefined;
26
+ if (!ui) return payload;
27
+
26
28
  switch (payload.kind) {
27
29
  case "tool-call":
28
- return handleToolCallPermission(payload);
30
+ return handleToolCall(payload, ui);
29
31
  case "file-write": {
30
32
  if (autoApproveWrites) {
31
- return { ...payload, decision: { approved: true } };
33
+ return { ...payload, decision: { outcome: "approved" } };
32
34
  }
33
- const result = await handleFileWritePermission(payload);
34
- if (result.decision.autoApprove) {
35
+ const result = await handleFileWrite(payload, ui);
36
+ if ((result.decision as any).autoApprove) {
35
37
  autoApproveWrites = true;
36
38
  }
37
39
  return result;
@@ -42,120 +44,90 @@ export default function activate({ bus }: ExtensionContext) {
42
44
  });
43
45
  }
44
46
 
45
- async function handleToolCallPermission(payload) {
47
+ async function handleToolCall(payload: any, ui: ToolUI) {
46
48
  const options = payload.metadata.options;
47
- const answer = await promptPermission(payload.title);
49
+
50
+ const answer = await ui.custom<"approve" | "approve_all" | "deny">({
51
+ render(width) {
52
+ const boxW = Math.min(84, width);
53
+ return renderBoxFrame(
54
+ [`${p.bold}⚠ ${payload.title}${p.reset}`],
55
+ {
56
+ width: boxW,
57
+ style: "rounded",
58
+ borderColor: p.warning,
59
+ title: "Permission required",
60
+ footer: [` ${p.dim}[y]es / [n]o / [a]llow all${p.reset}`],
61
+ },
62
+ );
63
+ },
64
+ handleInput(data, done) {
65
+ const ch = data.toLowerCase();
66
+ if (ch === "y") done("approve");
67
+ else if (ch === "a") done("approve_all");
68
+ else if (ch === "n" || ch === "\x1b") done("deny");
69
+ },
70
+ });
48
71
 
49
72
  if (answer === "approve" || answer === "approve_all") {
50
- const option = answer === "approve_all"
51
- ? options.find((o) => o.kind === "allow_always") ?? options.find((o) => o.kind === "allow_once")
52
- : options.find((o) => o.kind === "allow_once" || o.kind === "allow_always");
73
+ const kind = answer === "approve_all" ? "allow_always" : "allow_once";
74
+ const option = options?.find((o: any) => o.kind === kind)
75
+ ?? options?.find((o: any) => o.kind === "allow_once" || o.kind === "allow_always");
53
76
  if (option) {
54
77
  return { ...payload, decision: { outcome: "selected", optionId: option.optionId } };
55
78
  }
79
+ return { ...payload, decision: { outcome: "approved" } };
56
80
  }
57
81
  return { ...payload, decision: { outcome: "cancelled" } };
58
82
  }
59
83
 
60
- async function handleFileWritePermission(payload) {
84
+ async function handleFileWrite(payload: any, ui: ToolUI) {
61
85
  const diff = payload.metadata.diff;
62
- const filePath = payload.metadata.path;
63
- const answer = await previewDiff({ path: filePath, diff });
64
- if (answer === "approve") {
65
- return { ...payload, decision: { approved: true } };
66
- }
67
- if (answer === "approve_all") {
68
- return { ...payload, decision: { approved: true, autoApprove: true } };
69
- }
70
- return { ...payload, decision: { approved: false } };
71
- }
72
-
73
- async function promptPermission(title) {
74
- const termW = process.stdout.columns || 80;
75
- const boxW = Math.min(84, termW);
76
-
77
- const framed = renderBoxFrame(
78
- [`${p.bold}⚠ ${title}${p.reset}`],
79
- {
80
- width: boxW,
81
- style: "rounded",
82
- borderColor: p.warning,
83
- title: "Permission required",
84
- footer: [` ${p.dim}[y]es / [n]o / [a]llow all${p.reset}`],
86
+ const filePath = payload.metadata.path ?? payload.title;
87
+
88
+ const answer = await ui.custom<"approve" | "approve_all" | "reject">({
89
+ render(width) {
90
+ const boxW = Math.min(84, width);
91
+ const contentW = boxW - 4;
92
+ const MAX_DISPLAY = 25;
93
+
94
+ const stats = diff.isNewFile
95
+ ? `(+${diff.added} lines)`
96
+ : `(+${diff.added} / -${diff.removed})`;
97
+ const title = diff.isNewFile
98
+ ? `new: ${filePath} ${stats}`
99
+ : `${filePath} ${stats}`;
100
+
101
+ const diffLines = renderDiff(diff, {
102
+ width: contentW,
103
+ filePath,
104
+ maxLines: MAX_DISPLAY,
105
+ trueColor: true,
106
+ mode: "unified",
107
+ });
108
+ const content = ["", ...diffLines.slice(1), ""];
109
+
110
+ return renderBoxFrame(content, {
111
+ width: boxW,
112
+ style: "rounded",
113
+ borderColor: p.warning,
114
+ title,
115
+ footer: [` ${p.bold}[y] Apply [n] Skip [a] Don't ask again${p.reset}`],
116
+ });
117
+ },
118
+ handleInput(data, done) {
119
+ const ch = data.toLowerCase();
120
+ if (ch === "y") done("approve");
121
+ else if (ch === "a") done("approve_all");
122
+ else if (ch === "n" || ch === "\x1b") done("reject");
85
123
  },
86
- );
87
-
88
- process.stdout.write("\n");
89
- for (const line of framed) {
90
- process.stdout.write(line + "\n");
91
- }
92
- process.stdout.write(" ");
93
-
94
- return new Promise((resolve) => {
95
- const handler = (data) => {
96
- const ch = data.toString("utf-8").toLowerCase();
97
- process.stdin.removeListener("data", handler);
98
- process.stdout.write("\n");
99
-
100
- if (ch === "y") resolve("approve");
101
- else if (ch === "a") resolve("approve_all");
102
- else resolve(null);
103
- };
104
- process.stdin.on("data", handler);
105
- });
106
- }
107
-
108
- async function previewDiff(opts) {
109
- const termW = process.stdout.columns || 80;
110
- const boxW = Math.min(84, termW);
111
- const contentW = boxW - 4;
112
- const MAX_DISPLAY = 25;
113
-
114
- const stats = opts.diff.isNewFile
115
- ? `(+${opts.diff.added} lines)`
116
- : `(+${opts.diff.added} / -${opts.diff.removed})`;
117
- const title = opts.diff.isNewFile
118
- ? `new: ${opts.path} ${stats}`
119
- : `${opts.path} ${stats}`;
120
-
121
- const diffLines = renderDiff(opts.diff, {
122
- width: contentW,
123
- filePath: opts.path,
124
- maxLines: MAX_DISPLAY,
125
- trueColor: true,
126
- mode: "unified",
127
- });
128
- const content = ["", ...diffLines.slice(1), ""];
129
-
130
- const framed = renderBoxFrame(content, {
131
- width: boxW,
132
- style: "rounded",
133
- borderColor: p.warning,
134
- title,
135
- footer: [` ${p.bold}[y] Apply [n] Skip [a] Don't ask again${p.reset}`],
136
124
  });
137
125
 
138
- process.stdout.write("\n");
139
- for (const line of framed) {
140
- process.stdout.write(line + "\n");
126
+ if (answer === "approve") {
127
+ return { ...payload, decision: { outcome: "approved" } };
141
128
  }
142
-
143
- return new Promise((resolve) => {
144
- const handler = (data) => {
145
- const ch = data.toString("utf-8").toLowerCase();
146
- process.stdin.removeListener("data", handler);
147
-
148
- if (ch === "y") {
149
- process.stdout.write(` ${p.success}✓ Applied${p.reset}\n`);
150
- resolve("approve");
151
- } else if (ch === "a") {
152
- process.stdout.write(` ${p.success}✓ Applied (auto-approve on)${p.reset}\n`);
153
- resolve("approve_all");
154
- } else {
155
- process.stdout.write(` ${p.error}✗ Skipped${p.reset}\n`);
156
- resolve("reject");
157
- }
158
- };
159
- process.stdin.on("data", handler);
160
- });
129
+ if (answer === "approve_all") {
130
+ return { ...payload, decision: { outcome: "approved", autoApprove: true } };
131
+ }
132
+ return { ...payload, decision: { outcome: "cancelled" } };
161
133
  }
@@ -5,66 +5,112 @@
5
5
  * inside vim, htop, or ssh. Composites a floating response box on top
6
6
  * of the current terminal content.
7
7
  *
8
- * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
8
+ * Uses createRemoteSession() to route the full tui-renderer pipeline
9
+ * (markdown, tool grouping, spinner, diffs) into the floating panel.
10
+ *
11
+ * Install:
12
+ * cp examples/extensions/overlay-agent.ts ~/.agent-sh/extensions/
9
13
  *
10
- * Usage:
14
+ * Or load directly:
11
15
  * agent-sh -e ./examples/extensions/overlay-agent.ts
12
16
  *
13
- * # Or copy to ~/.agent-sh/extensions/ for permanent use:
14
- * cp examples/extensions/overlay-agent.ts ~/.agent-sh/extensions/
17
+ * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
15
18
  */
16
- import type { ExtensionContext } from "agent-sh/types";
17
- import { formatScreenContext } from "agent-sh/utils/terminal-buffer.js";
19
+ import type { ExtensionContext, RemoteSession } from "../../src/types.js";
20
+ import type { RenderSurface } from "../../src/utils/compositor.js";
21
+ import { FloatingPanel } from "../../src/utils/floating-panel.js";
18
22
 
19
- const BOLD = "\x1b[1m";
20
- const CYAN = "\x1b[36m";
21
- const RESET = "\x1b[0m";
23
+ /** Adapt a FloatingPanel to the RenderSurface interface. */
24
+ function createPanelSurface(panel: FloatingPanel): RenderSurface {
25
+ return {
26
+ write(text: string): void {
27
+ // Handle \r (carriage return) — overwrite the current line.
28
+ // The spinner uses "\r <content>\x1b[K" to update in-place.
29
+ if (text.startsWith("\r")) {
30
+ // Strip \r and any erase-line sequences
31
+ const cleaned = text.replace(/^\r/, "").replace(/\x1b\[\d*K/g, "");
32
+ if (cleaned.trim()) {
33
+ panel.updateLastLine(() => cleaned);
34
+ }
35
+ return;
36
+ }
37
+
38
+ // Regular text — may contain newlines
39
+ panel.appendText(text);
40
+ },
41
+ writeLine(line: string): void {
42
+ panel.appendLine(line);
43
+ },
44
+ get columns(): number {
45
+ return panel.computeGeometry().contentW;
46
+ },
47
+ };
48
+ }
22
49
 
23
- export default function activate({ bus, advise, createFloatingPanel, terminalBuffer }: ExtensionContext): void {
24
- const panel = createFloatingPanel({
50
+ export default function activate(ctx: ExtensionContext): void {
51
+ const { bus, registerInstruction, createRemoteSession, terminalBuffer } = ctx;
52
+
53
+ const panel = new FloatingPanel(bus, {
25
54
  trigger: "\x1c", // Ctrl+\
26
55
  dimBackground: true,
27
- autoDismissMs: 2000,
56
+ terminalBuffer: terminalBuffer ?? undefined,
28
57
  });
29
58
 
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
- }
59
+ const panelSurface = createPanelSurface(panel);
60
+ let session: RemoteSession | null = null;
61
+
62
+ registerInstruction("Interactive Overlay Sessions", [
63
+ "When the dynamic context includes `interactive-session: true`, the user has summoned you",
64
+ "via a hotkey overlay from inside their live terminal. They may be in the middle of using",
65
+ "a program (vim, ssh, a REPL, etc.) or at a shell prompt. In this mode:",
66
+ "- Start with terminal_read if you need to understand what's on screen.",
67
+ "- Prefer terminal_keys to interact with whatever is currently running.",
68
+ "- Use user_shell only for running new, standalone commands — not for interacting with",
69
+ " what's already on screen.",
70
+ "- Keep responses concise — the user is in the middle of a workflow.",
71
+ ].join("\n"));
72
+
73
+ // ── Panel lifecycle ────────────────────────────────────────────
36
74
 
37
- // ── Panel lifecycle ────────────────────────────────────────
38
75
  panel.handlers.advise("panel:submit", (_next, query: string) => {
76
+ if (!session) {
77
+ session = createRemoteSession({
78
+ surface: panelSurface,
79
+ suppressQueryBox: true,
80
+ interactive: true,
81
+ });
82
+ }
39
83
  panel.setActive();
40
- panel.appendLine(`${CYAN}${BOLD}❯${RESET} ${query}`);
41
- panel.appendLine("");
42
- bus.emit("agent:submit", { query });
84
+ session.submit(query);
43
85
  });
44
86
 
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
- }
87
+ panel.handlers.advise("panel:show", (_next) => {
88
+ // Re-establish session if panel is shown while agent is still working
89
+ if (panel.active && !session) {
90
+ session = createRemoteSession({
91
+ surface: panelSurface,
92
+ suppressQueryBox: true,
93
+ interactive: true,
94
+ });
52
95
  }
53
96
  });
54
97
 
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);
98
+ // On dismiss: close session only if agent is not actively processing.
99
+ // If agent is still working (phase="active"), keep session alive so
100
+ // output buffers in the panel and agent can keep executing tools.
101
+ panel.handlers.advise("panel:dismiss", (next) => {
102
+ next();
103
+ if (session && !panel.processing) {
104
+ session.close();
105
+ session = null;
106
+ }
64
107
  });
65
108
 
66
109
  bus.on("agent:processing-done", () => {
67
110
  if (!panel.active) return;
68
111
  panel.setDone();
112
+ // If panel was hidden while processing (passthrough), setDone()
113
+ // triggers dismiss() which closes the session above.
114
+ // If panel is still visible, session stays for the follow-up prompt.
69
115
  });
70
116
  }