nou-cli 0.5.1 → 0.5.2

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.
@@ -0,0 +1,106 @@
1
+ import { readFileSync } from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ function json(file) {
5
+ try {
6
+ return JSON.parse(readFileSync(file, "utf-8"));
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ /** Keeps the fields Nou uses, from a server entry in any of these formats. */
13
+ function normalize(raw) {
14
+ if (!raw || typeof raw !== "object")
15
+ return null;
16
+ const out = {};
17
+ if (typeof raw.command === "string")
18
+ out.command = raw.command;
19
+ if (Array.isArray(raw.args))
20
+ out.args = raw.args.map(String);
21
+ if (raw.env && typeof raw.env === "object")
22
+ out.env = Object.fromEntries(Object.entries(raw.env).map(([k, v]) => [k, String(v)]));
23
+ if (typeof raw.cwd === "string")
24
+ out.cwd = raw.cwd;
25
+ const url = raw.url ?? raw.serverUrl ?? raw.httpUrl;
26
+ if (typeof url === "string")
27
+ out.url = url;
28
+ if (raw.headers && typeof raw.headers === "object")
29
+ out.headers = Object.fromEntries(Object.entries(raw.headers).map(([k, v]) => [k, String(v)]));
30
+ if (raw.disabled === true)
31
+ out.disabled = true;
32
+ return out.command || out.url ? out : null;
33
+ }
34
+ /** The [mcp_servers.<name>] tables of a Codex config.toml (just the keys MCP servers use). */
35
+ export function codexServers(toml) {
36
+ const out = {};
37
+ let current = null;
38
+ let sub = null;
39
+ const value = (v) => {
40
+ v = v.trim();
41
+ if (v.startsWith("\"") || v.startsWith("'"))
42
+ return v.slice(1, v.lastIndexOf(v[0])).replace(/\\"/g, "\"");
43
+ if (v.startsWith("["))
44
+ return [...v.matchAll(/"((?:\\.|[^"\\])*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
45
+ if (v.startsWith("{"))
46
+ return Object.fromEntries([...v.matchAll(/([\w-]+)\s*=\s*"((?:\\.|[^"\\])*)"/g)].map((m) => [m[1], m[2]]));
47
+ if (v === "true" || v === "false")
48
+ return v === "true";
49
+ return v;
50
+ };
51
+ for (const raw of toml.split(/\r?\n/)) {
52
+ const line = raw.replace(/\s+#.*$/, "").trim();
53
+ const table = line.match(/^\[mcp_servers\.("?)([^"\].]+)\1(?:\.(\w+))?\]$/);
54
+ if (table) {
55
+ out[table[2]] ??= {};
56
+ current = out[table[2]];
57
+ sub = table[3] ?? null;
58
+ if (sub)
59
+ current[sub] ??= {};
60
+ continue;
61
+ }
62
+ if (line.startsWith("[")) {
63
+ current = null;
64
+ continue;
65
+ }
66
+ const kv = line.match(/^([\w-]+)\s*=\s*(.+)$/);
67
+ if (current && kv)
68
+ (sub ? current[sub] : current)[kv[1]] = value(kv[2]);
69
+ }
70
+ return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, normalize({ ...v, disabled: v.enabled === false })]).filter(([, v]) => v));
71
+ }
72
+ /** Every MCP server set up in the other tools on this machine (and in this project), first source wins per name. */
73
+ export function findOtherMcpServers(home = os.homedir(), cwd = process.cwd()) {
74
+ const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
75
+ const claudeDesktop = process.platform === "darwin"
76
+ ? path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
77
+ : process.platform === "win32" ? path.join(appData, "Claude", "claude_desktop_config.json") : path.join(home, ".config", "Claude", "claude_desktop_config.json");
78
+ const claudeCode = json(path.join(home, ".claude.json"));
79
+ const sources = [
80
+ { from: "Claude Code", servers: claudeCode?.mcpServers },
81
+ { from: "Claude Code (this project)", servers: claudeCode?.projects?.[cwd]?.mcpServers },
82
+ { from: "Claude Desktop", servers: json(claudeDesktop)?.mcpServers },
83
+ { from: "Cursor", servers: json(path.join(home, ".cursor", "mcp.json"))?.mcpServers },
84
+ { from: "Cursor (this project)", servers: json(path.join(cwd, ".cursor", "mcp.json"))?.mcpServers },
85
+ { from: "Windsurf", servers: json(path.join(home, ".codeium", "windsurf", "mcp_config.json"))?.mcpServers },
86
+ { from: "VS Code (this project)", servers: json(path.join(cwd, ".vscode", "mcp.json"))?.servers },
87
+ { from: "Gemini CLI", servers: json(path.join(home, ".gemini", "settings.json"))?.mcpServers },
88
+ ];
89
+ try {
90
+ sources.push({ from: "Codex", servers: codexServers(readFileSync(path.join(home, ".codex", "config.toml"), "utf-8")) });
91
+ }
92
+ catch {
93
+ // no Codex config
94
+ }
95
+ const found = [];
96
+ for (const { from, servers } of sources) {
97
+ if (!servers || typeof servers !== "object")
98
+ continue;
99
+ for (const [name, raw] of Object.entries(servers)) {
100
+ const config = normalize(raw);
101
+ if (config && !found.some((f) => f.name === name))
102
+ found.push({ name, config, from });
103
+ }
104
+ }
105
+ return found;
106
+ }
@@ -0,0 +1,184 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import path from "path";
3
+ import { NOU_HOME } from "../config/index.js";
4
+ export const USER_MCP_FILE = path.join(NOU_HOME, "mcp.json");
5
+ /** Project MCP files (only read in a folder you trust: they start programs). */
6
+ const PROJECT_MCP_FILES = [".mcp.json", path.join(".nou", "mcp.json")];
7
+ /** Ready-made servers: `/mcp add browser` and so on. */
8
+ export const MCP_PRESETS = {
9
+ browser: { config: { command: "npx", args: ["-y", "@playwright/mcp@latest"] }, about: "control a browser: open pages, click, type, read them, take screenshots (Playwright)" },
10
+ fetch: { config: { command: "uvx", args: ["mcp-server-fetch"] }, about: "fetch web pages as text (needs uv)" },
11
+ };
12
+ function readServers(file) {
13
+ try {
14
+ const json = JSON.parse(readFileSync(file, "utf-8"));
15
+ const servers = json?.mcpServers ?? json?.servers ?? {};
16
+ return typeof servers === "object" && servers ? servers : {};
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ /** Every configured server: yours (~/.nou/mcp.json) and, in a trusted folder, the project's. */
23
+ export function loadMcpServers(trusted) {
24
+ const out = [];
25
+ for (const [name, config] of Object.entries(readServers(USER_MCP_FILE)))
26
+ out.push({ name, config, scope: "user", file: USER_MCP_FILE });
27
+ if (trusted) {
28
+ for (const rel of PROJECT_MCP_FILES) {
29
+ const file = path.resolve(rel);
30
+ for (const [name, config] of Object.entries(readServers(file))) {
31
+ if (!out.some((e) => e.name === name))
32
+ out.push({ name, config, scope: "project", file });
33
+ }
34
+ }
35
+ }
36
+ return out.filter((e) => e.config && (e.config.command || e.config.url));
37
+ }
38
+ /** Adds (or replaces) servers in ~/.nou/mcp.json. */
39
+ export function saveUserServers(servers) {
40
+ let json = {};
41
+ try {
42
+ json = JSON.parse(readFileSync(USER_MCP_FILE, "utf-8"));
43
+ }
44
+ catch {
45
+ // a new file
46
+ }
47
+ json.mcpServers = { ...(json.mcpServers ?? {}), ...servers };
48
+ if (!existsSync(NOU_HOME))
49
+ mkdirSync(NOU_HOME, { recursive: true });
50
+ writeFileSync(USER_MCP_FILE, JSON.stringify(json, null, 2), { mode: 0o600 });
51
+ try {
52
+ chmodSync(USER_MCP_FILE, 0o600); // it can hold tokens
53
+ }
54
+ catch {
55
+ // Windows ignores it
56
+ }
57
+ }
58
+ export function removeUserServer(name) {
59
+ try {
60
+ const json = JSON.parse(readFileSync(USER_MCP_FILE, "utf-8"));
61
+ if (!json.mcpServers?.[name])
62
+ return false;
63
+ delete json.mcpServers[name];
64
+ writeFileSync(USER_MCP_FILE, JSON.stringify(json, null, 2), { mode: 0o600 });
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /** Tool names the model sees: mcp__server__tool, in the characters every provider accepts. */
72
+ const toolName = (server, tool) => `mcp__${server}__${tool}`.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
73
+ /** Keeps the end of a server's stderr, to say why it failed (it never reaches the screen). */
74
+ function tailOf(stream) {
75
+ let text = "";
76
+ stream?.on("data", (chunk) => {
77
+ text = (text + String(chunk)).slice(-2000);
78
+ });
79
+ return () => text.trim().split("\n").slice(-3).join(" ").slice(0, 300);
80
+ }
81
+ /**
82
+ * Connects to the MCP servers and turns their tools into tools for Nou's engine. A server's output goes to
83
+ * a buffer, never the terminal, so it can't scribble over Nou's screen; tools that change things ask
84
+ * first, like Nou's own.
85
+ */
86
+ export class McpManager {
87
+ onChange;
88
+ servers = new Map();
89
+ constructor(onChange) {
90
+ this.onChange = onChange;
91
+ }
92
+ status() {
93
+ return [...this.servers.values()].map((s) => s.status);
94
+ }
95
+ /** Connects to every enabled server in `entries` (already connected ones are kept). */
96
+ async connect(entries) {
97
+ const wanted = new Set(entries.map((e) => e.name));
98
+ for (const [name, s] of this.servers) {
99
+ if (!wanted.has(name)) {
100
+ void s.client?.close().catch(() => { });
101
+ this.servers.delete(name);
102
+ }
103
+ }
104
+ await Promise.all(entries.filter((e) => !this.servers.has(e.name)).map((e) => this.start(e)));
105
+ }
106
+ async start(entry) {
107
+ const status = { name: entry.name, scope: entry.scope, state: entry.config.disabled ? "off" : "connecting", tools: 0 };
108
+ const slot = { status, tools: [] };
109
+ this.servers.set(entry.name, slot);
110
+ this.onChange();
111
+ if (entry.config.disabled)
112
+ return;
113
+ let stderr = () => "";
114
+ try {
115
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
116
+ const client = new Client({ name: "nou", version: "1" });
117
+ let transport;
118
+ if (entry.config.url) {
119
+ const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
120
+ transport = new StreamableHTTPClientTransport(new URL(entry.config.url), entry.config.headers ? { requestInit: { headers: entry.config.headers } } : undefined);
121
+ }
122
+ else {
123
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
124
+ transport = new StdioClientTransport({
125
+ command: entry.config.command,
126
+ args: entry.config.args ?? [],
127
+ // The SDK adds a safe base (PATH, HOME...) to these: your API keys aren't passed to every server.
128
+ ...(entry.config.env ? { env: entry.config.env } : {}),
129
+ cwd: entry.config.cwd ?? process.cwd(),
130
+ stderr: "pipe",
131
+ });
132
+ stderr = tailOf(transport.stderr);
133
+ }
134
+ await withTimeout(client.connect(transport), 30_000, "didn't answer within 30 seconds");
135
+ const { tools } = await withTimeout(client.listTools(), 15_000, "didn't list its tools");
136
+ Object.assign(slot, { client, transport, tools });
137
+ slot.status = { ...status, state: "ready", tools: tools.length };
138
+ }
139
+ catch (error) {
140
+ const why = stderr();
141
+ slot.status = { ...status, state: "failed", error: `${error?.message ?? error}${why ? ` (${why})` : ""}` };
142
+ }
143
+ this.onChange();
144
+ }
145
+ /** The connected servers' tools, ready for the engine. */
146
+ tools() {
147
+ const out = [];
148
+ for (const [server, s] of this.servers) {
149
+ if (s.status.state !== "ready")
150
+ continue;
151
+ for (const tool of s.tools) {
152
+ const schema = tool.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {};
153
+ out.push({
154
+ definition: {
155
+ name: toolName(server, tool.name),
156
+ description: `[${server}] ${tool.description ?? tool.name}`.slice(0, 1000),
157
+ parameters: { ...schema, type: "object", properties: schema.properties ?? {}, required: Array.isArray(schema.required) ? schema.required : [] },
158
+ },
159
+ // Only tools that say they don't change anything run without asking.
160
+ readOnly: tool.annotations?.readOnlyHint === true,
161
+ source: `mcp:${server}`,
162
+ run: async (args) => {
163
+ const result = await s.client.callTool({ name: tool.name, arguments: args }, undefined, { timeout: 180_000 });
164
+ const parts = (result.content ?? []).map((c) => (c.type === "text" ? c.text : c.type === "resource" ? c.resource?.text ?? "" : `[${c.type}]`));
165
+ const text = parts.join("\n").trim() || "(no output)";
166
+ return result.isError ? `Error: ${text}` : text;
167
+ },
168
+ });
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+ close() {
174
+ for (const s of this.servers.values())
175
+ void s.client?.close().catch(() => { });
176
+ this.servers.clear();
177
+ }
178
+ }
179
+ function withTimeout(promise, ms, why) {
180
+ return new Promise((resolve, reject) => {
181
+ const timer = setTimeout(() => reject(new Error(why)), ms);
182
+ promise.then((v) => (clearTimeout(timer), resolve(v)), (e) => (clearTimeout(timer), reject(e)));
183
+ });
184
+ }
@@ -37,6 +37,21 @@ function toAnthropic(messages) {
37
37
  }
38
38
  return out;
39
39
  }
40
+ /**
41
+ * Marks the end of the conversation for prompt caching, so the next request (every tool step of a turn)
42
+ * reads all of it from the cache instead of paying for it again: cached input costs a tenth or less.
43
+ */
44
+ function withCacheBreakpoint(params) {
45
+ const last = params[params.length - 1];
46
+ if (!last)
47
+ return params;
48
+ const content = typeof last.content === "string" ? [{ type: "text", text: last.content }] : [...last.content];
49
+ const end = content[content.length - 1];
50
+ if (!end || end.type === "thinking" || end.type === "redacted_thinking")
51
+ return params;
52
+ content[content.length - 1] = { ...end, cache_control: { type: "ephemeral" } };
53
+ return [...params.slice(0, -1), { ...last, content }];
54
+ }
40
55
  function hasCredentials() {
41
56
  return !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_PROFILE)
42
57
  || existsSync(path.join(os.homedir(), ".config", "anthropic"));
@@ -46,8 +61,9 @@ export function anthropicProvider() {
46
61
  let client = null;
47
62
  const getClient = () => {
48
63
  const auth = `${process.env.ANTHROPIC_API_KEY}|${process.env.ANTHROPIC_AUTH_TOKEN}|${process.env.ANTHROPIC_BASE_URL}`;
64
+ // No retries here: the agent retries failed requests itself (with backoff), and both would multiply.
49
65
  if (client?.auth !== auth)
50
- client = { auth, api: new Anthropic() };
66
+ client = { auth, api: new Anthropic({ maxRetries: 0 }) };
51
67
  return client.api;
52
68
  };
53
69
  return {
@@ -72,7 +88,7 @@ export function anthropicProvider() {
72
88
  model,
73
89
  max_tokens: 64000,
74
90
  system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
75
- messages: toAnthropic(messages),
91
+ messages: withCacheBreakpoint(toAnthropic(messages)),
76
92
  tools: tools.map((t) => ({
77
93
  name: t.name,
78
94
  description: t.description,
@@ -80,6 +96,8 @@ export function anthropicProvider() {
80
96
  eager_input_streaming: true,
81
97
  })),
82
98
  stream: true,
99
+ // Current models think between tool calls; summarized, that shows what they're doing instead of a silence.
100
+ ...(EFFORT_MODELS.test(model) ? { thinking: { type: "adaptive", display: "summarized" } } : {}),
83
101
  ...(effort ? { output_config: { effort: effort } } : {}),
84
102
  ...(fallbacks ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" } : {}),
85
103
  };
@@ -87,7 +105,20 @@ export function anthropicProvider() {
87
105
  let text = "";
88
106
  try {
89
107
  const stream = getClient().beta.messages.stream(body, signal ? { signal } : {});
108
+ // Thinking streams as a quote above the reply (the chat shows quotes dimmed); only the reply is kept.
109
+ let thinking = false;
110
+ stream.on("thinking", (delta) => {
111
+ const piece = `${thinking ? "" : "> "}${delta.replace(/\n/g, "\n> ")}`;
112
+ thinking = true;
113
+ text += piece;
114
+ onText(piece);
115
+ });
90
116
  stream.on("text", (delta) => {
117
+ if (thinking) {
118
+ thinking = false;
119
+ text += "\n\n";
120
+ onText("\n\n");
121
+ }
91
122
  text += delta;
92
123
  onText(delta);
93
124
  });
@@ -27,6 +27,19 @@ export function resolveModel(spec) {
27
27
  export const describe = (choice) => `${choice.provider.id}:${choice.model}`;
28
28
  /** Whether a provider error means the API key is wrong (rather than, say, the server being down). */
29
29
  export const rejectedKey = (error) => /\b(401|403)\b|api key|unauthori[sz]ed|authentication/i.test(error);
30
+ /**
31
+ * Whether an error is the provider's setup rather than the request: a rejected key, a URL that doesn't
32
+ * answer, or a model it doesn't have. Each has a place in settings to fix it.
33
+ */
34
+ export function setupProblem(provider, error) {
35
+ if (rejectedKey(error) && provider.connect?.some((f) => f.secret))
36
+ return "key";
37
+ if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|getaddrinfo|fetch failed|Connection error|Invalid URL|connect ETIMEDOUT/i.test(error) && provider.connect?.some((f) => !f.secret))
38
+ return "url";
39
+ if (/model\b[^.]*\b(not found|does not exist|not exist|invalid|unknown|not available|not supported)|(invalid|unknown|no such) model|model_not_found/i.test(error))
40
+ return "model";
41
+ return null;
42
+ }
30
43
  /**
31
44
  * For an error that says the key was rejected: which key, where it was set, and how to fix it.
32
45
  * The usual cause is a project's .env holding a key for the app rather than for Nou.
@@ -52,7 +52,7 @@ export function openAICompatibleProvider(preset) {
52
52
  const url = baseURL();
53
53
  const key = apiKey();
54
54
  if (client?.url !== url || client.key !== key)
55
- client = { url, key, api: new OpenAI({ baseURL: url, apiKey: key }) };
55
+ client = { url, key, api: new OpenAI({ baseURL: url, apiKey: key, maxRetries: 0 }) };
56
56
  return client.api;
57
57
  };
58
58
  const connect = [
@@ -73,9 +73,13 @@ export function openAICompatibleProvider(preset) {
73
73
  return `set ${preset.keyEnv}`;
74
74
  return null;
75
75
  },
76
- async chat({ model, system, messages, tools, onText, signal }) {
76
+ async chat({ model, system, messages, tools, onText, signal, effort }) {
77
+ // OpenAI's reasoning models take an effort too (named in the model id, through a gateway too). Nou's
78
+ // default, high, is their default, medium; xhigh and max are their high.
79
+ const reasoning = effort && /(^|\/)(gpt-5|o\d)/.test(model) && !/-chat|o1-(preview|mini)/.test(model) ? { low: "low", medium: "low", high: "medium" }[effort] ?? "high" : null;
77
80
  const stream = await getClient().chat.completions.create({
78
81
  model,
82
+ ...(reasoning ? { reasoning_effort: reasoning } : {}),
79
83
  messages: toOpenAI(system, messages),
80
84
  ...(tools.length ? { tools: tools.map((t) => ({ type: "function", function: t })) } : {}),
81
85
  stream: true,
@@ -2,8 +2,8 @@ import { existsSync } from "fs";
2
2
  import { createRequire } from "module";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
5
- import chalk from "chalk";
6
5
  import { Language, Parser } from "web-tree-sitter";
6
+ import { themeColors } from "./themes.js";
7
7
  /** File extension -> grammar name (tree-sitter-<name>.wasm). Only these are shipped with Nou. */
8
8
  const GRAMMARS = {
9
9
  ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "tsx",
@@ -30,6 +30,11 @@ let init = null;
30
30
  const languages = new Map();
31
31
  let parser = null;
32
32
  const listeners = new Set();
33
+ /** Tells views to re-highlight (a grammar or the fallback highlighter just loaded). */
34
+ export function notifySyntaxLoaded() {
35
+ for (const l of listeners)
36
+ l();
37
+ }
33
38
  /** Called when a grammar finishes loading, so views can re-highlight. */
34
39
  export function onGrammarLoaded(listener) {
35
40
  listeners.add(listener);
@@ -51,8 +56,7 @@ function load(name) {
51
56
  if (!dir)
52
57
  throw new Error("grammars not found");
53
58
  languages.set(name, await Language.load(path.join(dir, `tree-sitter-${name}.wasm`)));
54
- for (const l of listeners)
55
- l();
59
+ notifySyntaxLoaded();
56
60
  })
57
61
  .catch(() => languages.set(name, "failed"));
58
62
  }
@@ -87,20 +91,6 @@ export function preload(file) {
87
91
  if (name)
88
92
  load(name);
89
93
  }
90
- // --- highlighting --------------------------------------------------------------
91
- const COLORS = {
92
- keyword: chalk.hex("#bb9af7"),
93
- string: chalk.hex("#9ece6a"),
94
- number: chalk.hex("#ff9e64"),
95
- comment: chalk.hex("#565f89").italic,
96
- type: chalk.hex("#2ac3de"),
97
- func: chalk.hex("#7aa2f7"),
98
- property: chalk.hex("#73daca"),
99
- constant: chalk.hex("#ff9e64"),
100
- tag: chalk.hex("#f7768e"),
101
- punctuation: chalk.hex("#89ddff"),
102
- plain: chalk.hex("#c0caf5"),
103
- };
104
94
  const STRINGS = /^(string|template_string|string_fragment|string_content|raw_string_literal|interpreted_string_literal|char_literal|character_literal|escape_sequence|string_literal|heredoc_body)$/;
105
95
  const NUMBERS = /^(number|integer|float|integer_literal|float_literal|number_literal|decimal_integer_literal|decimal_floating_point_literal)$/;
106
96
  const TYPES = /^(type_identifier|predefined_type|primitive_type|builtin_type|type)$/;
@@ -178,11 +168,11 @@ export function highlightTree(file, text) {
178
168
  const node = cursor.currentNode;
179
169
  if (node.childCount === 0 || node.type.includes("comment") || (isString(node) && !hasSubstitution(node))) {
180
170
  if (node.startIndex >= pos) {
181
- out += COLORS.plain(text.slice(pos, node.startIndex));
171
+ out += themeColors().plain(text.slice(pos, node.startIndex));
182
172
  const color = colorFor(node);
183
173
  const span = text.slice(node.startIndex, node.endIndex);
184
174
  // Color each line separately so styles don't bleed across line breaks.
185
- out += span.split("\n").map((part) => (color ? COLORS[color](part) : COLORS.plain(part))).join("\n");
175
+ out += span.split("\n").map((part) => (color ? themeColors()[color](part) : themeColors().plain(part))).join("\n");
186
176
  pos = node.endIndex;
187
177
  }
188
178
  }
@@ -194,7 +184,7 @@ export function highlightTree(file, text) {
194
184
  };
195
185
  if (cursor.gotoFirstChild())
196
186
  visit();
197
- out += COLORS.plain(text.slice(pos));
187
+ out += themeColors().plain(text.slice(pos));
198
188
  tree.delete();
199
189
  return out.split("\n");
200
190
  }
@@ -0,0 +1,57 @@
1
+ import chalk, {} from "chalk";
2
+ /** Well-known editor themes; one-dark is the default because it reads well on most dark terminals. */
3
+ const PALETTES = {
4
+ "one-dark": {
5
+ keyword: "#c678dd", string: "#98c379", number: "#d19a66", comment: "#7f848e", type: "#e5c07b", func: "#61afef",
6
+ property: "#e06c75", constant: "#d19a66", tag: "#e06c75", punctuation: "#abb2bf", plain: "#d7dae0",
7
+ },
8
+ "tokyo-night": {
9
+ keyword: "#bb9af7", string: "#9ece6a", number: "#ff9e64", comment: "#737aa2", type: "#2ac3de", func: "#7aa2f7",
10
+ property: "#73daca", constant: "#ff9e64", tag: "#f7768e", punctuation: "#89ddff", plain: "#c0caf5",
11
+ },
12
+ "github-dark": {
13
+ keyword: "#ff7b72", string: "#a5d6ff", number: "#79c0ff", comment: "#8b949e", type: "#ffa657", func: "#d2a8ff",
14
+ property: "#79c0ff", constant: "#79c0ff", tag: "#7ee787", punctuation: "#c9d1d9", plain: "#e6edf3",
15
+ },
16
+ dracula: {
17
+ keyword: "#ff79c6", string: "#f1fa8c", number: "#bd93f9", comment: "#8a94c4", type: "#8be9fd", func: "#50fa7b",
18
+ property: "#66d9ef", constant: "#bd93f9", tag: "#ff79c6", punctuation: "#f8f8f2", plain: "#f8f8f2",
19
+ },
20
+ "high-contrast": {
21
+ keyword: "#ff9cf5", string: "#a6ff8f", number: "#ffd27f", comment: "#b0b0b0", type: "#7fe7ff", func: "#8fc7ff",
22
+ property: "#ffb38a", constant: "#ffd27f", tag: "#ff8a8a", punctuation: "#ffffff", plain: "#ffffff",
23
+ },
24
+ "github-light": {
25
+ keyword: "#cf222e", string: "#0a3069", number: "#0550ae", comment: "#6e7781", type: "#953800", func: "#8250df",
26
+ property: "#0550ae", constant: "#0550ae", tag: "#116329", punctuation: "#24292f", plain: "#24292f",
27
+ },
28
+ };
29
+ const paint = (palette) => Object.fromEntries(Object.entries(palette).map(([role, hex]) => [role, role === "comment" ? chalk.hex(hex).italic : chalk.hex(hex)]));
30
+ let current = "one-dark";
31
+ let colors = paint(PALETTES[current]);
32
+ let version = 0;
33
+ /** The chalk styles for the current theme. */
34
+ export const themeColors = () => colors;
35
+ /** Changes whenever the theme does, so cached highlighting can be redone. */
36
+ export const themeVersion = () => version;
37
+ export function setCodeTheme(theme) {
38
+ if (theme === current || !PALETTES[theme])
39
+ return;
40
+ current = theme;
41
+ colors = paint(PALETTES[theme]);
42
+ version++;
43
+ }
44
+ /** The same colors for highlight.js (cli-highlight), used for languages tree-sitter doesn't cover. */
45
+ export function highlightJsTheme() {
46
+ const c = colors;
47
+ return {
48
+ keyword: c.keyword, built_in: c.type, type: c.type, literal: c.constant, number: c.number, regexp: c.string,
49
+ string: c.string, subst: c.plain, symbol: c.constant, class: c.type, function: c.func, title: c.func, params: c.plain,
50
+ comment: c.comment, doctag: c.comment, meta: c.comment, "meta-keyword": c.keyword, "meta-string": c.string,
51
+ section: c.func, tag: c.tag, name: c.tag, "builtin-name": c.type, attr: c.property, attribute: c.property,
52
+ variable: c.plain, bullet: c.punctuation, code: c.string, emphasis: c.plain.italic, strong: c.plain.bold,
53
+ formula: c.plain, link: c.func, quote: c.comment, "selector-tag": c.tag, "selector-id": c.func, "selector-class": c.func,
54
+ "selector-attr": c.property, "selector-pseudo": c.property, "template-tag": c.keyword, "template-variable": c.plain,
55
+ addition: c.string, deletion: c.tag, default: c.plain,
56
+ };
57
+ }
@@ -3,13 +3,17 @@ export const tools = [
3
3
  type: "function",
4
4
  function: {
5
5
  name: "run_command",
6
- description: "Run a terminal command",
6
+ description: "Run a shell command in the project folder: tests, builds, type checks, git, package scripts. Returns its output (stdout and stderr together) and, when it fails, its exit code. Use grep, glob and read_file rather than grep/find/cat here. Quote paths with spaces. Don't start interactive programs or servers that never exit.",
7
7
  parameters: {
8
8
  type: "object",
9
9
  properties: {
10
10
  command: {
11
11
  type: "string",
12
12
  description: "The shell command to run",
13
+ },
14
+ timeout: {
15
+ type: "number",
16
+ description: "Seconds to wait before stopping it. Defaults to 120, at most 600.",
13
17
  }
14
18
  },
15
19
  required: ["command"]
@@ -20,7 +24,7 @@ export const tools = [
20
24
  type: "function",
21
25
  function: {
22
26
  name: "read_file",
23
- description: "Read a file. Long files come back in pages of up to 1000 lines; the end of each page says how to read on.",
27
+ description: "Read a file. Lines come back numbered (\" 12\\tcode\"); the numbers aren't part of the file. Long files come back in pages of up to 1000 lines; the end of each page says how to read on.",
24
28
  parameters: {
25
29
  type: "object",
26
30
  properties: {
@@ -99,6 +103,31 @@ export const tools = [
99
103
  }
100
104
  }
101
105
  },
106
+ {
107
+ type: "function",
108
+ function: {
109
+ name: "open_file",
110
+ description: "Show a file in the user's code view (the middle panel), scrolled to a line with the given lines highlighted. Use it when the user asks where something is or to show/open/find something: find the exact place first, then open it.",
111
+ parameters: {
112
+ type: "object",
113
+ properties: {
114
+ path: {
115
+ type: "string",
116
+ description: "Path to the file, relative to the project root",
117
+ },
118
+ line: {
119
+ type: "number",
120
+ description: "First line to show and highlight (1-based)",
121
+ },
122
+ end_line: {
123
+ type: "number",
124
+ description: "Last line to highlight, e.g. the end of the function. Defaults to line.",
125
+ }
126
+ },
127
+ required: ["path"]
128
+ }
129
+ }
130
+ },
102
131
  {
103
132
  type: "function",
104
133
  function: {
@@ -145,6 +174,61 @@ export const tools = [
145
174
  }
146
175
  }
147
176
  },
177
+ {
178
+ type: "function",
179
+ function: {
180
+ name: "multi_edit",
181
+ description: "Make several edits to one file at once, shown to the user as one change. Each edit works like edit_file, applied in order to the result of the one before; if any fails, none are made. Use it instead of several edit_file calls on the same file.",
182
+ parameters: {
183
+ type: "object",
184
+ properties: {
185
+ path: {
186
+ type: "string",
187
+ description: "Path to the file to edit"
188
+ },
189
+ edits: {
190
+ type: "array",
191
+ description: "The edits, in order",
192
+ items: {
193
+ type: "object",
194
+ properties: {
195
+ old_string: { type: "string", description: "The exact text to replace" },
196
+ new_string: { type: "string", description: "The text to put in its place" },
197
+ replace_all: { type: "boolean", description: "Replace every occurrence. Defaults to false." }
198
+ },
199
+ required: ["old_string", "new_string"]
200
+ }
201
+ }
202
+ },
203
+ required: ["path", "edits"]
204
+ }
205
+ }
206
+ },
207
+ {
208
+ type: "function",
209
+ function: {
210
+ name: "update_plan",
211
+ description: "Keep a short checklist for a task with several steps: send the whole list each time, with each step's status. The user sees it. Use it for anything with three or more steps; skip it for quick questions and one-line fixes.",
212
+ parameters: {
213
+ type: "object",
214
+ properties: {
215
+ steps: {
216
+ type: "array",
217
+ description: "Every step, in order",
218
+ items: {
219
+ type: "object",
220
+ properties: {
221
+ step: { type: "string", description: "What the step does, in a few words" },
222
+ status: { type: "string", enum: ["pending", "in_progress", "done"], description: "Where it's at" }
223
+ },
224
+ required: ["step", "status"]
225
+ }
226
+ }
227
+ },
228
+ required: ["steps"]
229
+ }
230
+ }
231
+ },
148
232
  {
149
233
  type: "function",
150
234
  function: {