jaitus 0.3.4 → 0.3.5

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.
package/src/mcp.js CHANGED
@@ -1,51 +1,127 @@
1
1
  // ─── Model Context Protocol over stdio ─────────────────────────────────────────
2
2
  //
3
3
  // Jaitus supports the portable part of MCP that is most useful in a terminal: local
4
- // stdio servers exposing tools. Each tool invocation gets a short-lived server process,
5
- // which avoids leaking a child process across sessions and makes crashes recoverable.
4
+ // stdio servers exposing tools.
5
+ //
6
+ // Servers come from two places, merged: your own (~/.jaitus/mcp.json, added with
7
+ // `jaitus mcp add --global`) and the project's (.mcp.json). A project's file comes with
8
+ // the repository — somebody else wrote it — so its servers run only after you have
9
+ // approved that exact command (`jaitus mcp trust`, or the prompt in a session). The
10
+ // previous version started every command in a cloned repo's .mcp.json on the first
11
+ // prompt, without asking, even in plan mode.
12
+ //
13
+ // A server is started once and kept for the session, so a stateful one (a browser, a
14
+ // database connection) keeps its state between calls. Discovery is cached per project
15
+ // until a config file changes.
6
16
 
17
+ import crypto from 'node:crypto';
7
18
  import fs from 'node:fs';
8
19
  import path from 'node:path';
9
- import { spawn } from 'node:child_process';
10
- import { SESSIONS } from './config.js';
20
+ import { spawn, spawnSync } from 'node:child_process';
21
+ import { SESSIONS, loadSettings, saveSettings } from './config.js';
22
+
23
+ const GLOBAL_FILE = path.join(path.dirname(SESSIONS), 'mcp.json');
24
+ const projectFile = (cwd) => path.join(cwd, '.mcp.json');
25
+
26
+ function readServers(file) {
27
+ try {
28
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
29
+ return data?.mcpServers && typeof data.mcpServers === 'object' ? data.mcpServers : {};
30
+ } catch { return {}; }
31
+ }
11
32
 
12
- function configPaths(cwd) {
13
- return [path.join(cwd, '.mcp.json'), path.join(path.dirname(SESSIONS), 'mcp.json')];
33
+ /** Every configured server: { name, config, scope: 'global'|'project', file }. Project wins a name clash. */
34
+ export function allServers(cwd) {
35
+ const out = new Map();
36
+ for (const [name, config] of Object.entries(readServers(GLOBAL_FILE))) out.set(name, { name, config, scope: 'global', file: GLOBAL_FILE });
37
+ for (const [name, config] of Object.entries(readServers(projectFile(cwd)))) out.set(name, { name, config, scope: 'project', file: projectFile(cwd) });
38
+ return [...out.values()];
14
39
  }
15
40
 
41
+ /** Kept for callers that want the old shape. */
16
42
  export function readMcpConfig(cwd) {
17
- for (const file of configPaths(cwd)) {
18
- try {
19
- const data = JSON.parse(fs.readFileSync(file, 'utf8'));
20
- if (data?.mcpServers && typeof data.mcpServers === 'object') return { file, servers: data.mcpServers };
21
- } catch { /* try the next scope */ }
22
- }
23
- return { file: configPaths(cwd)[0], servers: {} };
43
+ const servers = {};
44
+ for (const s of allServers(cwd)) servers[s.name] = s.config;
45
+ return { file: projectFile(cwd), servers };
46
+ }
47
+
48
+ // ── Trust ──
49
+ /** What is approved: the command line itself. Change the command and it asks again. */
50
+ export function fingerprint(config) {
51
+ const body = JSON.stringify({ command: config?.command || '', args: config?.args || [], env: config?.env || {} });
52
+ return crypto.createHash('sha256').update(body).digest('hex').slice(0, 24);
53
+ }
54
+ const trustKey = (cwd) => path.resolve(cwd).toLowerCase();
55
+ export function isTrusted(cwd, server) {
56
+ if (server.scope === 'global') return true;
57
+ const t = loadSettings().trustedMcp?.[trustKey(cwd)] || {};
58
+ return t[server.name] === fingerprint(server.config);
59
+ }
60
+ export function trustServer(cwd, server) {
61
+ const s = loadSettings();
62
+ const all = { ...(s.trustedMcp || {}) };
63
+ all[trustKey(cwd)] = { ...(all[trustKey(cwd)] || {}), [server.name]: fingerprint(server.config) };
64
+ saveSettings({ ...s, trustedMcp: all });
65
+ discovery.clear();
66
+ }
67
+ export function untrustedServers(cwd) {
68
+ return allServers(cwd).filter((s) => s.config?.command && !isTrusted(cwd, s));
24
69
  }
25
70
 
26
71
  export function listMcpServers(cwd) {
27
- const { servers } = readMcpConfig(cwd);
28
- return Object.entries(servers).map(([name, config]) => ({
29
- name,
30
- transport: config?.type || (config?.command ? 'stdio' : 'unknown'),
31
- command: config?.command || config?.url || '',
72
+ return allServers(cwd).map((s) => ({
73
+ name: s.name,
74
+ scope: s.scope,
75
+ trusted: isTrusted(cwd, s),
76
+ transport: s.config?.type || (s.config?.command ? 'stdio' : 'unknown'),
77
+ command: [s.config?.command || s.config?.url || '', ...(s.config?.args || [])].join(' ').trim(),
32
78
  }));
33
79
  }
34
80
 
35
- export function addMcpServer(cwd, name, config) {
36
- const projectFile = path.join(cwd, '.mcp.json');
37
- let servers = {};
38
- try { servers = JSON.parse(fs.readFileSync(projectFile, 'utf8')).mcpServers || {}; } catch { /* create project config */ }
39
- const next = { mcpServers: { ...servers, [name]: config } };
40
- fs.writeFileSync(projectFile, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
81
+ export function addMcpServer(cwd, name, config, { global = false } = {}) {
82
+ const file = global ? GLOBAL_FILE : projectFile(cwd);
83
+ const next = { mcpServers: { ...readServers(file), [name]: config } };
84
+ fs.mkdirSync(path.dirname(file), { recursive: true });
85
+ fs.writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
86
+ // You added it yourself: approved.
87
+ if (!global) trustServer(cwd, { name, config, scope: 'project' });
88
+ discovery.clear();
41
89
  }
42
90
 
43
91
  export function removeMcpServer(cwd, name) {
44
- const current = readMcpConfig(cwd);
45
- if (!current.servers[name]) return false;
46
- const { [name]: _, ...rest } = current.servers;
47
- fs.writeFileSync(current.file, `${JSON.stringify({ mcpServers: rest }, null, 2)}\n`, { mode: 0o600 });
48
- return true;
92
+ for (const file of [projectFile(cwd), GLOBAL_FILE]) {
93
+ const servers = readServers(file);
94
+ if (!servers[name]) continue;
95
+ const { [name]: _, ...rest } = servers;
96
+ fs.writeFileSync(file, `${JSON.stringify({ mcpServers: rest }, null, 2)}\n`, { mode: 0o600 });
97
+ discovery.clear();
98
+ closeAll();
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ // ── Starting a server ──
105
+
106
+ /**
107
+ * On Windows, spawn without a shell only finds .exe and .com — `npx`, `npm`, `uvx` are
108
+ * .cmd shims and failed with ENOENT (silently: the server just had no tools). The
109
+ * command is looked up on PATH with PATHEXT; a .cmd/.bat is run through cmd.exe with
110
+ * every argument quoted.
111
+ */
112
+ function resolveSpawn(command, args) {
113
+ if (process.platform !== 'win32') return { file: command, args, opts: {} };
114
+ let full = command;
115
+ if (!/[\\/]/.test(command) || !path.extname(command)) {
116
+ const r = spawnSync('where', [command], { encoding: 'utf8', windowsHide: true });
117
+ const hits = String(r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
118
+ full = hits.find((h) => /\.(exe|com)$/i.test(h)) || hits.find((h) => /\.(cmd|bat)$/i.test(h)) || command;
119
+ }
120
+ if (/\.(cmd|bat)$/i.test(full)) {
121
+ const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
122
+ return { file: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${[q(full), ...args.map(q)].join(' ')}"`], opts: { windowsVerbatimArguments: true } };
123
+ }
124
+ return { file: full, args, opts: {} };
49
125
  }
50
126
 
51
127
  class StdioClient {
@@ -55,25 +131,37 @@ class StdioClient {
55
131
  this.nextId = 1;
56
132
  this.pending = new Map();
57
133
  this.buffer = '';
134
+ this.stderr = '';
135
+ this.alive = false;
58
136
  }
59
137
 
60
138
  async start() {
61
139
  if (!this.config?.command) throw new Error('MCP server has no stdio command');
62
- this.child = spawn(this.config.command, this.config.args || [], {
140
+ const { file, args, opts } = resolveSpawn(this.config.command, this.config.args || []);
141
+ this.child = spawn(file, args, {
63
142
  cwd: this.cwd,
64
143
  shell: false,
65
144
  env: { ...process.env, ...(this.config.env || {}) },
66
- stdio: ['pipe', 'pipe', 'ignore'],
145
+ stdio: ['pipe', 'pipe', 'pipe'],
146
+ windowsHide: true,
147
+ ...opts,
67
148
  });
149
+ this.alive = true;
150
+ // A server kept for the session must not keep the PROCESS alive: a one-shot run
151
+ // finished its answer and then never exited. A request in flight holds its own
152
+ // timer, which is what keeps Node running while one is needed.
153
+ try { this.child.unref(); this.child.stdout.unref?.(); this.child.stderr.unref?.(); this.child.stdin.unref?.(); } catch { /* keep them */ }
68
154
  this.child.stdout.setEncoding('utf8');
69
155
  this.child.stdout.on('data', (chunk) => this.onData(chunk));
70
- this.child.on('error', (error) => this.rejectAll(error));
71
- this.child.on('close', (code) => this.rejectAll(new Error(`MCP server exited (${code ?? 'unknown'})`)));
156
+ // Kept (the tail) so a failure can say what the server said.
157
+ this.child.stderr.on('data', (d) => { this.stderr = (this.stderr + d).slice(-2000); });
158
+ this.child.on('error', (error) => { this.alive = false; this.rejectAll(error); });
159
+ this.child.on('close', (code) => { this.alive = false; this.rejectAll(new Error(`MCP server exited (${code ?? 'unknown'})${this.stderr.trim() ? `: ${this.stderr.trim().split('\n').pop()}` : ''}`)); });
72
160
  await this.request('initialize', {
73
161
  protocolVersion: '2024-11-05',
74
162
  capabilities: {},
75
- clientInfo: { name: 'jaitus', version: '0.2.0' },
76
- });
163
+ clientInfo: { name: 'jaitus', version: '0.3' },
164
+ }, 20_000);
77
165
  this.notify('notifications/initialized', {});
78
166
  return this;
79
167
  }
@@ -96,23 +184,24 @@ class StdioClient {
96
184
  }
97
185
  }
98
186
 
99
- request(method, params) {
187
+ request(method, params, timeoutMs = 120_000) {
100
188
  const id = this.nextId++;
101
189
  return new Promise((resolve, reject) => {
102
190
  const timer = setTimeout(() => {
103
191
  this.pending.delete(id);
104
- reject(new Error(`MCP request timed out: ${method}`));
105
- }, 15_000);
192
+ reject(new Error(`MCP request timed out after ${Math.round(timeoutMs / 1000)}s: ${method}`));
193
+ }, timeoutMs);
106
194
  this.pending.set(id, {
107
195
  resolve: (value) => { clearTimeout(timer); resolve(value); },
108
196
  reject: (error) => { clearTimeout(timer); reject(error); },
109
197
  });
110
- this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
198
+ try { this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); }
199
+ catch (e) { this.pending.delete(id); clearTimeout(timer); reject(e); }
111
200
  });
112
201
  }
113
202
 
114
203
  notify(method, params) {
115
- this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`);
204
+ try { this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); } catch { /* gone */ }
116
205
  }
117
206
 
118
207
  rejectAll(error) {
@@ -121,40 +210,88 @@ class StdioClient {
121
210
  }
122
211
 
123
212
  close() {
124
- try { this.child?.kill(); } catch { /* already gone */ }
213
+ this.alive = false;
214
+ try {
215
+ if (process.platform === 'win32' && this.child?.pid) spawnSync('taskkill', ['/pid', String(this.child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
216
+ else this.child?.kill();
217
+ } catch { /* already gone */ }
125
218
  }
126
219
  }
127
220
 
128
- /** Discover MCP tools and wrap them in the same permission-aware shape as local tools. */
129
- export async function discoverMcpTools(cwd) {
130
- const { servers } = readMcpConfig(cwd);
131
- const result = [];
132
- for (const [serverName, config] of Object.entries(servers)) {
133
- if (!config?.command) continue; // HTTP/SSE can be added without changing the tool shape.
134
- const client = new StdioClient(config, cwd);
135
- try {
136
- await client.start();
137
- const listed = await client.request('tools/list', {});
138
- for (const remote of listed?.tools || []) {
139
- const name = `mcp__${serverName}__${remote.name}`;
140
- result.push({
141
- name,
142
- description: remote.description || `Tool ${remote.name} from MCP server ${serverName}.`,
143
- input_schema: remote.inputSchema || { type: 'object', properties: {} },
144
- needsPermission: true,
145
- summarise: () => `${serverName}: ${remote.name}`,
146
- async run(input) {
147
- const live = new StdioClient(config, cwd);
148
- try {
149
- await live.start();
150
- const response = await live.request('tools/call', { name: remote.name, arguments: input || {} });
151
- return response?.content?.map((item) => item.text || JSON.stringify(item)).join('\n') || JSON.stringify(response || {});
152
- } finally { live.close(); }
153
- },
154
- });
155
- }
156
- } catch { /* an unavailable integration must not disable the local agent */ }
157
- finally { client.close(); }
221
+ // One live client per server for the session.
222
+ const clients = new Map();
223
+ function closeAll() { for (const client of clients.values()) client.close(); clients.clear(); }
224
+ process.once('exit', closeAll);
225
+
226
+ async function clientFor(key, config, cwd) {
227
+ const have = clients.get(key);
228
+ if (have?.alive) return have;
229
+ const fresh = new StdioClient(config, cwd);
230
+ await fresh.start();
231
+ clients.set(key, fresh);
232
+ return fresh;
233
+ }
234
+
235
+ // A provider rejects every request whose tools list holds an invalid name.
236
+ const safeName = (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, '_');
237
+
238
+ function toolText(response) {
239
+ return (response?.content || []).map((item) => {
240
+ if (item?.type === 'text') return item.text;
241
+ if (item?.type === 'image') return '[the tool returned an image]';
242
+ if (item?.type === 'resource') return item.resource?.text || `[resource ${item.resource?.uri || ''}]`;
243
+ return JSON.stringify(item);
244
+ }).join('\n') || JSON.stringify(response || {});
245
+ }
246
+
247
+ const discovery = new Map(); // cwd|mtimes -> Promise<tools>
248
+
249
+ function stamp(cwd) {
250
+ return [GLOBAL_FILE, projectFile(cwd)].map((f) => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } }).join('|');
251
+ }
252
+
253
+ /**
254
+ * The tools of every trusted server, in the same permission-aware shape as local tools.
255
+ * Servers are started in parallel, once per session (until a config file changes).
256
+ */
257
+ export function discoverMcpTools(cwd) {
258
+ const key = `${path.resolve(cwd)}|${stamp(cwd)}|${JSON.stringify(loadSettings().trustedMcp?.[trustKey(cwd)] || {})}`;
259
+ if (!discovery.has(key)) {
260
+ discovery.set(key, (async () => {
261
+ const servers = allServers(cwd).filter((s) => s.config?.command && isTrusted(cwd, s));
262
+ const lists = await Promise.all(servers.map(async (server) => {
263
+ const ckey = `${path.resolve(cwd)}|${server.scope}|${server.name}|${fingerprint(server.config)}`;
264
+ try {
265
+ const client = await clientFor(ckey, server.config, cwd);
266
+ const listed = await client.request('tools/list', {}, 20_000);
267
+ return (listed?.tools || []).map((remote) => ({
268
+ name: `mcp__${safeName(server.name)}__${safeName(remote.name)}`.slice(0, 64),
269
+ description: remote.description || `Tool ${remote.name} from MCP server ${server.name}.`,
270
+ input_schema: remote.inputSchema || { type: 'object', properties: {} },
271
+ needsPermission: true,
272
+ summarise: () => `${server.name}: ${remote.name}`,
273
+ async run(input) {
274
+ const live = await clientFor(ckey, server.config, cwd);
275
+ const timeout = Number(server.config.timeoutMs) > 0 ? Number(server.config.timeoutMs) : 120_000;
276
+ const response = await live.request('tools/call', { name: remote.name, arguments: input || {} }, timeout);
277
+ const text = toolText(response);
278
+ // The server said it failed: a failure, not a result that happens to
279
+ // contain an error message.
280
+ if (response?.isError) throw new Error(text || `${remote.name} failed`);
281
+ return text;
282
+ },
283
+ }));
284
+ } catch (e) {
285
+ // Shown once per session instead of silently leaving the server toolless.
286
+ mcpProblems.set(server.name, e.message);
287
+ return [];
288
+ }
289
+ }));
290
+ return lists.flat();
291
+ })());
158
292
  }
159
- return result;
293
+ return discovery.get(key);
160
294
  }
295
+
296
+ /** Servers that failed to start this session, and why. */
297
+ export const mcpProblems = new Map();
package/src/project.js CHANGED
Binary file
package/src/session.js CHANGED
@@ -49,6 +49,20 @@ export function deviceId() {
49
49
  return id;
50
50
  }
51
51
 
52
+ /**
53
+ * Tokens go only to the server that issued them. Pointing the CLI elsewhere
54
+ * (`jaitus config baseUrl …`, JAITUS_BASE_URL) used to send the saved access AND refresh
55
+ * tokens to that host — anyone who got a user to set it could take over the account.
56
+ * The login records its server; a different origin needs its own sign-in.
57
+ */
58
+ export function assertSameServer(creds) {
59
+ if (!creds?.baseUrl) return; // older logins did not record it
60
+ const origin = (u) => { try { return new URL(u).origin.toLowerCase(); } catch { return String(u || '').toLowerCase(); } };
61
+ if (origin(creds.baseUrl) !== origin(endpoints().baseUrl)) {
62
+ throw new JaitusError({ code: 'auth_required', message: `You are signed in to ${origin(creds.baseUrl)}, but Jaitus is pointed at ${origin(endpoints().baseUrl)}. Sign in there with /login, or point it back.` });
63
+ }
64
+ }
65
+
52
66
  export function deviceName() {
53
67
  return os.hostname().slice(0, 60);
54
68
  }
@@ -151,6 +165,7 @@ export async function accessToken({ rejected = null } = {}) {
151
165
  if (process.env.JAITUS_TOKEN) return process.env.JAITUS_TOKEN.trim();
152
166
  const creds = loadCredentials();
153
167
  if (!creds?.refreshToken) throw new JaitusError({ code: 'auth_required' });
168
+ assertSameServer(creds);
154
169
  if (!rejected && fresh(creds)) return creds.accessToken;
155
170
  // Another process may already have renewed: the file holds a different, fresh token.
156
171
  if (rejected && fresh(creds) && creds.accessToken !== rejected) return creds.accessToken;
@@ -190,7 +205,10 @@ export async function signOut() {
190
205
  if (!creds?.refreshToken && !creds?.accessToken) return { hadSession: false, server: false };
191
206
  const { baseUrl } = endpoints();
192
207
  let server = false;
208
+ let otherServer = false;
209
+ try { assertSameServer(creds); } catch { otherServer = true; }
193
210
  try {
211
+ if (otherServer) throw new Error('pointed at a different server — the tokens are not sent there');
194
212
  const res = await fetch(`${baseUrl}/api/auth/cli/logout`, {
195
213
  method: 'POST',
196
214
  headers: {
package/src/sessions.js CHANGED
@@ -46,7 +46,19 @@ export function saveSession(session) {
46
46
  }
47
47
 
48
48
  export function loadSession(id) {
49
- const file = fileFor(id);
49
+ const direct = readSessionFile(fileFor(id));
50
+ if (direct) return direct;
51
+ // The ids shown by `jaitus sessions` and /sessions are 8-character prefixes; resuming
52
+ // one of those used to say "No saved session found". A unique prefix is enough.
53
+ const want = String(id || '').trim().toLowerCase();
54
+ if (want.length < 4) return null;
55
+ try {
56
+ const hits = fs.readdirSync(SESSIONS).filter((name) => name.endsWith('.json') && name.toLowerCase().startsWith(want));
57
+ return hits.length === 1 ? readSessionFile(path.join(SESSIONS, hits[0])) : null;
58
+ } catch { return null; }
59
+ }
60
+
61
+ function readSessionFile(file) {
50
62
  if (!file) return null;
51
63
  try {
52
64
  const session = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -57,13 +69,19 @@ export function loadSession(id) {
57
69
  }
58
70
  }
59
71
 
72
+ /** The same folder, however it was typed: on Windows `c:\app` and `C:\App` are one place. */
73
+ export const sameDir = (a, b) => {
74
+ const norm = (p) => { const r = path.resolve(String(p || '')); return process.platform === 'win32' ? r.toLowerCase() : r; };
75
+ return norm(a) === norm(b);
76
+ };
77
+
60
78
  export function listSessions({ cwd = null } = {}) {
61
79
  try {
62
80
  return fs.readdirSync(SESSIONS)
63
81
  .filter((name) => name.endsWith('.json'))
64
82
  .map((name) => loadSession(name.slice(0, -5)))
65
83
  .filter(Boolean)
66
- .filter((session) => !cwd || path.resolve(session.cwd) === path.resolve(cwd))
84
+ .filter((session) => !cwd || sameDir(session.cwd, cwd))
67
85
  .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
68
86
  } catch {
69
87
  return [];
package/src/terminal.js CHANGED
@@ -8,28 +8,57 @@
8
8
  // instead of painting codes that render as nothing. Sixel capability is
9
9
  // attribute 4 (";4" in the report).
10
10
  //
11
+ // The same handshake asks for the BACKGROUND colour (OSC 11). "auto" used to read only
12
+ // COLORFGBG, which macOS Terminal, VS Code and Windows Terminal never set, so a light
13
+ // terminal got the dark theme: near-white input text on a white window. OSC 11 is sent
14
+ // FIRST and DA second: terminals answer in order and every terminal answers DA, so a DA
15
+ // reply with no colour before it means "does not support the query", and nothing waits
16
+ // for a reply that will never come (the approach Neovim and terminal-colorsaurus use).
17
+ //
11
18
  // Done inline as a subprocess-style handshake: raw mode for the duration so
12
19
  // the reply doesn't echo, restored on the way out. Cached per process;
13
20
  // `probeDevice` does the work once.
14
21
 
15
22
  import fs from 'node:fs';
16
23
 
17
- const state = { done: false, vt: false, sixel: false };
24
+ const state = { done: false, vt: false, sixel: false, background: null };
18
25
  let pending = null;
19
26
 
20
27
  /** A device-attributes reply: CSI [? params c. */
21
28
  const DA_REPLY = /\x1b\[[?0-9;]*c/;
22
29
  /** The replies that mean "yes, images": xterm's CSI ? 6 2 ; ... c and kin. */
23
30
  const SIXEL_ATTR = /;4[;c]/;
31
+ /** A background-colour reply: OSC 11 ; rgb:R/G/B (1-4 hex digits each), ended by BEL or ST. */
32
+ const BG_REPLY = /\x1b\]11;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})(?:\x07|\x1b\\)/i;
33
+
34
+ /**
35
+ * 'light' or 'dark' from an OSC 11 reply, or null. By WCAG relative luminance, with the
36
+ * cut at 0.179 — the point where black text and white text contrast equally with it.
37
+ */
38
+ export function backgroundFromReply(text) {
39
+ const m = BG_REPLY.exec(String(text || ''));
40
+ if (!m) return null;
41
+ const [r, g, b] = m.slice(1, 4).map((h) => parseInt(h, 16) / (16 ** h.length - 1));
42
+ const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
43
+ const luminance = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
44
+ return luminance > 0.179 ? 'light' : 'dark';
45
+ }
24
46
 
25
47
  // 300ms was too tight. A terminal over SSH, one under a multiplexer, or one whose reply
26
48
  // queues behind anything else on stdin misses it — and a missed reply used to be read as
27
49
  // "this console cannot do escape sequences", which turned the whole UI to ASCII twins.
28
50
  // Startup does not wait on this: the probe runs concurrently with the model lookup.
29
- export function probeDevice({ timeoutMs = 900 } = {}) {
51
+ // `input`/`write` are the terminal's two directions; the defaults are the real ones, and
52
+ // tests pass a stand-in to hold the exact conversation a terminal has with the app.
53
+ export function probeDevice({
54
+ timeoutMs = 900,
55
+ input = process.stdin,
56
+ write = (text) => fs.writeSync(1, text),
57
+ tty = Boolean(process.stdout.isTTY && process.stdin.isTTY),
58
+ } = {}) {
30
59
  if (state.done) return Promise.resolve({ vt: state.vt, sixel: state.sixel });
31
60
  if (pending) return pending;
32
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
61
+ if (!tty) {
33
62
  state.done = true;
34
63
  return Promise.resolve({ vt: false, sixel: false });
35
64
  }
@@ -38,18 +67,19 @@ export function probeDevice({ timeoutMs = 900 } = {}) {
38
67
  let finished = false;
39
68
  let buffer = '';
40
69
  let wasRaw = false;
41
- try { wasRaw = !!process.stdin.isRaw; } catch { /* not a TTY */ }
70
+ try { wasRaw = !!input.isRaw; } catch { /* not a TTY */ }
42
71
  const finish = () => {
43
72
  if (finished) return;
44
73
  finished = true;
45
74
  clearTimeout(timer);
46
- try { process.stdin.setRawMode(wasRaw); } catch { /* already gone */ }
47
- try { process.stdin.pause(); } catch { /* already gone */ }
48
- process.stdin.removeListener('data', onData);
75
+ try { input.setRawMode(wasRaw); } catch { /* already gone */ }
76
+ try { input.pause(); } catch { /* already gone */ }
77
+ input.removeListener('data', onData);
49
78
  const vt = DA_REPLY.test(buffer);
50
79
  state.done = true;
51
80
  state.vt = vt;
52
81
  state.sixel = vt && SIXEL_ATTR.test(buffer);
82
+ state.background = backgroundFromReply(buffer);
53
83
  pending = null;
54
84
  resolve({ vt: state.vt, sixel: state.sixel });
55
85
  };
@@ -62,11 +92,11 @@ export function probeDevice({ timeoutMs = 900 } = {}) {
62
92
  const timer = setTimeout(finish, timeoutMs);
63
93
 
64
94
  try {
65
- process.stdin.setRawMode(true);
66
- process.stdin.resume();
67
- process.stdin.on('data', onData);
95
+ input.setRawMode(true);
96
+ input.resume();
97
+ input.on('data', onData);
68
98
  // Ask on stdout. The reply comes back through stdin as if typed.
69
- fs.writeSync(1, '\x1b[c');
99
+ write('\x1b]11;?\x1b\\\x1b[c');
70
100
  } catch {
71
101
  finish();
72
102
  }
@@ -74,6 +104,11 @@ export function probeDevice({ timeoutMs = 900 } = {}) {
74
104
  return pending;
75
105
  }
76
106
 
107
+ /** 'light' | 'dark' as the terminal itself reported it, or null when it did not say. */
108
+ export function terminalBackground() {
109
+ return state.background;
110
+ }
111
+
77
112
  /** Legacy wrapper: true when this terminal shows sixel images. */
78
113
  export async function probeAsync({ timeoutMs = 400 } = {}) {
79
114
  const { sixel } = await probeDevice({ timeoutMs });
package/src/tui/ansi.js CHANGED
@@ -225,7 +225,9 @@ export function wrapRow(row, width, indent = '') {
225
225
  for (const ch of chunk) {
226
226
  const cw = cellWidth(ch.codePointAt(0));
227
227
  if (used + pieceWidth + cw > width) {
228
- current.push({ ...span, t: piece });
228
+ // `cut` marks a word split mid-way, so a URL cut here can be rejoined when it is
229
+ // clicked (the renderer ignores the field).
230
+ current.push(piece ? { ...span, t: piece, cut: true } : { ...span, t: piece });
229
231
  flush();
230
232
  piece = '';
231
233
  pieceWidth = 0;