agent-sh 0.4.0 → 0.6.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 (83) hide show
  1. package/README.md +37 -115
  2. package/dist/agent/agent-loop.d.ts +86 -0
  3. package/dist/agent/agent-loop.js +704 -0
  4. package/dist/agent/conversation-state.d.ts +27 -0
  5. package/dist/agent/conversation-state.js +59 -0
  6. package/dist/agent/index.d.ts +11 -0
  7. package/dist/agent/index.js +9 -0
  8. package/dist/agent/skills.d.ts +25 -0
  9. package/dist/agent/skills.js +186 -0
  10. package/dist/agent/subagent.d.ts +37 -0
  11. package/dist/agent/subagent.js +119 -0
  12. package/dist/agent/system-prompt.d.ts +14 -0
  13. package/dist/agent/system-prompt.js +103 -0
  14. package/dist/agent/tool-registry.d.ts +15 -0
  15. package/dist/agent/tool-registry.js +30 -0
  16. package/dist/agent/tools/bash.d.ts +7 -0
  17. package/dist/agent/tools/bash.js +71 -0
  18. package/dist/agent/tools/display.d.ts +13 -0
  19. package/dist/agent/tools/display.js +70 -0
  20. package/dist/agent/tools/edit-file.d.ts +2 -0
  21. package/dist/agent/tools/edit-file.js +148 -0
  22. package/dist/agent/tools/glob.d.ts +2 -0
  23. package/dist/agent/tools/glob.js +87 -0
  24. package/dist/agent/tools/grep.d.ts +2 -0
  25. package/dist/agent/tools/grep.js +168 -0
  26. package/dist/agent/tools/list-skills.d.ts +2 -0
  27. package/dist/agent/tools/list-skills.js +28 -0
  28. package/dist/agent/tools/ls.d.ts +2 -0
  29. package/dist/agent/tools/ls.js +72 -0
  30. package/dist/agent/tools/read-file.d.ts +10 -0
  31. package/dist/agent/tools/read-file.js +101 -0
  32. package/dist/agent/tools/user-shell.d.ts +13 -0
  33. package/dist/agent/tools/user-shell.js +84 -0
  34. package/dist/agent/tools/write-file.d.ts +2 -0
  35. package/dist/agent/tools/write-file.js +82 -0
  36. package/dist/agent/types.d.ts +78 -0
  37. package/dist/agent/types.js +1 -0
  38. package/dist/core.d.ts +22 -14
  39. package/dist/core.js +256 -36
  40. package/dist/event-bus.d.ts +98 -17
  41. package/dist/event-bus.js +10 -1
  42. package/dist/extension-loader.d.ts +1 -1
  43. package/dist/extension-loader.js +10 -1
  44. package/dist/extensions/command-suggest.d.ts +10 -0
  45. package/dist/extensions/command-suggest.js +41 -0
  46. package/dist/extensions/slash-commands.d.ts +1 -1
  47. package/dist/extensions/slash-commands.js +161 -64
  48. package/dist/extensions/tui-renderer.js +426 -126
  49. package/dist/index.js +110 -129
  50. package/dist/input-handler.js +78 -9
  51. package/dist/output-parser.d.ts +7 -0
  52. package/dist/output-parser.js +27 -0
  53. package/dist/settings.d.ts +53 -2
  54. package/dist/settings.js +46 -3
  55. package/dist/shell.js +35 -28
  56. package/dist/types.d.ts +33 -6
  57. package/dist/utils/box-frame.d.ts +3 -1
  58. package/dist/utils/box-frame.js +12 -5
  59. package/dist/utils/diff.js +10 -0
  60. package/dist/utils/llm-client.d.ts +45 -0
  61. package/dist/utils/llm-client.js +60 -0
  62. package/dist/utils/markdown.d.ts +1 -0
  63. package/dist/utils/markdown.js +25 -3
  64. package/dist/utils/stream-transform.js +20 -47
  65. package/dist/utils/tool-display.d.ts +4 -0
  66. package/dist/utils/tool-display.js +35 -8
  67. package/examples/extensions/claude-code-bridge/README.md +35 -0
  68. package/examples/extensions/claude-code-bridge/index.ts +194 -0
  69. package/examples/extensions/claude-code-bridge/package.json +11 -0
  70. package/examples/extensions/openrouter.ts +87 -0
  71. package/examples/extensions/pi-bridge/README.md +35 -0
  72. package/examples/extensions/pi-bridge/index.ts +263 -0
  73. package/examples/extensions/pi-bridge/package.json +13 -0
  74. package/examples/extensions/secret-guard.ts +100 -0
  75. package/examples/extensions/subagents.ts +87 -0
  76. package/package.json +3 -5
  77. package/dist/acp-client.d.ts +0 -105
  78. package/dist/acp-client.js +0 -684
  79. package/dist/extensions/shell-exec.d.ts +0 -24
  80. package/dist/extensions/shell-exec.js +0 -188
  81. package/dist/mcp-server.d.ts +0 -13
  82. package/dist/mcp-server.js +0 -234
  83. package/examples/pi-agent-sh.ts +0 -166
@@ -1,9 +1,28 @@
1
1
  export declare const CONFIG_DIR: string;
2
+ /** Provider profile — a named LLM configuration. */
3
+ export interface ProviderConfig {
4
+ /** API key (supports $ENV_VAR syntax for runtime expansion). */
5
+ apiKey?: string;
6
+ /** Base URL for OpenAI-compatible API. */
7
+ baseURL?: string;
8
+ /** Default model to use. Falls back to first entry in models list. */
9
+ defaultModel?: string;
10
+ /** Models available for cycling. */
11
+ models?: string[];
12
+ /** Context window size in tokens (e.g. 128000). Used for usage display. */
13
+ contextWindow?: number;
14
+ }
2
15
  export interface Settings {
3
16
  /** Extensions to load (npm packages or file paths). */
4
17
  extensions?: string[];
5
18
  /** Max agent query history entries to keep. */
6
19
  historySize?: number;
20
+ /** Named provider configurations. */
21
+ providers?: Record<string, ProviderConfig>;
22
+ /** Which provider to use by default. */
23
+ defaultProvider?: string;
24
+ /** Preferred agent backend (extension name, e.g. "pi", "claude-code"). */
25
+ defaultBackend?: string;
7
26
  /** Recent exchanges included in agent context window. */
8
27
  contextWindowSize?: number;
9
28
  /** Context budget in bytes (~4 chars per token). */
@@ -22,8 +41,12 @@ export interface Settings {
22
41
  readOutputMaxLines?: number;
23
42
  /** Max diff lines shown before "ctrl+o to expand". */
24
43
  diffMaxLines?: number;
25
- /** Register MCP server for bridge tools (shell_cwd, user_shell, shell_recall). Default true. */
26
- enableMcp?: boolean;
44
+ /** Additional directories to scan for skills (supports ~ expansion). */
45
+ skillPaths?: string[];
46
+ /** Show a startup banner when agent-sh launches. */
47
+ startupBanner?: boolean;
48
+ /** Show a subtle agent-sh indicator in the shell prompt. */
49
+ promptIndicator?: boolean;
27
50
  }
28
51
  declare const DEFAULTS: Required<Settings>;
29
52
  /** Load settings from disk (cached after first call). */
@@ -41,4 +64,32 @@ export declare function getSettings(): Settings & typeof DEFAULTS;
41
64
  export declare function getExtensionSettings<T extends Record<string, unknown>>(namespace: string, defaults: T): T;
42
65
  /** Reset cached settings (for testing or after external edit). */
43
66
  export declare function reloadSettings(): void;
67
+ /**
68
+ * Expand $ENV_VAR references in a string.
69
+ * Supports $VAR and ${VAR} syntax.
70
+ */
71
+ export declare function expandEnvVars(value: string): string;
72
+ /** Resolved provider ready for use (env vars expanded, defaults applied). */
73
+ export interface ResolvedProvider {
74
+ id: string;
75
+ apiKey?: string;
76
+ baseURL?: string;
77
+ defaultModel?: string;
78
+ models: string[];
79
+ contextWindow?: number;
80
+ /** Provider supports the reasoning_effort parameter. Default: true. */
81
+ supportsReasoningEffort?: boolean;
82
+ /** Per-model capabilities, keyed by model id. */
83
+ modelCapabilities?: Map<string, {
84
+ reasoning?: boolean;
85
+ contextWindow?: number;
86
+ }>;
87
+ }
88
+ /**
89
+ * Resolve a provider config by name from settings.
90
+ * Returns null if provider not found.
91
+ */
92
+ export declare function resolveProvider(name: string): ResolvedProvider | null;
93
+ /** Get all configured provider names. */
94
+ export declare function getProviderNames(): string[];
44
95
  export {};
package/dist/settings.js CHANGED
@@ -12,6 +12,9 @@ const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
12
12
  const DEFAULTS = {
13
13
  extensions: [],
14
14
  historySize: 500,
15
+ providers: {},
16
+ defaultProvider: undefined,
17
+ defaultBackend: "agent-sh",
15
18
  contextWindowSize: 20,
16
19
  contextBudget: 16384,
17
20
  shellTruncateThreshold: 10,
@@ -19,9 +22,11 @@ const DEFAULTS = {
19
22
  shellTailLines: 5,
20
23
  recallExpandMaxLines: 100,
21
24
  maxCommandOutputLines: 3,
22
- readOutputMaxLines: 0,
25
+ readOutputMaxLines: 10,
23
26
  diffMaxLines: 20,
24
- enableMcp: true,
27
+ skillPaths: [],
28
+ startupBanner: true,
29
+ promptIndicator: true,
25
30
  };
26
31
  let cached = null;
27
32
  /** Load settings from disk (cached after first call). */
@@ -31,7 +36,10 @@ export function getSettings() {
31
36
  const raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
32
37
  cached = JSON.parse(raw);
33
38
  }
34
- catch {
39
+ catch (err) {
40
+ if (err instanceof SyntaxError) {
41
+ console.error(`[agent-sh] Warning: invalid JSON in ${SETTINGS_PATH}: ${err.message}`);
42
+ }
35
43
  cached = {};
36
44
  }
37
45
  }
@@ -59,3 +67,38 @@ export function getExtensionSettings(namespace, defaults) {
59
67
  export function reloadSettings() {
60
68
  cached = null;
61
69
  }
70
+ /**
71
+ * Expand $ENV_VAR references in a string.
72
+ * Supports $VAR and ${VAR} syntax.
73
+ */
74
+ export function expandEnvVars(value) {
75
+ return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, braced, plain) => {
76
+ const name = braced || plain;
77
+ return process.env[name] ?? "";
78
+ });
79
+ }
80
+ /**
81
+ * Resolve a provider config by name from settings.
82
+ * Returns null if provider not found.
83
+ */
84
+ export function resolveProvider(name) {
85
+ const settings = getSettings();
86
+ const provider = settings.providers?.[name];
87
+ if (!provider)
88
+ return null;
89
+ const models = provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []);
90
+ const defaultModel = provider.defaultModel ?? models[0];
91
+ return {
92
+ id: name,
93
+ apiKey: provider.apiKey ? expandEnvVars(provider.apiKey) : undefined,
94
+ baseURL: provider.baseURL,
95
+ defaultModel,
96
+ models: models.length ? models : (defaultModel ? [defaultModel] : []),
97
+ contextWindow: provider.contextWindow,
98
+ };
99
+ }
100
+ /** Get all configured provider names. */
101
+ export function getProviderNames() {
102
+ const settings = getSettings();
103
+ return Object.keys(settings.providers ?? {});
104
+ }
package/dist/shell.js CHANGED
@@ -4,6 +4,7 @@ import * as path from "path";
4
4
  import * as pty from "node-pty";
5
5
  import { InputHandler } from "./input-handler.js";
6
6
  import { OutputParser } from "./output-parser.js";
7
+ import { getSettings } from "./settings.js";
7
8
  export class Shell {
8
9
  ptyProcess;
9
10
  bus;
@@ -40,13 +41,16 @@ export class Shell {
40
41
  let shellArgs;
41
42
  const osc7Cmd = 'printf "\\e]7;file://%s%s\\a" "$(hostname)" "$PWD"';
42
43
  const promptMarker = 'printf "\\e]9999;PROMPT\\a"';
44
+ const titleCmd = 'printf "\\e]0;⚡ agent-sh: %s\\a" "${PWD/#$HOME/~}"';
43
45
  this.isZsh = isZsh;
46
+ const settings = getSettings();
47
+ const showIndicator = settings.promptIndicator !== false;
44
48
  if (isZsh) {
45
49
  // For zsh: use ZDOTDIR to source user's real config, then append
46
50
  // our hooks via precmd_functions (additive — doesn't clobber p10k/omz).
47
51
  this.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-sh-"));
48
52
  const userZdotdir = env.ZDOTDIR || env.HOME || os.homedir();
49
- fs.writeFileSync(path.join(this.tmpDir, ".zshrc"), [
53
+ const zshrcLines = [
50
54
  `ZDOTDIR="${userZdotdir}"`,
51
55
  `[ -f "${userZdotdir}/.zshrc" ] && source "${userZdotdir}/.zshrc"`,
52
56
  "",
@@ -54,32 +58,19 @@ export class Shell {
54
58
  "__agent_sh_precmd() {",
55
59
  ` ${osc7Cmd}`,
56
60
  ` ${promptMarker}`,
61
+ ...(showIndicator ? [` ${titleCmd}`] : []),
57
62
  "}",
58
63
  "precmd_functions+=(__agent_sh_precmd)",
59
64
  "",
60
- "# End-of-prompt marker via zle-line-init (fires after prompt is rendered)",
61
- "# Chain onto existing widget (p10k uses zle-line-init) rather than clobbering",
62
- 'if (( ${+widgets[zle-line-init]} )); then',
63
- " zle -A zle-line-init __agent_sh_orig_line_init",
64
- " __agent_sh_line_init() {",
65
- " zle __agent_sh_orig_line_init",
66
- ' printf "\\e]9998;READY\\a"',
67
- " }",
68
- "else",
69
- " __agent_sh_line_init() {",
70
- ' printf "\\e]9998;READY\\a"',
71
- " }",
72
- "fi",
73
- "zle -N zle-line-init __agent_sh_line_init",
74
- "",
75
- "# Hidden widget to trigger prompt redraw from Node.js side",
76
- "# Bound to an unused escape sequence that no real key produces",
77
- "__agent_sh_redraw() {",
78
- " zle reset-prompt",
65
+ "# Preexec hook: emit actual command text so agent-sh can track",
66
+ "# history-recalled and tab-completed commands accurately",
67
+ "__agent_sh_preexec() {",
68
+ ' printf "\\e]9997;%s\\a" "$1"',
79
69
  "}",
80
- "zle -N __agent_sh_redraw",
81
- "bindkey '\\e[9999~' __agent_sh_redraw",
82
- ].join("\n") + "\n");
70
+ "preexec_functions+=(__agent_sh_preexec)",
71
+ ];
72
+ zshrcLines.push("", "# End-of-prompt marker via zle-line-init (fires after prompt is rendered)", "# Chain onto existing widget (p10k uses zle-line-init) rather than clobbering", 'if (( ${+widgets[zle-line-init]} )); then', " zle -A zle-line-init __agent_sh_orig_line_init", " __agent_sh_line_init() {", " zle __agent_sh_orig_line_init", ' printf "\\e]9998;READY\\a"', " }", "else", " __agent_sh_line_init() {", ' printf "\\e]9998;READY\\a"', " }", "fi", "zle -N zle-line-init __agent_sh_line_init", "", "# Hidden widget to trigger prompt redraw from Node.js side", "# Bound to an unused escape sequence that no real key produces", "__agent_sh_redraw() {", " zle reset-prompt", "}", "zle -N __agent_sh_redraw", "bindkey '\\e[9999~' __agent_sh_redraw");
73
+ fs.writeFileSync(path.join(this.tmpDir, ".zshrc"), zshrcLines.join("\n") + "\n");
83
74
  env.ZDOTDIR = this.tmpDir;
84
75
  shellArgs = ["--no-globalrcs"];
85
76
  }
@@ -88,15 +79,29 @@ export class Shell {
88
79
  // real bashrc then appends our hooks. No HOME override needed.
89
80
  this.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-sh-"));
90
81
  const userHome = env.HOME || os.homedir();
91
- fs.writeFileSync(path.join(this.tmpDir, ".bashrc"), [
82
+ const bashrcLines = [
92
83
  `[ -f "${userHome}/.bashrc" ] && source "${userHome}/.bashrc"`,
93
84
  "",
94
85
  "# agent-sh hooks (invisible OSC sequences for cwd + prompt detection)",
95
- `PROMPT_COMMAND="\${PROMPT_COMMAND:+\$PROMPT_COMMAND;}${osc7Cmd}; ${promptMarker}"`,
86
+ `PROMPT_COMMAND="\${PROMPT_COMMAND:+\$PROMPT_COMMAND;}__agent_sh_preexec_ran=0; ${osc7Cmd}; ${promptMarker}${showIndicator ? `; ${titleCmd}` : ""}"`,
87
+ "",
88
+ "# Preexec hook via DEBUG trap: emit actual command text so agent-sh",
89
+ "# can track history-recalled and tab-completed commands accurately",
90
+ "__agent_sh_preexec_ran=0",
91
+ "__agent_sh_emit_preexec() {",
92
+ ' [[ $__agent_sh_preexec_ran == 1 ]] && return',
93
+ ' [[ -n $COMP_LINE ]] && return',
94
+ " __agent_sh_preexec_ran=1",
95
+ " local this_cmd",
96
+ ` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
97
+ ` printf '\\e]9997;%s\\a' "$this_cmd"`,
98
+ "}",
99
+ "trap '__agent_sh_emit_preexec' DEBUG",
96
100
  "",
97
101
  "# End-of-prompt marker: append to PS1 (\\[...\\] marks it zero-width)",
98
102
  'case "$PS1" in *9998*) ;; *) PS1="${PS1}\\[\\e]9998;READY\\a\\]";; esac',
99
- ].join("\n") + "\n");
103
+ ];
104
+ fs.writeFileSync(path.join(this.tmpDir, ".bashrc"), bashrcLines.join("\n") + "\n");
100
105
  shellArgs = ["--rcfile", path.join(this.tmpDir, ".bashrc")];
101
106
  }
102
107
  // Pause stdin before spawning PTY to avoid TTY contention on macOS.
@@ -259,6 +264,7 @@ export class Shell {
259
264
  this.echoSkip = true;
260
265
  this.paused = false;
261
266
  process.stdout.write("\n");
267
+ this.bus.emit("shell:agent-exec-start", {});
262
268
  const output = await new Promise((resolve, reject) => {
263
269
  const timeout = setTimeout(() => {
264
270
  this.bus.off("shell:command-done", handler);
@@ -268,7 +274,7 @@ export class Shell {
268
274
  const handler = (e) => {
269
275
  clearTimeout(timeout);
270
276
  this.bus.off("shell:command-done", handler);
271
- resolve({ output: e.output, cwd: e.cwd });
277
+ resolve({ output: e.output, cwd: e.cwd, exitCode: e.exitCode });
272
278
  };
273
279
  this.bus.on("shell:command-done", handler);
274
280
  this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
@@ -276,7 +282,8 @@ export class Shell {
276
282
  });
277
283
  this.paused = true;
278
284
  this.echoSkip = false;
279
- return { ...payload, output: output.output, cwd: output.cwd, done: true };
285
+ this.bus.emit("shell:agent-exec-done", {});
286
+ return { ...payload, output: output.output, cwd: output.cwd, exitCode: output.exitCode, done: true };
280
287
  });
281
288
  }
282
289
  // ── Public API (used by index.ts) ──
package/dist/types.d.ts CHANGED
@@ -1,18 +1,38 @@
1
1
  import type { EventBus } from "./event-bus.js";
2
2
  import type { ContextManager } from "./context-manager.js";
3
- import type { AcpClient } from "./acp-client.js";
3
+ import type { LlmClient } from "./utils/llm-client.js";
4
4
  import type { ColorPalette } from "./utils/palette.js";
5
5
  import type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
6
+ import type { ToolDefinition } from "./agent/types.js";
6
7
  export type { ContentBlock } from "./event-bus.js";
7
8
  export type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
9
+ /** A model entry in the cycling list, optionally tied to a provider. */
10
+ export interface AgentMode {
11
+ model: string;
12
+ /** Provider id — when cycling changes provider, LlmClient is reconfigured. */
13
+ provider?: string;
14
+ /** Provider-specific config for reconfiguring LlmClient on switch. */
15
+ providerConfig?: {
16
+ apiKey: string;
17
+ baseURL?: string;
18
+ };
19
+ /** Context window size in tokens (for usage display). */
20
+ contextWindow?: number;
21
+ /** Model supports reasoning/thinking tokens. */
22
+ reasoning?: boolean;
23
+ /** Provider supports the reasoning_effort parameter. */
24
+ supportsReasoningEffort?: boolean;
25
+ }
8
26
  export interface AgentShellConfig {
9
- agentCommand: string;
10
- agentArgs: string[];
11
27
  shell?: string;
12
28
  model?: string;
13
29
  extensions?: string[];
14
- /** Full shell environment (from user's rc files) for agent subprocess. */
15
- shellEnv?: Record<string, string>;
30
+ /** API key for OpenAI-compatible provider. */
31
+ apiKey?: string;
32
+ /** Base URL for OpenAI-compatible API. */
33
+ baseURL?: string;
34
+ /** Named provider to use from settings.json. */
35
+ provider?: string;
16
36
  }
17
37
  /**
18
38
  * Context passed to user/third-party extensions.
@@ -23,7 +43,8 @@ export interface AgentShellConfig {
23
43
  export interface ExtensionContext {
24
44
  bus: EventBus;
25
45
  contextManager: ContextManager;
26
- getAcpClient: () => AcpClient;
46
+ /** LLM client for fast-path features (null in ACP mode). */
47
+ llmClient: LlmClient | null;
27
48
  quit: () => void;
28
49
  /** Override color palette slots for theming. */
29
50
  setPalette: (overrides: Partial<ColorPalette>) => void;
@@ -33,6 +54,12 @@ export interface ExtensionContext {
33
54
  createFencedBlockTransform: (opts: FencedBlockTransformOptions) => void;
34
55
  /** Read extension-namespaced settings from ~/.agent-sh/settings.json. */
35
56
  getExtensionSettings: <T extends Record<string, unknown>>(namespace: string, defaults: T) => T;
57
+ /** Register a slash command available in any input mode. */
58
+ registerCommand: (name: string, description: string, handler: (args: string) => Promise<void> | void) => void;
59
+ /** Register a tool for the built-in agent. No-op when using bridge backends. */
60
+ registerTool: (tool: ToolDefinition) => void;
61
+ /** Get all registered tools (for subagent tool subsets). Returns [] when using bridge backends. */
62
+ getTools: () => ToolDefinition[];
36
63
  /** Register a named handler. */
37
64
  define: (name: string, fn: (...args: any[]) => any) => void;
38
65
  /** Wrap a named handler. Receives `next` (original) + args. */
@@ -6,8 +6,10 @@ export interface BoxFrameOptions {
6
6
  style?: BorderStyle;
7
7
  /** Border color (ANSI escape). Default DIM. */
8
8
  borderColor?: string;
9
- /** Title text shown in the top border. */
9
+ /** Title text shown on the left of the top border. */
10
10
  title?: string;
11
+ /** Title text shown on the right of the top border. */
12
+ titleRight?: string;
11
13
  /** Footer lines shown below a divider, inside the box. */
12
14
  footer?: string[];
13
15
  }
@@ -30,11 +30,18 @@ export function renderBoxFrame(content, opts) {
30
30
  // Content area width = total - 2 borders - 2 padding spaces
31
31
  const innerW = Math.max(1, width - 4);
32
32
  const output = [];
33
- // Top border (with optional title)
34
- if (opts.title) {
35
- const titleVis = visibleLen(opts.title);
36
- const afterDashes = Math.max(1, width - titleVis - 4);
37
- output.push(`${bc}${b.tl}${p.reset} ${opts.title} ${bc}${b.h.repeat(afterDashes)}${b.tr}${p.reset}`);
33
+ // Top border (with optional left/right titles)
34
+ if (opts.title || opts.titleRight) {
35
+ const leftPart = opts.title
36
+ ? `${p.reset} ${opts.title} ${bc}`
37
+ : "";
38
+ const leftVis = opts.title ? visibleLen(opts.title) + 2 : 0; // +2 for spaces
39
+ const rightPart = opts.titleRight
40
+ ? `${p.reset} ${opts.titleRight} ${bc}`
41
+ : "";
42
+ const rightVis = opts.titleRight ? visibleLen(opts.titleRight) + 2 : 0;
43
+ const dashCount = Math.max(1, width - 2 - leftVis - rightVis);
44
+ output.push(`${bc}${b.tl}${leftPart}${b.h.repeat(dashCount)}${rightPart}${b.tr}${p.reset}`);
38
45
  }
39
46
  else {
40
47
  output.push(`${bc}${b.tl}${b.h.repeat(width - 2)}${b.tr}${p.reset}`);
@@ -39,6 +39,16 @@ export function computeDiff(oldText, newText) {
39
39
  // Build LCS table and backtrack to produce diff lines
40
40
  const a = oldText.split("\n");
41
41
  const b = newText.split("\n");
42
+ // Bail out if LCS table would be too large (avoids OOM / hang)
43
+ if (a.length * b.length > 10_000_000) {
44
+ return {
45
+ hunks: [],
46
+ added: b.length,
47
+ removed: a.length,
48
+ isIdentical: false,
49
+ isNewFile: false,
50
+ };
51
+ }
42
52
  const dp = buildLcs(a, b);
43
53
  const raw = backtrack(dp, a, b);
44
54
  let added = 0;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Thin, stateless wrapper around the OpenAI SDK.
3
+ * No agent-sh knowledge — just a configured client.
4
+ *
5
+ * Used by both AgentLoop (full tool loop) and fast-path features
6
+ * (command suggestions, completions).
7
+ */
8
+ import OpenAI from "openai";
9
+ import type { ChatCompletionMessageParam, ChatCompletionTool } from "openai/resources/chat/completions.js";
10
+ export type { ChatCompletionMessageParam, ChatCompletionTool };
11
+ export interface LlmClientConfig {
12
+ apiKey: string;
13
+ baseURL?: string;
14
+ model: string;
15
+ }
16
+ export declare class LlmClient {
17
+ private config;
18
+ private client;
19
+ model: string;
20
+ constructor(config: LlmClientConfig);
21
+ /** Swap the underlying client config at runtime (e.g. provider switch). */
22
+ reconfigure(newConfig: LlmClientConfig): void;
23
+ /**
24
+ * Create a streaming chat completion.
25
+ * Returns an async iterable of chunks.
26
+ */
27
+ stream(opts: {
28
+ messages: ChatCompletionMessageParam[];
29
+ tools?: ChatCompletionTool[];
30
+ model?: string;
31
+ max_tokens?: number;
32
+ /** Reasoning effort level (e.g. "low", "medium", "high"). Provider-dependent. */
33
+ reasoning_effort?: string;
34
+ signal?: AbortSignal;
35
+ }): import("openai").APIPromise<import("openai/core/streaming.mjs").Stream<OpenAI.Chat.Completions.ChatCompletionChunk>>;
36
+ /**
37
+ * Single-shot completion (no streaming) — for fast-path features.
38
+ * Returns the text content of the first choice.
39
+ */
40
+ complete(opts: {
41
+ messages: ChatCompletionMessageParam[];
42
+ model?: string;
43
+ max_tokens?: number;
44
+ }): Promise<string>;
45
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Thin, stateless wrapper around the OpenAI SDK.
3
+ * No agent-sh knowledge — just a configured client.
4
+ *
5
+ * Used by both AgentLoop (full tool loop) and fast-path features
6
+ * (command suggestions, completions).
7
+ */
8
+ import OpenAI from "openai";
9
+ export class LlmClient {
10
+ config;
11
+ client;
12
+ model;
13
+ constructor(config) {
14
+ this.config = config;
15
+ this.client = new OpenAI({
16
+ apiKey: config.apiKey,
17
+ baseURL: config.baseURL,
18
+ });
19
+ this.model = config.model;
20
+ }
21
+ /** Swap the underlying client config at runtime (e.g. provider switch). */
22
+ reconfigure(newConfig) {
23
+ this.config = newConfig;
24
+ this.client = new OpenAI({
25
+ apiKey: newConfig.apiKey,
26
+ baseURL: newConfig.baseURL,
27
+ });
28
+ this.model = newConfig.model;
29
+ }
30
+ /**
31
+ * Create a streaming chat completion.
32
+ * Returns an async iterable of chunks.
33
+ */
34
+ stream(opts) {
35
+ const body = {
36
+ model: opts.model ?? this.model,
37
+ messages: opts.messages,
38
+ tools: opts.tools?.length ? opts.tools : undefined,
39
+ max_tokens: opts.max_tokens ?? 8192,
40
+ stream: true,
41
+ stream_options: { include_usage: true },
42
+ ...(opts.reasoning_effort
43
+ ? { reasoning_effort: opts.reasoning_effort }
44
+ : {}),
45
+ };
46
+ return this.client.chat.completions.create(body, { signal: opts.signal });
47
+ }
48
+ /**
49
+ * Single-shot completion (no streaming) — for fast-path features.
50
+ * Returns the text content of the first choice.
51
+ */
52
+ async complete(opts) {
53
+ const response = await this.client.chat.completions.create({
54
+ model: opts.model ?? this.model,
55
+ messages: opts.messages,
56
+ max_tokens: opts.max_tokens ?? 1024,
57
+ });
58
+ return response.choices[0]?.message?.content ?? "";
59
+ }
60
+ }
@@ -15,6 +15,7 @@ export declare class MarkdownRenderer {
15
15
  private buffer;
16
16
  private contentWidth;
17
17
  private firstLine;
18
+ private lastLineBlank;
18
19
  private pendingLines;
19
20
  private width;
20
21
  private tableRows;
@@ -83,6 +83,7 @@ export class MarkdownRenderer {
83
83
  buffer = "";
84
84
  contentWidth;
85
85
  firstLine = true;
86
+ lastLineBlank = false;
86
87
  pendingLines = [];
87
88
  width;
88
89
  tableRows = [];
@@ -192,6 +193,9 @@ export class MarkdownRenderer {
192
193
  }
193
194
  // Render rows
194
195
  const hasHeader = sepIdx.includes(1) && dataRows.length > 1;
196
+ // Top border
197
+ const topBorder = colWidths.map((w) => "─".repeat(w)).join(`─┬─`);
198
+ this.writeLine(`${p.dim}┌─${topBorder}─┐${p.reset}`);
195
199
  for (let i = 0; i < dataRows.length; i++) {
196
200
  const row = dataRows[i];
197
201
  const isHeader = hasHeader && i === 0;
@@ -207,6 +211,9 @@ export class MarkdownRenderer {
207
211
  this.writeLine(`${p.dim}├─${sep}─┤${p.reset}`);
208
212
  }
209
213
  }
214
+ // Bottom border
215
+ const bottomBorder = colWidths.map((w) => "─".repeat(w)).join(`─┴─`);
216
+ this.writeLine(`${p.dim}└─${bottomBorder}─┘${p.reset}`);
210
217
  }
211
218
  renderLine(line) {
212
219
  if (line.trim() === "")
@@ -224,14 +231,24 @@ export class MarkdownRenderer {
224
231
  const h4 = line.match(/^#{4,} (.+)/);
225
232
  if (h4)
226
233
  return `${p.bold}${h4[1]}${p.reset}`;
227
- // Horizontal rule
234
+ // Horizontal rule — subtle short separator, not full-width
228
235
  if (/^(-{3,}|_{3,}|\*{3,})\s*$/.test(line)) {
229
- return `${p.muted}${"".repeat(this.contentWidth)}${p.reset}`;
236
+ return "";
230
237
  }
231
238
  // Blockquote
232
239
  const bq = line.match(/^>\s?(.*)/);
233
240
  if (bq)
234
241
  return `${p.muted}│${p.reset} ${p.dim}${p.italic}${this.renderInline(bq[1] || "")}${p.reset}`;
242
+ // Task list (checkbox items) — must come before generic unordered list
243
+ const task = line.match(/^(\s*)[*\-+]\s+\[([ xX])\]\s+(.*)/);
244
+ if (task) {
245
+ const indent = task[1] || "";
246
+ const checked = task[2] !== " ";
247
+ const box = checked
248
+ ? `${p.success}☑${p.reset}`
249
+ : `${p.dim}☐${p.reset}`;
250
+ return `${indent} ${box} ${this.renderInline(task[3] || "")}`;
251
+ }
235
252
  // Unordered list
236
253
  const ul = line.match(/^(\s*)[*\-+]\s+(.*)/);
237
254
  if (ul) {
@@ -268,9 +285,14 @@ export class MarkdownRenderer {
268
285
  * The line is accumulated internally — call drainLines() to extract.
269
286
  */
270
287
  writeLine(text) {
271
- if (this.firstLine && visibleLen(text) === 0)
288
+ const isBlank = visibleLen(text) === 0;
289
+ if (this.firstLine && isBlank)
290
+ return;
291
+ // Collapse consecutive blank lines to a single one
292
+ if (isBlank && this.lastLineBlank)
272
293
  return;
273
294
  this.firstLine = false;
295
+ this.lastLineBlank = isBlank;
274
296
  this.pendingLines.push(` ${text}`);
275
297
  }
276
298
  }