nou-cli 0.5.1 → 0.5.3

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 (42) hide show
  1. package/README.md +48 -9
  2. package/dist/index.js +9 -99
  3. package/dist/src/agent/index.js +199 -33
  4. package/dist/src/cli.js +117 -0
  5. package/dist/src/commands/index.js +233 -25
  6. package/dist/src/config/index.js +14 -0
  7. package/dist/src/context/index.js +33 -10
  8. package/dist/src/conversations/external.js +165 -0
  9. package/dist/src/conversations/index.js +71 -0
  10. package/dist/src/engines/claude.js +10 -0
  11. package/dist/src/engines/codex.js +60 -5
  12. package/dist/src/games/index.js +217 -0
  13. package/dist/src/mcp/import.js +106 -0
  14. package/dist/src/mcp/index.js +184 -0
  15. package/dist/src/providers/anthropic.js +23 -3
  16. package/dist/src/providers/index.js +13 -0
  17. package/dist/src/providers/openai.js +6 -2
  18. package/dist/src/syntax/index.js +10 -20
  19. package/dist/src/syntax/themes.js +57 -0
  20. package/dist/src/terminal/index.js +127 -0
  21. package/dist/src/tools/index.js +86 -2
  22. package/dist/src/ui/App.js +900 -118
  23. package/dist/src/ui/Chat.js +143 -22
  24. package/dist/src/ui/ChatInput.js +9 -2
  25. package/dist/src/ui/CodeView.js +111 -20
  26. package/dist/src/ui/Editor.js +13 -6
  27. package/dist/src/ui/FileTree.js +6 -5
  28. package/dist/src/ui/GamePanel.js +99 -0
  29. package/dist/src/ui/Header.js +1 -1
  30. package/dist/src/ui/QuickOpen.js +51 -12
  31. package/dist/src/ui/Settings.js +58 -13
  32. package/dist/src/ui/Tabs.js +56 -0
  33. package/dist/src/ui/Terminal.js +84 -0
  34. package/dist/src/ui/clipboard.js +9 -7
  35. package/dist/src/ui/commands.js +36 -0
  36. package/dist/src/ui/definition.js +135 -0
  37. package/dist/src/ui/fast-width.js +62 -0
  38. package/dist/src/ui/mouse.js +4 -1
  39. package/dist/src/ui/render-options.js +4 -0
  40. package/dist/src/ui/text.js +132 -12
  41. package/dist/src/ui/width-shim.js +7 -0
  42. package/package.json +2 -1
@@ -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 {
@@ -65,14 +81,14 @@ export function anthropicProvider() {
65
81
  apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "",
66
82
  api: "anthropic-messages",
67
83
  }),
68
- async chat({ model, system, messages, tools, onText, signal, effort: requested }) {
84
+ async chat({ model, system, messages, tools, onText, onThinking, signal, effort: requested }) {
69
85
  const fallbacks = FALLBACK_MODELS.test(model);
70
86
  const effort = EFFORT_MODELS.test(model) ? process.env.NOU_EFFORT ?? requested ?? "high" : undefined;
71
87
  const body = {
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,6 +105,8 @@ export function anthropicProvider() {
87
105
  let text = "";
88
106
  try {
89
107
  const stream = getClient().beta.messages.stream(body, signal ? { signal } : {});
108
+ // Thinking goes to the progress line, not the reply: it isn't kept, so it mustn't reflow the chat.
109
+ stream.on("thinking", (delta) => onThinking?.(delta));
90
110
  stream.on("text", (delta) => {
91
111
  text += delta;
92
112
  onText(delta);
@@ -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
+ }
@@ -0,0 +1,127 @@
1
+ import { spawn } from "child_process";
2
+ import { existsSync, statSync } from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { stripVTControlCharacters } from "util";
6
+ import { stopProcess } from "../commands/index.js";
7
+ /** Lines of output kept (the oldest go first). */
8
+ const KEEP = 3000;
9
+ /**
10
+ * The terminal panel's shell: runs what you type with your shell in the folder you've `cd`'d to, and
11
+ * keeps the output. Each command is its own process (no native pseudo-terminal), so full-screen programs
12
+ * like vim won't draw, but builds, tests, git and servers work, and what you type while one runs goes
13
+ * to it.
14
+ */
15
+ export class Shell {
16
+ onChange;
17
+ lines = [];
18
+ cwd = process.cwd();
19
+ child = null;
20
+ /** The line being written (output without its newline yet; a \r starts it over, like progress bars). */
21
+ partial = "";
22
+ constructor(onChange) {
23
+ this.onChange = onChange;
24
+ process.once("exit", () => this.interrupt()); // nothing it started is left running after Nou
25
+ }
26
+ get running() {
27
+ return !!this.child;
28
+ }
29
+ /** Runs a command line; `cd` and `clear` are handled here so they stick. */
30
+ run(command) {
31
+ const line = command.trim();
32
+ if (!line)
33
+ return;
34
+ if (this.child)
35
+ return this.input(command);
36
+ this.push({ text: `${this.prompt()}${line}`, kind: "command" });
37
+ if (line === "clear" || line === "cls") {
38
+ this.lines = [];
39
+ return this.onChange();
40
+ }
41
+ const cd = /^cd(?:\s+(.*))?$/.exec(line);
42
+ if (cd)
43
+ return this.changeDir(cd[1]?.trim().replace(/^["']|["']$/g, "") || os.homedir());
44
+ const posix = process.platform !== "win32";
45
+ const child = spawn(line, {
46
+ cwd: this.cwd,
47
+ shell: posix ? process.env.SHELL || true : true,
48
+ stdio: ["pipe", "pipe", "pipe"],
49
+ detached: posix, // its own process group, so Ctrl+C stops everything it started
50
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
51
+ });
52
+ this.child = child;
53
+ const write = (chunk) => this.write(chunk.toString("utf-8"));
54
+ child.stdout?.on("data", write);
55
+ child.stderr?.on("data", write);
56
+ child.stdin?.on("error", () => { }); // it may exit before reading what you typed
57
+ child.on("error", (error) => this.push({ text: error.message, kind: "error" }));
58
+ child.on("close", (code, signal) => {
59
+ this.flush();
60
+ if (signal)
61
+ this.push({ text: "stopped", kind: "info" });
62
+ else if (code)
63
+ this.push({ text: `exit ${code}`, kind: "error" });
64
+ this.child = null;
65
+ this.onChange();
66
+ });
67
+ this.onChange();
68
+ }
69
+ /** Text for the running command's input (it gets a newline, like pressing Enter in a terminal). */
70
+ input(text) {
71
+ if (!this.child?.stdin?.writable)
72
+ return;
73
+ this.push({ text, kind: "command" });
74
+ this.child.stdin.write(`${text}\n`);
75
+ }
76
+ /** Ctrl+C: stops the running command. */
77
+ interrupt() {
78
+ if (this.child)
79
+ stopProcess(this.child);
80
+ }
81
+ clear() {
82
+ this.lines = [];
83
+ this.partial = "";
84
+ this.onChange();
85
+ }
86
+ dispose() {
87
+ this.interrupt();
88
+ }
89
+ prompt() {
90
+ const home = os.homedir();
91
+ const where = this.cwd === home ? "~" : this.cwd.startsWith(home + path.sep) ? `~${this.cwd.slice(home.length)}` : this.cwd;
92
+ return `${where.length > 30 ? `…${path.sep}${path.basename(where)}` : where} $ `;
93
+ }
94
+ changeDir(target) {
95
+ const next = path.resolve(this.cwd, target.startsWith("~") ? path.join(os.homedir(), target.slice(1)) : target);
96
+ if (!existsSync(next) || !statSync(next).isDirectory())
97
+ this.push({ text: `cd: no such folder: ${target}`, kind: "error" });
98
+ else
99
+ this.cwd = next;
100
+ this.onChange();
101
+ }
102
+ write(text) {
103
+ const parts = stripVTControlCharacters(text).replace(/\r\n/g, "\n").split("\n");
104
+ for (let i = 0; i < parts.length; i++) {
105
+ const part = parts[i];
106
+ // A carriage return goes back to the start of the line: what follows replaces it.
107
+ this.partial = part.includes("\r") ? part.slice(part.lastIndexOf("\r") + 1) || this.partial : this.partial + part;
108
+ if (i < parts.length - 1)
109
+ this.flush(true);
110
+ }
111
+ this.onChange();
112
+ }
113
+ flush(always = false) {
114
+ if (this.partial || always)
115
+ this.push({ text: this.partial.replace(/\t/g, " "), kind: "out" });
116
+ this.partial = "";
117
+ }
118
+ /** The output so far, with the line still being written. */
119
+ view() {
120
+ return this.partial ? [...this.lines, { text: this.partial, kind: "out" }] : this.lines;
121
+ }
122
+ push(line) {
123
+ this.lines.push(line);
124
+ if (this.lines.length > KEEP)
125
+ this.lines.splice(0, this.lines.length - KEEP);
126
+ }
127
+ }