agent-sh 0.8.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 (74) hide show
  1. package/README.md +25 -34
  2. package/dist/agent/agent-loop.d.ts +29 -6
  3. package/dist/agent/agent-loop.js +177 -59
  4. package/dist/agent/conversation-state.d.ts +3 -1
  5. package/dist/agent/conversation-state.js +6 -2
  6. package/dist/agent/nuclear-form.js +5 -4
  7. package/dist/agent/system-prompt.d.ts +4 -5
  8. package/dist/agent/system-prompt.js +12 -28
  9. package/dist/{token-budget.js → agent/token-budget.js} +1 -1
  10. package/dist/agent/tool-protocol.d.ts +83 -0
  11. package/dist/agent/tool-protocol.js +386 -0
  12. package/dist/agent/types.d.ts +21 -1
  13. package/dist/core.d.ts +7 -7
  14. package/dist/core.js +76 -194
  15. package/dist/event-bus.d.ts +26 -0
  16. package/dist/event-bus.js +20 -1
  17. package/dist/extension-loader.d.ts +5 -0
  18. package/dist/extension-loader.js +104 -17
  19. package/dist/extensions/agent-backend.d.ts +13 -0
  20. package/dist/extensions/agent-backend.js +167 -0
  21. package/dist/extensions/command-suggest.d.ts +3 -3
  22. package/dist/extensions/command-suggest.js +4 -3
  23. package/dist/extensions/index.d.ts +19 -0
  24. package/dist/extensions/index.js +25 -0
  25. package/dist/extensions/slash-commands.d.ts +1 -1
  26. package/dist/extensions/slash-commands.js +16 -1
  27. package/dist/extensions/terminal-buffer.d.ts +1 -1
  28. package/dist/extensions/terminal-buffer.js +13 -4
  29. package/dist/extensions/tui-renderer.js +63 -43
  30. package/dist/index.js +14 -20
  31. package/dist/settings.d.ts +6 -0
  32. package/dist/settings.js +4 -1
  33. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  34. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  35. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  36. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  37. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  38. package/dist/{shell.js → shell/shell.js} +20 -6
  39. package/dist/types.d.ts +49 -10
  40. package/dist/utils/compositor.d.ts +62 -0
  41. package/dist/utils/compositor.js +88 -0
  42. package/dist/utils/diff-renderer.js +92 -4
  43. package/dist/utils/floating-panel.d.ts +2 -0
  44. package/dist/utils/floating-panel.js +30 -14
  45. package/dist/utils/handler-registry.d.ts +26 -10
  46. package/dist/utils/handler-registry.js +52 -16
  47. package/dist/utils/line-editor.d.ts +23 -3
  48. package/dist/utils/line-editor.js +180 -42
  49. package/dist/utils/markdown.d.ts +1 -0
  50. package/dist/utils/markdown.js +1 -1
  51. package/dist/utils/message-utils.d.ts +35 -0
  52. package/dist/utils/message-utils.js +75 -0
  53. package/dist/utils/terminal-buffer.d.ts +5 -1
  54. package/dist/utils/terminal-buffer.js +18 -2
  55. package/dist/utils/tool-interactive.d.ts +12 -0
  56. package/dist/utils/tool-interactive.js +53 -0
  57. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  58. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  60. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  61. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  62. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  63. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  64. package/examples/extensions/interactive-prompts.ts +82 -110
  65. package/examples/extensions/overlay-agent.ts +84 -38
  66. package/examples/extensions/peer-mesh.ts +450 -0
  67. package/examples/extensions/questionnaire.ts +249 -0
  68. package/examples/extensions/tmux-pane.ts +307 -0
  69. package/examples/extensions/web-access.ts +327 -0
  70. package/package.json +9 -1
  71. package/dist/extensions/overlay-agent.d.ts +0 -14
  72. package/dist/extensions/overlay-agent.js +0 -147
  73. package/examples/extensions/terminal-buffer.ts +0 -184
  74. /package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +0 -0
@@ -0,0 +1,167 @@
1
+ import { AgentLoop } from "../agent/agent-loop.js";
2
+ import { LlmClient } from "../utils/llm-client.js";
3
+ import { resolveProvider, getProviderNames, getSettings } from "../settings.js";
4
+ export default function agentBackend(ctx) {
5
+ const { bus } = ctx;
6
+ // ── Resolve providers ──────────────────────────────────────
7
+ const config = ctx.call("config:get-shell-config") ?? {};
8
+ const settings = getSettings();
9
+ let activeProvider = null;
10
+ const providerRegistry = new Map();
11
+ for (const name of getProviderNames()) {
12
+ const p = resolveProvider(name);
13
+ if (p)
14
+ providerRegistry.set(name, p);
15
+ }
16
+ const providerName = config.provider ?? settings.defaultProvider;
17
+ if (providerName) {
18
+ activeProvider = providerRegistry.get(providerName) ?? null;
19
+ }
20
+ // ── Build modes ────────────────────────────────────────────
21
+ const buildModes = () => {
22
+ const allModes = [];
23
+ for (const [id, p] of providerRegistry) {
24
+ if (!p.apiKey)
25
+ continue;
26
+ for (const model of p.models) {
27
+ const mc = p.modelCapabilities?.get(model);
28
+ allModes.push({
29
+ model,
30
+ provider: id,
31
+ providerConfig: { apiKey: p.apiKey, baseURL: p.baseURL },
32
+ contextWindow: mc?.contextWindow ?? p.contextWindow,
33
+ reasoning: mc?.reasoning,
34
+ supportsReasoningEffort: p.supportsReasoningEffort,
35
+ });
36
+ }
37
+ }
38
+ return allModes;
39
+ };
40
+ const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
41
+ const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
42
+ const effectiveModel = config.model ?? activeProvider?.defaultModel;
43
+ let modes = buildModes();
44
+ if (modes.length === 0 && effectiveApiKey && effectiveModel) {
45
+ modes = [{ model: effectiveModel }];
46
+ }
47
+ const initialModeIndex = Math.max(0, modes.findIndex((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id)));
48
+ // ── Create LLM client ─────────────────────────────────────
49
+ if (!effectiveApiKey)
50
+ return; // No LLM provider configured — skip
51
+ if (!effectiveModel) {
52
+ bus.emit("ui:error", { message: "No model specified. Use --model or configure a provider with defaultModel in ~/.agent-sh/settings.json" });
53
+ return;
54
+ }
55
+ const llmClient = new LlmClient({
56
+ apiKey: effectiveApiKey,
57
+ baseURL: effectiveBaseURL,
58
+ model: effectiveModel,
59
+ });
60
+ // Expose LLM client for other extensions (e.g. command-suggest)
61
+ ctx.define("llm:get-client", () => llmClient);
62
+ // ── Initial modes (queryable via pipe) ─────────────────────
63
+ bus.onPipe("config:get-initial-modes", () => ({
64
+ modes,
65
+ initialModeIndex,
66
+ }));
67
+ // ── Create agent loop ──────────────────────────────────────
68
+ const agentLoop = new AgentLoop({
69
+ bus,
70
+ contextManager: ctx.contextManager,
71
+ llmClient,
72
+ handlers: { define: ctx.define, advise: ctx.advise, call: ctx.call },
73
+ modes,
74
+ initialModeIndex,
75
+ compositor: ctx.compositor,
76
+ });
77
+ // Register as backend
78
+ bus.emit("agent:register-backend", {
79
+ name: "ash",
80
+ kill: () => agentLoop.kill(),
81
+ start: async () => {
82
+ agentLoop.wire();
83
+ bus.emit("agent:info", {
84
+ name: "ash",
85
+ version: "0.4",
86
+ model: llmClient.model,
87
+ provider: modes[initialModeIndex]?.provider,
88
+ contextWindow: modes[initialModeIndex]?.contextWindow,
89
+ });
90
+ },
91
+ });
92
+ // ── Runtime provider registration ──────────────────────────
93
+ bus.on("provider:register", (p) => {
94
+ const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
95
+ const modelIds = [];
96
+ const caps = new Map();
97
+ for (const m of rawModels) {
98
+ if (typeof m === "string") {
99
+ modelIds.push(m);
100
+ }
101
+ else {
102
+ modelIds.push(m.id);
103
+ caps.set(m.id, { reasoning: m.reasoning, contextWindow: m.contextWindow });
104
+ }
105
+ }
106
+ providerRegistry.set(p.id, {
107
+ id: p.id,
108
+ apiKey: p.apiKey,
109
+ baseURL: p.baseURL,
110
+ defaultModel: p.defaultModel,
111
+ models: modelIds,
112
+ supportsReasoningEffort: p.supportsReasoningEffort,
113
+ modelCapabilities: caps.size > 0 ? caps : undefined,
114
+ });
115
+ const addModes = modelIds.map((m) => {
116
+ const mc = caps.get(m);
117
+ return {
118
+ model: m,
119
+ provider: p.id,
120
+ providerConfig: { apiKey: p.apiKey ?? "", baseURL: p.baseURL },
121
+ contextWindow: mc?.contextWindow,
122
+ reasoning: mc?.reasoning,
123
+ supportsReasoningEffort: p.supportsReasoningEffort,
124
+ };
125
+ });
126
+ bus.emit("config:add-modes", { modes: addModes });
127
+ });
128
+ // ── Runtime provider switching ─────────────────────────────
129
+ bus.on("config:switch-provider", ({ provider: name }) => {
130
+ const p = providerRegistry.get(name);
131
+ if (!p) {
132
+ bus.emit("ui:error", { message: `Unknown provider: ${name}` });
133
+ return;
134
+ }
135
+ const newApiKey = p.apiKey;
136
+ if (!newApiKey) {
137
+ bus.emit("ui:error", { message: `Provider "${name}" has no API key configured` });
138
+ return;
139
+ }
140
+ const switchModel = p.defaultModel ?? p.models[0];
141
+ if (!switchModel) {
142
+ bus.emit("ui:error", { message: `Provider "${name}" has no models configured` });
143
+ return;
144
+ }
145
+ llmClient.reconfigure({
146
+ apiKey: newApiKey,
147
+ baseURL: p.baseURL,
148
+ model: switchModel,
149
+ });
150
+ const newModes = p.models.map((m) => {
151
+ const mc = p.modelCapabilities?.get(m);
152
+ return {
153
+ model: m,
154
+ provider: name,
155
+ providerConfig: { apiKey: newApiKey, baseURL: p.baseURL },
156
+ contextWindow: mc?.contextWindow ?? p.contextWindow,
157
+ reasoning: mc?.reasoning,
158
+ supportsReasoningEffort: p.supportsReasoningEffort,
159
+ };
160
+ });
161
+ bus.emit("config:set-modes", { modes: newModes });
162
+ activeProvider = p;
163
+ bus.emit("agent:info", { name: "ash", version: "0.4", model: switchModel, provider: name, contextWindow: p.contextWindow });
164
+ bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
165
+ bus.emit("config:changed", {});
166
+ });
167
+ }
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Command suggestion extension (fast-path LLM feature).
3
3
  *
4
- * After a shell command fails (non-zero exit), uses llmClient.complete()
4
+ * After a shell command fails (non-zero exit), uses LlmClient.complete()
5
5
  * to suggest a fix. Shows the suggestion below the prompt.
6
6
  *
7
- * Only active when llmClient is available (internal agent mode).
7
+ * Only active when an LLM client is available (registered by agent-backend).
8
8
  */
9
9
  import type { ExtensionContext } from "../types.js";
10
- export default function activate({ bus, llmClient }: ExtensionContext): void;
10
+ export default function activate({ bus, call }: ExtensionContext): void;
@@ -1,6 +1,4 @@
1
- export default function activate({ bus, llmClient }) {
2
- if (!llmClient)
3
- return;
1
+ export default function activate({ bus, call }) {
4
2
  let suggesting = false;
5
3
  bus.on("shell:command-done", ({ command, output, exitCode, cwd }) => {
6
4
  if (exitCode === null || exitCode === 0)
@@ -9,6 +7,9 @@ export default function activate({ bus, llmClient }) {
9
7
  return;
10
8
  if (suggesting)
11
9
  return; // don't stack suggestions
10
+ const llmClient = call("llm:get-client");
11
+ if (!llmClient)
12
+ return;
12
13
  suggesting = true;
13
14
  // Truncate output to avoid blowing up the prompt
14
15
  const truncated = output.length > 1000
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Built-in extension manifest.
3
+ *
4
+ * These extensions ship with agent-sh and load before user extensions.
5
+ * They receive unscoped contexts (not reloadable) and can be individually
6
+ * disabled via the `disabledBuiltins` setting in ~/.agent-sh/settings.json.
7
+ */
8
+ import type { ExtensionContext } from "../types.js";
9
+ type ActivateFn = (ctx: ExtensionContext) => void;
10
+ export declare const BUILTIN_EXTENSIONS: Array<{
11
+ name: string;
12
+ load: () => Promise<ActivateFn>;
13
+ }>;
14
+ /**
15
+ * Load built-in extensions sequentially, skipping any in the disabled list.
16
+ * Returns the names of extensions that were loaded.
17
+ */
18
+ export declare function loadBuiltinExtensions(ctx: ExtensionContext, disabled?: string[]): Promise<string[]>;
19
+ export {};
@@ -0,0 +1,25 @@
1
+ export const BUILTIN_EXTENSIONS = [
2
+ { name: "agent-backend", load: () => import("./agent-backend.js").then(m => m.default) },
3
+ { name: "tui-renderer", load: () => import("./tui-renderer.js").then(m => m.default) },
4
+ { name: "slash-commands", load: () => import("./slash-commands.js").then(m => m.default) },
5
+ { name: "file-autocomplete", load: () => import("./file-autocomplete.js").then(m => m.default) },
6
+ { name: "shell-recall", load: () => import("./shell-recall.js").then(m => m.default) },
7
+ { name: "command-suggest", load: () => import("./command-suggest.js").then(m => m.default) },
8
+ { name: "terminal-buffer", load: () => import("./terminal-buffer.js").then(m => m.default) },
9
+ ];
10
+ /**
11
+ * Load built-in extensions sequentially, skipping any in the disabled list.
12
+ * Returns the names of extensions that were loaded.
13
+ */
14
+ export async function loadBuiltinExtensions(ctx, disabled = []) {
15
+ const disabledSet = new Set(disabled);
16
+ const loaded = [];
17
+ for (const ext of BUILTIN_EXTENSIONS) {
18
+ if (disabledSet.has(ext.name))
19
+ continue;
20
+ const activate = await ext.load();
21
+ activate(ctx);
22
+ loaded.push(ext.name);
23
+ }
24
+ return loaded;
25
+ }
@@ -1,2 +1,2 @@
1
1
  import type { ExtensionContext } from "../types.js";
2
- export default function activate({ bus, contextManager }: ExtensionContext): void;
2
+ export default function activate(ctx: ExtensionContext): void;
@@ -12,7 +12,9 @@
12
12
  */
13
13
  import { palette as p } from "../utils/palette.js";
14
14
  import { discoverSkills, loadSkillContent } from "../agent/skills.js";
15
- export default function activate({ bus, contextManager }) {
15
+ import { reloadExtensions } from "../extension-loader.js";
16
+ export default function activate(ctx) {
17
+ const { bus, contextManager } = ctx;
16
18
  const commands = new Map();
17
19
  const register = (cmd) => {
18
20
  const name = cmd.name.startsWith("/") ? cmd.name : `/${cmd.name}`;
@@ -103,6 +105,19 @@ export default function activate({ bus, contextManager }) {
103
105
  bus.emit("ui:info", { message: lines.join("\n") });
104
106
  },
105
107
  });
108
+ register({
109
+ name: "/reload",
110
+ description: "Reload user extensions from ~/.agent-sh/extensions/",
111
+ handler: async () => {
112
+ const names = await reloadExtensions(ctx);
113
+ if (names.length > 0) {
114
+ bus.emit("ui:info", { message: `Reloaded: ${names.join(", ")}` });
115
+ }
116
+ else {
117
+ bus.emit("ui:info", { message: "No extensions to reload." });
118
+ }
119
+ },
120
+ });
106
121
  // ── Extension registration ────────────────────────────────────
107
122
  bus.on("command:register", (cmd) => {
108
123
  register(cmd);
@@ -11,4 +11,4 @@
11
11
  * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
12
12
  */
13
13
  import type { ExtensionContext } from "../types.js";
14
- export default function activate({ bus, terminalBuffer: tb, registerTool }: ExtensionContext): void;
14
+ export default function activate(ctx: ExtensionContext): void;
@@ -19,7 +19,8 @@ function interpretEscapes(str) {
19
19
  function settle(ms = 100) {
20
20
  return new Promise((resolve) => setTimeout(resolve, ms));
21
21
  }
22
- export default function activate({ bus, terminalBuffer: tb, registerTool }) {
22
+ export default function activate(ctx) {
23
+ const { bus, terminalBuffer: tb, registerTool, registerInstruction } = ctx;
23
24
  if (!tb)
24
25
  return; // @xterm/headless not installed
25
26
  registerTool({
@@ -30,7 +31,14 @@ export default function activate({ bus, terminalBuffer: tb, registerTool }) {
30
31
  "diagnosing errors on screen, or checking state before/after sending keystrokes with terminal_keys.",
31
32
  input_schema: {
32
33
  type: "object",
33
- properties: {},
34
+ properties: {
35
+ include_scrollback: {
36
+ type: "boolean",
37
+ description: "If true, include scrollback buffer (content that scrolled off screen) " +
38
+ "in addition to the visible viewport. Useful for capturing output from " +
39
+ "long-running or streaming commands. Default: false.",
40
+ },
41
+ },
34
42
  },
35
43
  showOutput: true,
36
44
  getDisplayInfo: () => ({
@@ -38,8 +46,9 @@ export default function activate({ bus, terminalBuffer: tb, registerTool }) {
38
46
  icon: "⊞",
39
47
  locations: [],
40
48
  }),
41
- async execute() {
42
- const { text, altScreen, cursorX, cursorY } = tb.readScreen();
49
+ async execute(args) {
50
+ const includeScrollback = args.include_scrollback ?? false;
51
+ const { text, altScreen, cursorX, cursorY } = tb.readScreen({ includeScrollback });
43
52
  const info = [
44
53
  altScreen ? "mode: alternate screen" : "mode: normal",
45
54
  `cursor: row=${cursorY} col=${cursorX}`,
@@ -11,14 +11,13 @@
11
11
  * can subscribe to the same events.
12
12
  */
13
13
  import { highlight } from "cli-highlight";
14
- import { MarkdownRenderer, wrapLine } from "../utils/markdown.js";
14
+ import { MarkdownRenderer, wrapLine, MAX_CONTENT_WIDTH } from "../utils/markdown.js";
15
15
  import { createFencedBlockTransform } from "../utils/stream-transform.js";
16
16
  import { palette as p } from "../utils/palette.js";
17
17
  import { renderToolCall, createSpinner, formatElapsed, SPINNER_FRAMES, } from "../utils/tool-display.js";
18
18
  import { renderDiff } from "../utils/diff-renderer.js";
19
19
  import { renderBoxFrame } from "../utils/box-frame.js";
20
20
  import { getSettings } from "../settings.js";
21
- import { StdoutWriter } from "../utils/output-writer.js";
22
21
  /** Encode a PNG buffer as a terminal inline image escape sequence. */
23
22
  function encodeImageForTerminal(data) {
24
23
  const b64 = data.toString("base64");
@@ -68,12 +67,12 @@ function createRenderState() {
68
67
  };
69
68
  }
70
69
  export default function activate(ctx) {
71
- const { bus, llmClient, define } = ctx;
72
- const writer = new StdoutWriter();
70
+ const { bus, define, compositor } = ctx;
73
71
  const s = createRenderState();
74
- // Suppress all TUI output while stdout is held (overlay extensions)
75
- bus.on("shell:stdout-hold", () => { writer.hold(); });
76
- bus.on("shell:stdout-release", () => { writer.release(); });
72
+ /** Shorthand get the current agent surface. */
73
+ function out() { return compositor.surface("agent"); }
74
+ /** Capped width for borders, tool lines, and content — keeps everything aligned. */
75
+ function cappedW() { return Math.min(MAX_CONTENT_WIDTH + 2, out().columns); }
77
76
  // Gate: other extensions (e.g. overlay) can advise this to suppress
78
77
  // TUI rendering of agent output while they own the display.
79
78
  define("tui:should-render-agent", () => true);
@@ -81,6 +80,9 @@ export default function activate(ctx) {
81
80
  // ── Advisable rendering handlers ───────────────────────────────
82
81
  // Extensions advise these to customize how the TUI renders content.
83
82
  // Each handler receives data and returns rendered strings.
83
+ define("tui:response-border", (position, width) => {
84
+ return `${p.dim}${p.accent}${"─".repeat(width)}${p.reset}`;
85
+ });
84
86
  define("tui:response-start", () => { });
85
87
  define("tui:response-end", (_hadToolCalls) => { });
86
88
  define("tui:render-info", (message) => `${p.muted}${message}${p.reset}`);
@@ -210,7 +212,7 @@ export default function activate(ctx) {
210
212
  break;
211
213
  case "raw":
212
214
  flushForRaw();
213
- writer.write(block.escape);
215
+ out().write(block.escape);
214
216
  break;
215
217
  }
216
218
  }
@@ -391,6 +393,8 @@ export default function activate(ctx) {
391
393
  if (e.key === "\x14")
392
394
  toggleThinkingDisplay(); // Ctrl+T
393
395
  });
396
+ // Interactive tool UI — stop spinner while tool has control
397
+ bus.on("tool:interactive-start", () => { stopCurrentSpinner(); });
394
398
  bus.on("ui:info", (e) => {
395
399
  stopCurrentSpinner();
396
400
  showInfo(e.message);
@@ -400,23 +404,25 @@ export default function activate(ctx) {
400
404
  });
401
405
  bus.on("ui:error", (e) => showError(e.message));
402
406
  bus.on("ui:suggestion", (e) => {
403
- writer.write(`${p.dim}💡 ${e.text}${p.reset}\n`);
407
+ compositor.surface("status").writeLine(`${p.dim}💡 ${e.text}${p.reset}`);
404
408
  });
405
409
  // ── Rendering functions ─────────────────────────────────────
406
410
  function drain() {
407
411
  if (!s.renderer)
408
412
  return;
409
413
  for (const line of s.renderer.drainLines()) {
410
- writer.write(line + "\n");
414
+ out().write(line + "\n");
411
415
  // Track whether we just emitted a blank line (for contentGap dedup).
412
416
  // Lines from the renderer are indented (" "), so a blank line is " " or empty.
413
417
  lastEmittedLineBlank = line.trimEnd() === "" || line.trimEnd().replace(/\x1b\[[^m]*m/g, "").trim() === "";
414
418
  }
415
419
  }
416
420
  function startAgentResponse() {
417
- s.renderer = new MarkdownRenderer(writer.columns);
421
+ s.renderer = new MarkdownRenderer(cappedW());
418
422
  s.hadToolCalls = false;
419
- s.renderer.printTopBorder();
423
+ const border = ctx.call("tui:response-border", "top", cappedW());
424
+ if (border)
425
+ s.renderer.writeLine(border);
420
426
  drain();
421
427
  ctx.call("tui:response-start");
422
428
  }
@@ -434,7 +440,7 @@ export default function activate(ctx) {
434
440
  s.renderer.flush();
435
441
  drain();
436
442
  }
437
- writer.write(gap);
443
+ out().write(gap);
438
444
  }
439
445
  }
440
446
  s.lastContentKind = kind;
@@ -453,14 +459,16 @@ export default function activate(ctx) {
453
459
  if (s.renderer) {
454
460
  ctx.call("tui:response-end", s.hadToolCalls);
455
461
  s.renderer.flush();
456
- s.renderer.printBottomBorder();
462
+ const border = ctx.call("tui:response-border", "bottom", cappedW());
463
+ if (border)
464
+ s.renderer.writeLine(border);
457
465
  drain();
458
- writer.write("\n");
466
+ out().write("\n");
459
467
  s.renderer = null;
460
468
  }
461
469
  }
462
470
  function showUserQuery(query) {
463
- const model = backendInfo?.model ?? llmClient?.model;
471
+ const model = backendInfo?.model;
464
472
  const backend = backendInfo?.name;
465
473
  let modelLabel;
466
474
  if (backend && model) {
@@ -472,10 +480,13 @@ export default function activate(ctx) {
472
480
  else if (backend) {
473
481
  modelLabel = `${p.bold}${backend}${p.reset}`;
474
482
  }
475
- const framed = ctx.call("tui:render-user-query", query, writer.columns, modelLabel);
476
- writer.write("\n");
477
- for (const line of framed) {
478
- writer.write(line + "\n");
483
+ const querySurface = compositor.surface("query");
484
+ const framed = ctx.call("tui:render-user-query", query, querySurface.columns, modelLabel);
485
+ if (framed.length > 0) {
486
+ querySurface.write("\n");
487
+ for (const line of framed) {
488
+ querySurface.writeLine(line);
489
+ }
479
490
  }
480
491
  }
481
492
  function writeAgentText(text) {
@@ -486,7 +497,7 @@ export default function activate(ctx) {
486
497
  s.isThinking = false;
487
498
  if (s.showThinkingText && s.renderer) {
488
499
  s.renderer.flush();
489
- const w = Math.min(80, writer.columns);
500
+ const w = Math.min(80, out().columns);
490
501
  s.renderer.writeLine(`${p.dim}${"─".repeat(w)}${p.reset}`);
491
502
  drain();
492
503
  }
@@ -525,7 +536,7 @@ export default function activate(ctx) {
525
536
  drain();
526
537
  });
527
538
  function writeCodeBlock(language, code) {
528
- ctx.call("render:code-block", language, code, writer.columns);
539
+ ctx.call("render:code-block", language, code, cappedW());
529
540
  }
530
541
  function flushForRaw() {
531
542
  closeToolLine();
@@ -539,7 +550,7 @@ export default function activate(ctx) {
539
550
  flushForRaw();
540
551
  const escape = encodeImageForTerminal(data);
541
552
  if (escape) {
542
- writer.write(" " + escape + "\n");
553
+ out().write(" " + escape + "\n");
543
554
  }
544
555
  });
545
556
  function writeInlineImage(data) {
@@ -567,7 +578,7 @@ export default function activate(ctx) {
567
578
  function renderDiffBody(diff, filePath, width) {
568
579
  if (diff.isIdentical)
569
580
  return [];
570
- const boxW = Math.min(120, width);
581
+ const boxW = Math.min(120, width - 2); // -2 for writeLine indent
571
582
  const contentW = boxW - 4;
572
583
  const diffLines = renderDiff(diff, {
573
584
  width: contentW,
@@ -663,12 +674,12 @@ export default function activate(ctx) {
663
674
  locations: extra?.locations,
664
675
  rawInput: extra?.rawInput,
665
676
  displayDetail: extra?.displayDetail,
666
- }, writer.columns);
677
+ }, cappedW());
667
678
  if (extra?.groupContinuation && lines.length > 0) {
668
679
  // Swap the colored kind icon for a muted tree connector,
669
680
  // and strip the tool name prefix — show detail only.
670
681
  const detail = extra.displayDetail || extractDetail(extra);
671
- const maxW = Math.max(1, writer.columns - 6);
682
+ const maxW = Math.max(1, cappedW() - 6);
672
683
  const text = detail.length > maxW ? detail.slice(0, maxW - 1) + "…" : detail;
673
684
  lines[0] = detail
674
685
  ? `${p.muted}├${p.reset} ${p.dim}${text}${p.reset}`
@@ -687,7 +698,7 @@ export default function activate(ctx) {
687
698
  s.toolLineOpen = false;
688
699
  }
689
700
  else {
690
- writer.write(` ${batchPrefix}${lines[lines.length - 1]}`);
701
+ out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
691
702
  s.toolLineOpen = true;
692
703
  }
693
704
  }
@@ -702,7 +713,7 @@ export default function activate(ctx) {
702
713
  const elapsed = s.toolStartTime ? formatElapsed(Date.now() - s.toolStartTime) : "";
703
714
  const mark = ctx.call("tui:render-tool-complete", exitCode, elapsed, resultDisplay?.summary);
704
715
  if (s.toolLineOpen && s.commandOutputLineCount === 0) {
705
- writer.write(` ${mark}\n`);
716
+ out().write(` ${mark}\n`);
706
717
  s.toolLineOpen = false;
707
718
  }
708
719
  else {
@@ -719,7 +730,7 @@ export default function activate(ctx) {
719
730
  function renderResultBody(body) {
720
731
  if (!s.renderer)
721
732
  return;
722
- const lines = ctx.call("render:result-body", body, writer.columns) ?? [];
733
+ const lines = ctx.call("render:result-body", body, cappedW()) ?? [];
723
734
  for (const line of lines) {
724
735
  s.renderer.writeLine(line);
725
736
  }
@@ -748,7 +759,7 @@ export default function activate(ctx) {
748
759
  s.spinner.frame++;
749
760
  const elapsed = formatElapsed(Date.now() - s.spinner.startTime);
750
761
  const line = ctx.call("tui:render-spinner", s.spinnerLabel, frame, elapsed, s.spinnerOpts.hint);
751
- writer.write(`\r ${line}\x1b[K`);
762
+ out().write(`\r ${line}\x1b[K`);
752
763
  }
753
764
  }, 80);
754
765
  }
@@ -758,13 +769,13 @@ export default function activate(ctx) {
758
769
  s.spinnerInterval = null;
759
770
  }
760
771
  if (s.spinner) {
761
- writer.write("\r\x1b[2K");
772
+ out().write("\r\x1b[2K");
762
773
  s.spinner = null;
763
774
  }
764
775
  }
765
776
  function closeToolLine() {
766
777
  if (s.toolLineOpen) {
767
- writer.write("\n");
778
+ out().write("\n");
768
779
  s.toolLineOpen = false;
769
780
  }
770
781
  }
@@ -847,7 +858,15 @@ export default function activate(ctx) {
847
858
  }
848
859
  }
849
860
  else if (s.commandOutputOverflow > 0 && maxLines > 0) {
850
- s.renderer.writeLine(renderCommandLine(`… ${s.commandOutputOverflow} more lines`));
861
+ // Show last line of output so the user sees the tail (often the most useful part)
862
+ const tail = s.commandOverflowLines[s.commandOverflowLines.length - 1];
863
+ const hidden = tail ? s.commandOutputOverflow - 1 : s.commandOutputOverflow;
864
+ if (hidden > 0) {
865
+ s.renderer.writeLine(renderCommandLine(`… ${hidden} more lines`));
866
+ }
867
+ if (tail) {
868
+ s.renderer.writeLine(renderCommandLine(tail));
869
+ }
851
870
  }
852
871
  s.commandOutputOverflow = 0;
853
872
  s.commandOverflowLines = [];
@@ -864,7 +883,7 @@ export default function activate(ctx) {
864
883
  if (diff.isIdentical)
865
884
  return;
866
885
  contentGap("diff");
867
- const lines = ctx.call("render:result-body", { kind: "diff", diff, filePath }, writer.columns) ?? [];
886
+ const lines = ctx.call("render:result-body", { kind: "diff", diff, filePath }, cappedW()) ?? [];
868
887
  if (!s.renderer)
869
888
  startAgentResponse();
870
889
  for (const line of lines) {
@@ -883,7 +902,7 @@ export default function activate(ctx) {
883
902
  }
884
903
  if (!entry.expandedLines) {
885
904
  const { filePath, diff } = entry;
886
- const boxW = Math.min(120, writer.columns);
905
+ const boxW = Math.min(cappedW() - 2, out().columns - 2); // -2 for writeLine indent
887
906
  const contentW = boxW - 4;
888
907
  const diffLines = renderDiff(diff, {
889
908
  width: contentW,
@@ -900,16 +919,16 @@ export default function activate(ctx) {
900
919
  footer: [` ${p.dim}ctrl+o to collapse${p.reset}`],
901
920
  });
902
921
  }
903
- writer.write("\n");
922
+ out().write("\n");
904
923
  for (const line of entry.expandedLines) {
905
- writer.write(line + "\n");
924
+ out().write(line + "\n");
906
925
  }
907
926
  }
908
927
  function showFileDiffCached(entry) {
909
- const lines = ctx.call("render:result-body", { kind: "diff", diff: entry.diff, filePath: entry.filePath }, writer.columns) ?? [];
910
- writer.write("\n");
928
+ const lines = ctx.call("render:result-body", { kind: "diff", diff: entry.diff, filePath: entry.filePath }, cappedW()) ?? [];
929
+ out().write("\n");
911
930
  for (const line of lines) {
912
- writer.write(line + "\n");
931
+ out().write(line + "\n");
913
932
  }
914
933
  }
915
934
  function toggleThinkingDisplay() {
@@ -941,7 +960,7 @@ export default function activate(ctx) {
941
960
  else {
942
961
  if (s.renderer) {
943
962
  s.renderer.flush();
944
- const w = Math.min(80, writer.columns);
963
+ const w = Math.min(80, out().columns);
945
964
  s.renderer.writeLine(`${p.dim}${"─".repeat(w)}${p.reset}`);
946
965
  drain();
947
966
  }
@@ -949,9 +968,10 @@ export default function activate(ctx) {
949
968
  }
950
969
  }
951
970
  function showError(message) {
952
- writer.write("\n" + ctx.call("tui:render-error", message) + "\n");
971
+ const s = compositor.surface("status");
972
+ s.write("\n" + ctx.call("tui:render-error", message) + "\n");
953
973
  }
954
974
  function showInfo(message) {
955
- writer.write(ctx.call("tui:render-info", message) + "\n");
975
+ compositor.surface("status").writeLine(ctx.call("tui:render-info", message));
956
976
  }
957
977
  }