agent-sh 0.14.10 → 0.15.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 (97) hide show
  1. package/README.md +36 -13
  2. package/dist/agent/agent-loop.d.ts +9 -17
  3. package/dist/agent/agent-loop.js +123 -150
  4. package/dist/agent/events.d.ts +10 -12
  5. package/dist/agent/host-types.d.ts +17 -11
  6. package/dist/agent/index.d.ts +1 -1
  7. package/dist/agent/index.js +76 -29
  8. package/dist/agent/live-view.d.ts +3 -3
  9. package/dist/agent/live-view.js +15 -7
  10. package/dist/agent/providers/deepseek.js +9 -1
  11. package/dist/agent/providers/openrouter.js +9 -0
  12. package/dist/agent/session-store.js +1 -1
  13. package/dist/agent/subagent.js +1 -1
  14. package/dist/agent/system-prompt.d.ts +7 -3
  15. package/dist/agent/system-prompt.js +11 -14
  16. package/dist/agent/tool-protocol.js +0 -7
  17. package/dist/cli/args.js +2 -1
  18. package/dist/cli/install.d.ts +1 -0
  19. package/dist/cli/install.js +39 -2
  20. package/dist/cli/subcommands.js +1 -0
  21. package/dist/core/event-bus.js +0 -2
  22. package/dist/core/extension-loader.js +3 -1
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/index.js +3 -2
  25. package/dist/extensions/slash-commands/index.js +16 -11
  26. package/dist/shell/events.d.ts +3 -0
  27. package/dist/shell/index.js +9 -0
  28. package/dist/shell/shell-context.d.ts +2 -2
  29. package/dist/shell/shell-context.js +26 -11
  30. package/dist/shell/shell.js +3 -0
  31. package/dist/shell/tui-renderer.js +0 -1
  32. package/dist/utils/diff-renderer.d.ts +4 -0
  33. package/dist/utils/diff-renderer.js +15 -27
  34. package/dist/utils/handler-registry.d.ts +1 -6
  35. package/dist/utils/handler-registry.js +1 -6
  36. package/dist/utils/line-editor.js +0 -2
  37. package/dist/utils/palette.js +4 -4
  38. package/dist/utils/terminal-buffer.d.ts +2 -0
  39. package/dist/utils/terminal-buffer.js +4 -0
  40. package/examples/extensions/ads/SKILL.md +170 -0
  41. package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
  42. package/examples/extensions/ash-scheme/index.ts +377 -687
  43. package/examples/extensions/ash-scheme/package.json +1 -1
  44. package/examples/extensions/ashi/EXTENDING.md +118 -0
  45. package/examples/extensions/ashi/README.md +26 -54
  46. package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
  47. package/examples/extensions/ashi/package.json +14 -2
  48. package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
  49. package/examples/extensions/ashi/src/autocomplete.ts +1 -23
  50. package/examples/extensions/ashi/src/capture.ts +54 -10
  51. package/examples/extensions/ashi/src/chat/assistant.ts +67 -0
  52. package/examples/extensions/ashi/src/chat/lines.ts +39 -0
  53. package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
  54. package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
  55. package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
  56. package/examples/extensions/ashi/src/cli.ts +80 -12
  57. package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
  58. package/examples/extensions/ashi/src/commands.ts +11 -1
  59. package/examples/extensions/ashi/src/dialogs.ts +67 -0
  60. package/examples/extensions/ashi/src/display-config.ts +16 -1
  61. package/examples/extensions/ashi/src/docks.ts +31 -0
  62. package/examples/extensions/ashi/src/events.ts +16 -0
  63. package/examples/extensions/ashi/src/frontend.ts +456 -268
  64. package/examples/extensions/ashi/src/hooks.ts +27 -40
  65. package/examples/extensions/ashi/src/input-prompt.ts +64 -0
  66. package/examples/extensions/ashi/src/renderer.ts +222 -0
  67. package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
  68. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +27 -0
  69. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +190 -0
  70. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +203 -0
  71. package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
  72. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
  73. package/examples/extensions/ashi/src/schema.ts +46 -205
  74. package/examples/extensions/ashi/src/session-commands.ts +2 -1
  75. package/examples/extensions/ashi/src/status-footer.ts +35 -25
  76. package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
  77. package/examples/extensions/ashi/src/theme.ts +1 -47
  78. package/examples/extensions/ashi/src/ui.ts +88 -0
  79. package/examples/extensions/ashi-ink/README.md +61 -0
  80. package/examples/extensions/ashi-ink/package.json +30 -0
  81. package/examples/extensions/ashi-ink/src/index.ts +6 -0
  82. package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
  83. package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
  84. package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
  85. package/examples/extensions/ashi-ink/tsconfig.json +14 -0
  86. package/examples/extensions/ashi-scheme-render.ts +10 -10
  87. package/examples/extensions/ashi-shell-passthrough.ts +95 -0
  88. package/examples/extensions/ashi-ui-demo.ts +63 -0
  89. package/examples/extensions/latex-images.ts +70 -19
  90. package/examples/extensions/overlay-agent.ts +5 -5
  91. package/examples/extensions/pi-bridge/index.ts +7 -12
  92. package/examples/extensions/terminal-buffer.ts +4 -2
  93. package/package.json +3 -9
  94. package/examples/extensions/ashi/src/components.ts +0 -238
  95. package/examples/extensions/ollama.ts +0 -108
  96. package/examples/extensions/opencode-provider.ts +0 -251
  97. package/examples/extensions/zai-coding-plan.ts +0 -35
@@ -14,7 +14,16 @@ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
14
14
  export function listBundled() {
15
15
  if (!fs.existsSync(BUNDLED_DIR))
16
16
  return [];
17
- return fs.readdirSync(BUNDLED_DIR).map((n) => n.replace(/\.(ts|js|mjs)$/, ""));
17
+ const out = [];
18
+ for (const d of fs.readdirSync(BUNDLED_DIR, { withFileTypes: true })) {
19
+ if (d.name.startsWith("."))
20
+ continue;
21
+ if (d.isDirectory())
22
+ out.push(d.name);
23
+ else if (SCRIPT_EXTS.some((ext) => d.name.endsWith(ext)))
24
+ out.push(d.name.replace(/\.[^.]+$/, ""));
25
+ }
26
+ return out;
18
27
  }
19
28
  /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
20
29
  export function suggestBridgeFor(backend) {
@@ -181,6 +190,32 @@ function rewriteFileDeps(target, sourcePath) {
181
190
  if (changed)
182
191
  fs.writeFileSync(pkgJson, `${JSON.stringify(pkg, null, 2)}\n`);
183
192
  }
193
+ /** --dev: repoint the extension's agent-sh dep at the running host's package
194
+ * root, so the install builds and runs against the local (possibly unreleased)
195
+ * core instead of the published registry version. npm links the file: path, so
196
+ * later core rebuilds flow through without reinstalling. */
197
+ function pinHostCore(target) {
198
+ const pkgJson = path.join(target, "package.json");
199
+ if (!fs.existsSync(pkgJson))
200
+ return;
201
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf-8"));
202
+ const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
203
+ let changed = false;
204
+ for (const section of sections) {
205
+ const deps = pkg[section];
206
+ if (!deps || typeof deps !== "object")
207
+ continue;
208
+ const d = deps;
209
+ if (typeof d["agent-sh"] !== "string")
210
+ continue;
211
+ d["agent-sh"] = `file:${PACKAGE_ROOT}`;
212
+ changed = true;
213
+ }
214
+ if (!changed)
215
+ return;
216
+ fs.writeFileSync(pkgJson, `${JSON.stringify(pkg, null, 2)}\n`);
217
+ console.log(`agent-sh: --dev — linking ${path.basename(target)} against host core at ${PACKAGE_ROOT}`);
218
+ }
184
219
  function maybeNpmInstall(target, pkg) {
185
220
  const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) };
186
221
  if (Object.keys(deps).length === 0)
@@ -243,7 +278,7 @@ function linkBins(target, pkg) {
243
278
  }
244
279
  export async function runInstall(spec, opts = {}) {
245
280
  if (!spec) {
246
- console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force] [--sync-deps]\n\n" +
281
+ console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force] [--sync-deps] [--dev]\n\n" +
247
282
  "Bundled extensions:\n" +
248
283
  listBundled()
249
284
  .map((n) => ` ${n}`)
@@ -277,6 +312,8 @@ export async function runInstall(spec, opts = {}) {
277
312
  });
278
313
  try {
279
314
  rewriteFileDeps(target, resolved.sourcePath);
315
+ if (opts.dev)
316
+ pinHostCore(target);
280
317
  syncAgentShVersion(target, opts.syncDeps ?? false);
281
318
  const pkg = readPackageJson(target);
282
319
  if (pkg) {
@@ -6,6 +6,7 @@ const SUBCOMMANDS = {
6
6
  install: (args) => runInstall(args[0] ?? "", {
7
7
  force: args.includes("--force"),
8
8
  syncDeps: args.includes("--sync-deps"),
9
+ dev: args.includes("--dev"),
9
10
  }),
10
11
  uninstall: (args) => runUninstall(args[0] ?? ""),
11
12
  list: () => runList(),
@@ -159,9 +159,7 @@ export class EventBus {
159
159
  * returns the original payload unchanged (with safe defaults).
160
160
  */
161
161
  async emitPipeAsync(event, payload) {
162
- // Phase 1: notify (lets renderers prepare for interactive I/O)
163
162
  this.dispatch(event, payload);
164
- // Phase 2: transform (extensions provide decisions)
165
163
  const listeners = this.asyncPipeListeners.get(event);
166
164
  if (!listeners)
167
165
  return payload;
@@ -119,7 +119,9 @@ function createScopedContext(ctx, extensionName) {
119
119
  onDispose: (fn) => { cleanups.push(fn); },
120
120
  };
121
121
  const dispose = () => {
122
- for (const fn of cleanups) {
122
+ // Snapshot: a re-registering cleanup appends a new cleanup, and iterating
123
+ // the live array would run it and undo the restore in the same pass.
124
+ for (const fn of cleanups.slice()) {
123
125
  try {
124
126
  fn();
125
127
  }
@@ -13,7 +13,7 @@ import { HandlerRegistry } from "../utils/handler-registry.js";
13
13
  export { EventBus } from "./event-bus.js";
14
14
  export type { BusEvents, ContentBlock, BackendRegistration } from "./event-bus.js";
15
15
  export type { CoreContext, CoreConfig } from "./types.js";
16
- export type { AgentContext, AgentConfig, AgentSurface, AgentConfigSurface, AgentMode, LlmInterface, LlmMessage, LlmSession } from "../agent/host-types.js";
16
+ export type { AgentContext, AgentConfig, AgentSurface, AgentConfigSurface, Model, LlmInterface, LlmMessage, LlmSession } from "../agent/host-types.js";
17
17
  export type { ShellContext, ShellConfig, ShellSurface, ShellConfigSurface, ExtensionContext, RemoteSession, RemoteSessionOptions, RenderSurface, InputModeConfig, TerminalSession, BlockTransformOptions, FencedBlockTransformOptions, AppConfig } from "../shell/host-types.js";
18
18
  export { palette, setPalette, resetPalette } from "../utils/palette.js";
19
19
  export type { ColorPalette } from "../utils/palette.js";
@@ -29,10 +29,11 @@ export function createCore(config) {
29
29
  bus.setSource(instanceId);
30
30
  handlers.define("config:get-app-config", () => config);
31
31
  handlers.define("cwd", () => process.cwd());
32
- // Empty defaults so registerContextProducer can advise regardless of
33
- // backend. Each backend chooses how to consume the strings.
32
+ // Empty defaults so advisors can wrap these regardless of load order;
33
+ // system-prompt:frontend is where the active frontend describes its surface.
34
34
  handlers.define("dynamic-context:build", () => "");
35
35
  handlers.define("query-context:build", () => "");
36
+ handlers.define("system-prompt:frontend", () => "");
36
37
  const backends = new Map();
37
38
  let activeBackendName = null;
38
39
  bus.on("agent:register-backend", (backend) => {
@@ -41,16 +41,21 @@ export default function activate(ctx) {
41
41
  description: "Cycle to next model, or switch to a specific one",
42
42
  handler: (args) => {
43
43
  const name = args.trim();
44
+ const { models, active } = bus.emitPipe("config:get-models", { models: [], active: null });
44
45
  if (!name) {
45
- const { active } = bus.emitPipe("config:get-models", { models: [], active: null });
46
- const label = active
47
- ? `${active.model}${active.provider ? ` [${active.provider}]` : ""}`
48
- : "none";
46
+ const label = active ? `${active.id} [${active.provider}]` : "none";
49
47
  bus.emit("ui:info", { message: `Model: ${label}` });
48
+ return;
50
49
  }
51
- else {
52
- bus.emit("config:switch-model", { model: name });
50
+ const atIdx = name.lastIndexOf("@");
51
+ const id = atIdx > 0 ? name.slice(0, atIdx) : name;
52
+ const providerHint = atIdx > 0 ? name.slice(atIdx + 1) : undefined;
53
+ const found = models.find((m) => m.id === id && (!providerHint || m.provider === providerHint));
54
+ if (!found) {
55
+ bus.emit("ui:error", { message: `Unknown model: ${name}` });
56
+ return;
53
57
  }
58
+ bus.emit("config:switch-model", { id: found.id, provider: found.provider });
54
59
  },
55
60
  });
56
61
  register({
@@ -163,16 +168,16 @@ export default function activate(ctx) {
163
168
  const { models, active } = bus.emitPipe("config:get-models", { models: [], active: null });
164
169
  const counts = new Map();
165
170
  for (const m of models)
166
- counts.set(m.model, (counts.get(m.model) ?? 0) + 1);
171
+ counts.set(m.id, (counts.get(m.id) ?? 0) + 1);
167
172
  const items = models
168
- .filter((m) => m.model.toLowerCase().includes(partial))
173
+ .filter((m) => m.id.toLowerCase().includes(partial))
169
174
  .slice(0, 15)
170
175
  .map((m) => {
171
- const ambiguous = (counts.get(m.model) ?? 0) > 1 && m.provider;
172
- const qualified = ambiguous ? `${m.model}@${m.provider}` : m.model;
176
+ const ambiguous = (counts.get(m.id) ?? 0) > 1;
177
+ const qualified = ambiguous ? `${m.id}@${m.provider}` : m.id;
173
178
  return {
174
179
  name: `/model ${qualified}`,
175
- description: `${m.provider ? `[${m.provider}]` : ""}${active && m.model === active.model && m.provider === active.provider ? " (active)" : ""}`,
180
+ description: `[${m.provider}]${active && m.id === active.id && m.provider === active.provider ? " (active)" : ""}`,
176
181
  };
177
182
  });
178
183
  if (items.length === 0)
@@ -32,6 +32,9 @@ declare module "../core/event-bus.js" {
32
32
  cols: number;
33
33
  rows: number;
34
34
  };
35
+ "shell:host-write": {
36
+ data: string;
37
+ };
35
38
  "shell:buffer-request": Record<string, never>;
36
39
  "shell:buffer-snapshot": {
37
40
  text: string;
@@ -7,11 +7,13 @@ import "./events.js"; // augments BusEvents with shell-owned events
7
7
  import { Shell } from "./shell.js";
8
8
  import { DefaultCompositor } from "../utils/compositor.js";
9
9
  import { TerminalBuffer } from "../utils/terminal-buffer.js";
10
+ import { FloatingPanel } from "../utils/floating-panel.js";
10
11
  import { setPalette } from "../utils/palette.js";
11
12
  import * as streamTransform from "../utils/stream-transform.js";
12
13
  import activateShellContext from "./shell-context.js";
13
14
  import activateTuiRenderer from "./tui-renderer.js";
14
15
  import { processTerminal, surfaceFromTerminal } from "./terminal.js";
16
+ const SHELL_SURFACE = `You're attached through a terminal shell. It shares the user's working directory, environment, and command history, and you can act on their live session — everything they run at the prompt is visible to you.`;
15
17
  /**
16
18
  * Register shell-owned handlers extensions can `ctx.call`, and attach
17
19
  * the shell surface to ctx. Must run before `loadExtensions` so user
@@ -20,6 +22,10 @@ import { processTerminal, surfaceFromTerminal } from "./terminal.js";
20
22
  export function registerShellHandlers(ctx) {
21
23
  const { bus } = ctx;
22
24
  const compositor = new DefaultCompositor(bus);
25
+ ctx.advise("system-prompt:frontend", (next) => {
26
+ const base = next() ?? "";
27
+ return base ? `${base}\n\n${SHELL_SURFACE}` : SHELL_SURFACE;
28
+ });
23
29
  const shellSurface = {
24
30
  compositor,
25
31
  setPalette,
@@ -69,6 +75,9 @@ export function registerShellHandlers(ctx) {
69
75
  terminalBufferSingleton = TerminalBuffer.createWired(ctx.bus);
70
76
  return terminalBufferSingleton;
71
77
  });
78
+ // bus override lets callers pass their scoped bus, so the panel's
79
+ // listeners unwire when the extension reloads.
80
+ ctx.define("floating-panel:create", (config, bus) => new FloatingPanel(bus ?? ctx.bus, config));
72
81
  activateShellContext(ctx);
73
82
  activateTuiRenderer(ctx);
74
83
  }
@@ -1,5 +1,5 @@
1
1
  /** Tracks PTY commands and cwd, spills long outputs, contributes per-query
2
- * `<cwd>` (always) and `<shell_events>` (fresh user exchanges). Frontends
3
- * without a PTY skip this and fall back to core's process.cwd() default. */
2
+ * `<shell_events>` (fresh user exchanges) and — under the shell frontend —
3
+ * `<cwd>`. Frontends without a PTY skip this. */
4
4
  import type { ExtensionContext } from "./host-types.js";
5
5
  export default function activate(ctx: ExtensionContext): void;
@@ -1,7 +1,15 @@
1
1
  import { getSettings } from "../core/settings.js";
2
2
  import { spillOutput } from "../utils/shell-output-spill.js";
3
+ // The cwd-drift note applies only under the shell frontend (where the agent shares
4
+ // the user's cwd); other frontends own a fixed cwd.
5
+ const SHELL_EVENTS_NOTE = `When the user runs shell commands, they appear as \`<shell_events>\` inside \`<query_context>\` on your next turn — use them to ground "fix this" / "what just happened" requests.`;
6
+ const CWD_DRIFT_NOTE = `\`<cwd>\` is the working directory your own tool calls run in: relative paths resolve against it, and it follows the user's shell \`cd\`, so it can change from one turn to the next. Always act on the latest \`<cwd>\`, not one from earlier in the conversation.`;
7
+ const PREFERENCES_NOTE = `Treat the user's commands as standing preferences: check them for recurring patterns and apply them proactively, without waiting to be asked.`;
3
8
  export default function activate(ctx) {
4
9
  const { bus } = ctx;
10
+ // The agent shares the user's cwd only under the shell frontend (which installs
11
+ // ctx.shell); other frontends — e.g. ashi — keep their own fixed cwd.
12
+ const ownsAgentCwd = !!ctx.shell;
5
13
  const exchanges = [];
6
14
  let nextId = 1;
7
15
  let currentCwd = process.cwd();
@@ -50,23 +58,30 @@ export default function activate(ctx) {
50
58
  bus.on("shell:agent-exec-start", () => { agentShellActive = true; });
51
59
  bus.on("shell:agent-exec-done", () => { agentShellActive = false; });
52
60
  bus.on("shell:user-exec-exclude-next", () => { nextUserExcluded = true; });
53
- ctx.advise("cwd", () => currentCwd);
61
+ if (ownsAgentCwd)
62
+ ctx.advise("cwd", () => currentCwd);
54
63
  // Advise the core handler directly: this loads before the agent host
55
64
  // attaches `ctx.agent`, so the sugar isn't available yet.
56
65
  ctx.advise("query-context:build", (next) => {
57
66
  const base = next();
58
- const part = (() => {
59
- const cwdTag = `<cwd>${currentCwd}</cwd>`;
60
- const fresh = exchanges.filter((ex) => ex.id > lastSeq && ex.source === "user");
61
- if (fresh.length === 0)
62
- return cwdTag;
67
+ const fresh = exchanges.filter((ex) => ex.id > lastSeq && ex.source === "user");
68
+ let shellEvents = "";
69
+ if (fresh.length > 0) {
63
70
  lastSeq = exchanges[exchanges.length - 1].id;
64
71
  const text = fresh.map(formatExchangeTruncated).filter(Boolean).join("\n");
65
- if (!text)
66
- return cwdTag;
67
- return `${cwdTag}\n<shell_events>\n${text}\n</shell_events>`;
68
- })();
69
- return base ? `${base}\n\n${part}` : part;
72
+ if (text)
73
+ shellEvents = `<shell_events>\n${text}\n</shell_events>`;
74
+ }
75
+ const part = ownsAgentCwd
76
+ ? [`<cwd>${currentCwd}</cwd>`, shellEvents].filter(Boolean).join("\n")
77
+ : shellEvents;
78
+ return [base, part].filter(Boolean).join("\n\n");
79
+ });
80
+ bus.onPipe("agent:instructions", (acc) => {
81
+ const text = [SHELL_EVENTS_NOTE, ownsAgentCwd ? CWD_DRIFT_NOTE : "", PREFERENCES_NOTE]
82
+ .filter(Boolean).join("\n\n");
83
+ acc.instructions.push({ name: "shell-events", text });
84
+ return acc;
70
85
  });
71
86
  ctx.define("shell:context-recent", (n = 25) => {
72
87
  const recent = exchanges.slice(-n);
@@ -110,6 +110,9 @@ export class Shell {
110
110
  this.bus.on("shell:pty-resize", ({ cols, rows }) => {
111
111
  this.ptyProcess.resize(cols, rows);
112
112
  });
113
+ this.bus.on("shell:host-write", ({ data }) => {
114
+ this.terminal.write(data);
115
+ });
113
116
  // Compat shims for the bus-event API. shell:stdout-hold maps to hard
114
117
  // mute so terminal_keys' stdout-show can't paint through the overlay.
115
118
  let holdRefcount = 0;
@@ -222,7 +222,6 @@ export default function activate(ctx) {
222
222
  }
223
223
  }
224
224
  });
225
- // Track token usage for display
226
225
  let pendingUsage = null;
227
226
  bus.on("agent:usage", (e) => { pendingUsage = e; });
228
227
  bus.on("agent:response-done", () => {
@@ -13,6 +13,10 @@ export interface DiffRenderOptions {
13
13
  trueColor?: boolean;
14
14
  /** Enable syntax highlighting on diff lines. Default true. */
15
15
  syntaxHighlight?: boolean;
16
+ /** Draw the `│` rule between the line number and the code (default true). Set false
17
+ * for a flush gutter: `<n> <sigil><code>`, the row background spans the line, and
18
+ * context code is left un-dimmed. */
19
+ gutterLine?: boolean;
16
20
  }
17
21
  export declare function detectLanguage(filePath?: string): string | undefined;
18
22
  /**
@@ -176,17 +176,14 @@ function findChangePairs(hunk) {
176
176
  const lines = hunk.lines;
177
177
  let i = 0;
178
178
  while (i < lines.length) {
179
- // Find a run of removed lines
180
179
  const removedStart = i;
181
180
  while (i < lines.length && lines[i].type === "removed")
182
181
  i++;
183
182
  const removedEnd = i;
184
- // Find a run of added lines immediately after
185
183
  const addedStart = i;
186
184
  while (i < lines.length && lines[i].type === "added")
187
185
  i++;
188
186
  const addedEnd = i;
189
- // Pair them 1:1
190
187
  const removedCount = removedEnd - removedStart;
191
188
  const addedCount = addedEnd - addedStart;
192
189
  const pairCount = Math.min(removedCount, addedCount);
@@ -239,25 +236,35 @@ function unifiedLayout(diff, opts) {
239
236
  lineTextW: Math.max(1, textWidth - noW - 5),
240
237
  textWidth,
241
238
  useTrueColor: opts.trueColor !== false,
239
+ gutterLine: opts.gutterLine !== false,
242
240
  lang: opts.syntaxHighlight !== false ? detectLanguage(opts.filePath) : undefined,
243
241
  removedPalette: { rowBg: p.errorBg, emphBg: p.errorBgEmph },
244
242
  addedPalette: { rowBg: p.successBg, emphBg: p.successBgEmph },
245
243
  };
246
244
  }
247
245
  function renderUnifiedHunk(hunk, layout) {
248
- const { noW, lineTextW, textWidth, useTrueColor, lang, removedPalette, addedPalette } = layout;
246
+ const { noW, lineTextW, textWidth, useTrueColor, gutterLine, lang, removedPalette, addedPalette } = layout;
249
247
  const out = [];
250
248
  const pairs = findChangePairs(hunk);
251
249
  const renderedAsPartOfPair = new Set();
252
250
  const bgWidth = Math.max(1, textWidth - noW - 3);
253
251
  const gutter = (n) => `${p.dim}${n} │${p.reset} `;
252
+ const change = (no, sigil, bg, fg, text) => {
253
+ if (!gutterLine) {
254
+ return `${bg}${fg}${padToWidth(`${no} ${sigil} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
255
+ }
256
+ if (useTrueColor)
257
+ return gutter(no) + padToWidth(`${bg}${fg}${sigil} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
258
+ return `${gutter(no)}${fg}${sigil} ${text}${p.reset}`;
259
+ };
254
260
  for (let i = 0; i < hunk.lines.length; i++) {
255
261
  const line = hunk.lines[i];
256
262
  const no = String(line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? "")).padStart(noW);
257
263
  if (line.type === "context") {
258
264
  const raw = truncateText(line.text, lineTextW);
259
265
  const text = lang ? highlightLine(raw, lang) : raw;
260
- out.push(`${gutter(no)} ${p.dim}${text}${p.reset}`);
266
+ // The flush gutter dims only the line number; the code stays normal/highlighted.
267
+ out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
261
268
  continue;
262
269
  }
263
270
  if (line.type === "removed") {
@@ -276,19 +283,9 @@ function renderUnifiedHunk(hunk, layout) {
276
283
  const raw = truncateText(line.text, lineTextW);
277
284
  removedText = lang ? highlightLine(raw, lang) : raw;
278
285
  }
279
- if (useTrueColor) {
280
- out.push(gutter(no) + padToWidth(`${p.errorBg}${p.error}- ${preserveBg(removedText, p.errorBg)}`, bgWidth) + p.reset);
281
- }
282
- else {
283
- out.push(`${gutter(no)}${p.error}- ${removedText}${p.reset}`);
284
- }
286
+ out.push(change(no, "-", p.errorBg, p.error, removedText));
285
287
  if (addedText !== null && addedNo !== null) {
286
- if (useTrueColor) {
287
- out.push(gutter(addedNo) + padToWidth(`${p.successBg}${p.success}+ ${preserveBg(addedText, p.successBg)}`, bgWidth) + p.reset);
288
- }
289
- else {
290
- out.push(`${gutter(addedNo)}${p.success}+ ${addedText}${p.reset}`);
291
- }
288
+ out.push(change(addedNo, "+", p.successBg, p.success, addedText));
292
289
  }
293
290
  continue;
294
291
  }
@@ -297,12 +294,7 @@ function renderUnifiedHunk(hunk, layout) {
297
294
  continue;
298
295
  const raw = truncateText(line.text, lineTextW);
299
296
  const text = lang ? highlightLine(raw, lang) : raw;
300
- if (useTrueColor) {
301
- out.push(gutter(no) + padToWidth(`${p.successBg}${p.success}+ ${preserveBg(text, p.successBg)}`, bgWidth) + p.reset);
302
- }
303
- else {
304
- out.push(`${gutter(no)}${p.success}+ ${text}${p.reset}`);
305
- }
297
+ out.push(change(no, "+", p.successBg, p.success, text));
306
298
  }
307
299
  }
308
300
  return out;
@@ -423,19 +415,16 @@ function buildSplitRows(hunk) {
423
415
  i++;
424
416
  continue;
425
417
  }
426
- // Collect a run of removed lines
427
418
  const removed = [];
428
419
  while (i < lines.length && lines[i].type === "removed") {
429
420
  removed.push(lines[i]);
430
421
  i++;
431
422
  }
432
- // Collect a run of added lines
433
423
  const added = [];
434
424
  while (i < lines.length && lines[i].type === "added") {
435
425
  added.push(lines[i]);
436
426
  i++;
437
427
  }
438
- // Pair them side by side
439
428
  const maxLen = Math.max(removed.length, added.length);
440
429
  for (let k = 0; k < maxLen; k++) {
441
430
  rows.push({
@@ -522,7 +511,6 @@ function trimHunksToFit(hunks, maxLines) {
522
511
  changeCount++;
523
512
  }
524
513
  }
525
- // Separators between hunks
526
514
  const separators = Math.max(0, hunks.length - 1);
527
515
  // How many context lines can we afford?
528
516
  const contextBudget = Math.max(0, maxLines - changeCount - separators);
@@ -50,13 +50,8 @@ export declare class HandlerRegistry {
50
50
  * Returns undefined if no handler is registered.
51
51
  */
52
52
  call(name: string, ...args: any[]): any;
53
- /**
54
- * Check if a named handler exists.
55
- */
56
53
  has(name: string): boolean;
57
- /**
58
- * Names of all registered handlers. For diagnostic/introspection use.
59
- */
54
+ /** Names of all registered handlers — for diagnostics/introspection. */
60
55
  list(): string[];
61
56
  }
62
57
  export {};
@@ -79,15 +79,10 @@ export class HandlerRegistry {
79
79
  }
80
80
  return fn(...args);
81
81
  }
82
- /**
83
- * Check if a named handler exists.
84
- */
85
82
  has(name) {
86
83
  return this.entries.has(name);
87
84
  }
88
- /**
89
- * Names of all registered handlers. For diagnostic/introspection use.
90
- */
85
+ /** Names of all registered handlers — for diagnostics/introspection. */
91
86
  list() {
92
87
  return [...this.entries.keys()];
93
88
  }
@@ -309,11 +309,9 @@ export class LineEditor {
309
309
  pushHistory(line) {
310
310
  if (!line.trim())
311
311
  return;
312
- // Deduplicate: remove if already at top
313
312
  if (this.history.length > 0 && this.history[0] === line)
314
313
  return;
315
314
  this.history.unshift(line);
316
- // Cap history size
317
315
  if (this.history.length > 100)
318
316
  this.history.pop();
319
317
  }
@@ -14,10 +14,10 @@ const defaultPalette = {
14
14
  warning: "\x1b[33m", // yellow
15
15
  error: "\x1b[31m", // red
16
16
  muted: "\x1b[90m", // gray
17
- successBg: "\x1b[48;2;0;60;0m",
18
- errorBg: "\x1b[48;2;50;0;0m",
19
- successBgEmph: "\x1b[48;2;0;112;0m",
20
- errorBgEmph: "\x1b[48;2;90;0;0m",
17
+ successBg: "\x1b[48;2;34;92;43m",
18
+ errorBg: "\x1b[48;2;122;41;54m",
19
+ successBgEmph: "\x1b[48;2;56;166;96m",
20
+ errorBgEmph: "\x1b[48;2;179;89;107m",
21
21
  bold: "\x1b[1m",
22
22
  dim: "\x1b[2m",
23
23
  italic: "\x1b[3m",
@@ -47,6 +47,8 @@ export declare class TerminalBuffer {
47
47
  readScreen(opts?: {
48
48
  includeScrollback?: boolean;
49
49
  }): ScreenSnapshot;
50
+ /** Read the screen and wrap it as a `<terminal_buffer>` context block. */
51
+ formatScreen(maxLines?: number, baseContext?: string): string;
50
52
  /**
51
53
  * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
52
54
  * Clean text only (ANSI stripped). Reads from the active buffer's
@@ -111,6 +111,10 @@ export class TerminalBuffer {
111
111
  cursorY: buf.cursorY,
112
112
  };
113
113
  }
114
+ /** Read the screen and wrap it as a `<terminal_buffer>` context block. */
115
+ formatScreen(maxLines, baseContext) {
116
+ return formatScreenContext(this.readScreen(), maxLines, baseContext);
117
+ }
114
118
  /**
115
119
  * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
116
120
  * Clean text only (ANSI stripped). Reads from the active buffer's