wisp-agents 0.3.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.5.0] - 2026-10-17
9
+
10
+ ### Added
11
+
12
+ - Anonymous usage stats and error reports via [PostHog](https://posthog.com): which agents, models and modes are used, run outcomes and timings, and errors (agent failures, server errors, crashes) with paths scrubbed. Never sends prompts, output, paths or project names. Turn it off in Settings → *Anonymous usage stats*, or with `WISP_TELEMETRY=0` / `DO_NOT_TRACK=1`; always off when `CI` is set. See the Telemetry section of the README.
13
+
14
+ ## [0.4.0] - 2026-10-17
15
+
16
+ ### Added
17
+
18
+ - Thinking level in the harness · model picker: Default / Low / Medium / High / X-High / Max under the model list, remembered per harness and shown on the launcher button. Claude Code gets `--effort`, Codex `-c model_reasoning_effort=…`, opencode `--variant` (levels read per model from `opencode models --verbose`). Cursor bakes effort into its model ids, so it has no separate selector.
19
+ - Take over in [cmux](https://cmux.dev): opens a new cmux workspace in the project running the resume command. Auto-detected when wisp is started from a cmux terminal.
20
+
8
21
  ## [0.3.0] - 2026-10-17
9
22
 
10
23
  ### Added
@@ -76,7 +89,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
76
89
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
77
90
  - Older turns are folded, with a floating collapse-all button and back-to-top.
78
91
 
79
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.3.0...HEAD
92
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.5.0...HEAD
93
+ [0.5.0]: https://github.com/M0xoo/wisp/compare/v0.4.0...v0.5.0
94
+ [0.4.0]: https://github.com/M0xoo/wisp/compare/v0.3.0...v0.4.0
80
95
  [0.3.0]: https://github.com/M0xoo/wisp/compare/v0.2.2...v0.3.0
81
96
  [0.2.2]: https://github.com/M0xoo/wisp/compare/v0.2.1...v0.2.2
82
97
  [0.2.1]: https://github.com/M0xoo/wisp/compare/v0.2.0...v0.2.1
package/README.md CHANGED
@@ -44,8 +44,8 @@ From there you can **follow up**, **answer its questions**, **take it over** in
44
44
  | 📥 **A queue** | Sessions sort into *Needs you*, *Working* (with a live one-line status), and *Done*. Archive what you don't need to see. |
45
45
  | 💬 **Follow-ups that interrupt** | A follow-up sent mid-run interrupts the agent and resumes it with your message. Turn on **Queue** to wait for the run to finish instead; queued messages can be cancelled. |
46
46
  | 🔎 **Command palette** | `⌘K` / `Ctrl+K` fuzzy-searches sessions, projects, and actions. |
47
- | 🤖 **Any harness** | Claude Code, Codex, opencode, or Cursor CLI. Pick the harness and model together in the launcher; the model is remembered per harness. Each card shows the harness it ran on. |
48
- | 🖥️ **Take over** | Opens your terminal with the harness's resume command (`claude --resume <id>`, `codex resume <id>`, …) in the project folder. Works with Terminal, iTerm, Ghostty, Warp, kitty, WezTerm, GNOME Terminal, Konsole, Alacritty, Windows Terminal, PowerShell, or a custom command. |
47
+ | 🤖 **Any harness** | Claude Code, Codex, opencode, or Cursor CLI. Pick the harness, model and thinking level together in the launcher; both are remembered per harness. Each card shows the harness it ran on. |
48
+ | 🖥️ **Take over** | Opens your terminal with the harness's resume command (`claude --resume <id>`, `codex resume <id>`, …) in the project folder. Works with Terminal, iTerm, Ghostty, Warp, cmux, kitty, WezTerm, GNOME Terminal, Konsole, Alacritty, Windows Terminal, PowerShell, or a custom command. |
49
49
  | 📎 **Attachments** | Paste screenshots or drop images, video, logs, or PDFs into the launcher. |
50
50
  | 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
51
51
  | 🪶 **No runtime dependencies** | The server uses only Node built-ins. Electron is needed only for the desktop app. |
@@ -202,6 +202,21 @@ If the harness has no system-prompt flag, prepend `systemPrompt` to the first pr
202
202
  - [x] opencode adapter
203
203
  - [x] Cursor CLI adapter
204
204
 
205
+ ## Telemetry
206
+
207
+ wisp sends anonymous usage stats and error reports to [PostHog](https://posthog.com) so we can see which agents people use and what breaks. The code is in [`server/telemetry.js`](server/telemetry.js).
208
+
209
+ **What is sent:** a random install id (not tied to you or your machine), the wisp, OS, Node and agent CLI versions, and per run: agent, model, mode, effort, status, duration, token counts and cost, how many files changed, and which presentation block types came back. Errors include a stack trace and message with your home directory and file paths removed, cut to 500 characters.
210
+
211
+ **Never sent:** prompts, replies, agent output, file contents, project names, paths or repo URLs.
212
+
213
+ **Opt out** with any of these:
214
+ - Settings → *Anonymous usage stats*
215
+ - `WISP_TELEMETRY=0`
216
+ - `DO_NOT_TRACK=1`
217
+
218
+ It is also off whenever `CI` is set.
219
+
205
220
  ## Contributing
206
221
 
207
222
  Issues and PRs are welcome. wisp has no build step: edit `web/` or `server/`, then run `npm run dev` (or `npm run dev:web`) to see your changes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Launch coding agents, skip the transcript, read the presentation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,10 +23,11 @@ export default {
23
23
  { id: 'sonnet', label: 'Sonnet', hint: 'Fast and smart, everyday work' },
24
24
  { id: 'haiku', label: 'Haiku', hint: 'Fastest, for quick tasks' },
25
25
  ],
26
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
26
27
 
27
28
  // Interactive command to take over a session in a terminal.
28
- resumeCommand({ resumeId, model }) {
29
- return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : [])];
29
+ resumeCommand({ resumeId, model, effort }) {
30
+ return [BIN, ...(resumeId ? ['--resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : [])];
30
31
  },
31
32
 
32
33
  detect() {
@@ -34,7 +35,7 @@ export default {
34
35
  },
35
36
 
36
37
  // Returns { stop(), done: Promise<void> }. Everything else is reported through onEvent.
37
- run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
38
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
38
39
  const args = [
39
40
  '-p',
40
41
  '--output-format', 'stream-json',
@@ -42,6 +43,7 @@ export default {
42
43
  '--permission-mode', PERMISSION_MODES[mode] || 'acceptEdits',
43
44
  ];
44
45
  if (model) args.push('--model', model);
46
+ if (effort) args.push('--effort', effort);
45
47
  if (resumeId) args.push('--resume', resumeId);
46
48
  if (Object.keys(mcpServers).length) {
47
49
  args.push('--mcp-config', JSON.stringify({ mcpServers }));
@@ -21,21 +21,23 @@ export default {
21
21
  name: 'Codex',
22
22
  askTool: 'mcp__wisp__ask_user',
23
23
  models: [{ id: '', label: 'Default', hint: 'Whatever Codex is set to' }],
24
+ efforts: ['minimal', 'low', 'medium', 'high', 'xhigh'],
24
25
 
25
- resumeCommand({ resumeId, model }) {
26
- return [BIN, ...(resumeId ? ['resume', resumeId] : []), ...(model ? ['--model', model] : [])];
26
+ resumeCommand({ resumeId, model, effort }) {
27
+ return [BIN, ...(resumeId ? ['resume', resumeId] : []), ...(model ? ['--model', model] : []), ...(effort ? ['-c', `model_reasoning_effort=${toml(effort)}`] : [])];
27
28
  },
28
29
 
29
30
  detect() {
30
31
  return detectVersion(BIN);
31
32
  },
32
33
 
33
- run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
34
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
34
35
  const args = ['exec', '--json', '--skip-git-repo-check'];
35
36
  // `resume` doesn't take --sandbox, so set it through config for both paths.
36
37
  if (mode === 'auto') args.push('--dangerously-bypass-approvals-and-sandbox');
37
38
  else args.push('-c', `sandbox_mode=${toml(SANDBOX[mode] || 'workspace-write')}`);
38
39
  if (model) args.push('-m', model);
40
+ if (effort) args.push('-c', `model_reasoning_effort=${toml(effort)}`);
39
41
  for (const [name, srv] of Object.entries(mcpServers)) {
40
42
  const key = `mcp_servers.${name}`;
41
43
  args.push('-c', `${key}.command=${toml(srv.command)}`, '-c', `${key}.args=${toml(srv.args || [])}`);
@@ -2,12 +2,14 @@
2
2
  // (./common.js has the shared plumbing) and register it here:
3
3
  //
4
4
  // {
5
- // id, name, models: [{id, label, hint?}],
5
+ // id, name, models: [{id, label, hint?, efforts?}],
6
+ // efforts?: string[] reasoning levels for every model (a model's own
7
+ // efforts list wins); omit if the CLI has none
6
8
  // askTool: string | null | undefined how the agent sees wisp's ask_user MCP tool;
7
9
  // null = can't inject MCP, ask via a question block
8
10
  // detect(): Promise<{ available, version?, error?, models? }> models overrides the static list
9
- // resumeCommand({ resumeId, model }): string[] argv to continue the session interactively
10
- // run({ prompt, cwd, resumeId, mode, model, systemPrompt, onEvent }): { stop(), done: Promise }
11
+ // resumeCommand({ resumeId, model, effort }): string[] argv to continue the session interactively
12
+ // run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, onEvent }): { stop(), done: Promise }
11
13
  // }
12
14
  //
13
15
  // onEvent receives normalized events:
@@ -35,7 +37,7 @@ export async function listAgents() {
35
37
  return Promise.all(
36
38
  Object.values(adapters).map(async (a) => {
37
39
  if (!detected.has(a.id)) detected.set(a.id, a.detect());
38
- return { id: a.id, name: a.name, models: a.models, ...(await detected.get(a.id)) };
40
+ return { id: a.id, name: a.name, models: a.models, efforts: a.efforts || [], ...(await detected.get(a.id)) };
39
41
  }),
40
42
  );
41
43
  }
@@ -11,23 +11,40 @@ export default {
11
11
  models: [{ id: '', label: 'Default', hint: 'Whatever opencode is set to' }],
12
12
 
13
13
  resumeCommand({ resumeId, model }) {
14
+ // The TUI has no --variant flag; the effort only applies to `run`.
14
15
  return [BIN, ...(resumeId ? ['--session', resumeId] : []), ...(model ? ['--model', model] : [])];
15
16
  },
16
17
 
17
18
  async detect() {
18
19
  const found = await detectVersion(BIN);
19
20
  if (!found.available) return found;
20
- // `opencode models` prints provider/model ids, one per line, for every configured provider.
21
- const ids = (await quietOutput(BIN, ['models'])).split('\n').map((l) => l.trim()).filter((l) => /^[\w.-]+\/\S+$/.test(l));
22
- return { ...found, models: [this.models[0], ...ids.map((id) => ({ id, label: id.split('/').slice(1).join('/'), hint: id.split('/')[0] }))] };
21
+ // `opencode models --verbose` prints each provider/model id followed by its JSON metadata;
22
+ // the metadata's `variants` are the reasoning efforts `run --variant` accepts for that model.
23
+ const out = await quietOutput(BIN, ['models', '--verbose']);
24
+ const models = [];
25
+ for (const [, id, json] of out.matchAll(/^([\w.-]+\/\S+)\n(\{[\s\S]*?\n\})$/gm)) {
26
+ let efforts = [];
27
+ try {
28
+ efforts = Object.keys(JSON.parse(json).variants || {});
29
+ } catch {}
30
+ models.push({ id, label: id.split('/').slice(1).join('/'), hint: id.split('/')[0], efforts });
31
+ }
32
+ if (!models.length) {
33
+ // Older builds without --verbose: plain ids, one per line.
34
+ for (const l of (await quietOutput(BIN, ['models'])).split('\n').map((x) => x.trim())) {
35
+ if (/^[\w.-]+\/\S+$/.test(l)) models.push({ id: l, label: l.split('/').slice(1).join('/'), hint: l.split('/')[0] });
36
+ }
37
+ }
38
+ return { ...found, models: [this.models[0], ...models] };
23
39
  },
24
40
 
25
- run({ prompt, attachments = [], cwd, resumeId, mode, model, systemPrompt, mcpServers = {}, onEvent }) {
41
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, onEvent }) {
26
42
  // The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
27
43
  const args = ['run', '--format', 'json'];
28
44
  if (mode === 'plan') args.push('--agent', 'plan');
29
45
  if (mode === 'auto') args.push('--auto');
30
46
  if (model) args.push('--model', model);
47
+ if (model && effort) args.push('--variant', effort);
31
48
  if (resumeId) args.push('--session', resumeId);
32
49
  for (const a of attachments) args.push('--file', a.path);
33
50
 
package/server/index.js CHANGED
@@ -11,6 +11,7 @@ import { Runner, summarize, newId } from './runner.js';
11
11
  import { adapters, listAgents } from './adapters/index.js';
12
12
  import { welcomeSession } from './welcome.js';
13
13
  import { openInTerminal, TERMINALS, shq } from './terminal.js';
14
+ import { Telemetry, envDisabled } from './telemetry.js';
14
15
 
15
16
  const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
16
17
 
@@ -62,6 +63,29 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
62
63
  runner.on('finished', (f) => broadcast('finished', f));
63
64
  setInterval(() => broadcast('ping', {}), 25_000).unref();
64
65
 
66
+ const telemetry = new Telemetry(store);
67
+ const run = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
68
+ runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...run(session, turn), attachments: turn.attachments.length }));
69
+ runner.on('ended', ({ session, turn, interrupted, errorText }) => {
70
+ const blocks = turn.presentation?.blocks || [];
71
+ const st = turn.stats || {};
72
+ telemetry.track('turn_ended', {
73
+ ...run(session, turn),
74
+ status: interrupted ? 'interrupted' : turn.status, // done | error | stopped | interrupted
75
+ heroStatus: blocks.find((b) => b.type === 'hero')?.status,
76
+ durationMs: turn.endedAt - turn.startedAt,
77
+ blockCount: blocks.length,
78
+ blockTypes: [...new Set(blocks.map((b) => b.type))],
79
+ presentationFallback: !!turn.presentation?.fallback, // agent ignored or broke the wisp protocol
80
+ changedFiles: turn.changes.length,
81
+ inputTokens: st.inputTokens,
82
+ outputTokens: st.outputTokens,
83
+ costUsd: st.costUsd,
84
+ });
85
+ if (errorText) telemetry.captureError(new Error(errorText), { type: 'AgentError', ...run(session, turn) });
86
+ });
87
+ if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
88
+
65
89
  const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
66
90
 
67
91
  const server = http.createServer(async (req, res) => {
@@ -81,6 +105,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
81
105
  return serveStatic(res, url.pathname);
82
106
  } catch (err) {
83
107
  console.error(err);
108
+ if (!err.status || err.status >= 500) telemetry.captureError(err, { route: `${req.method} ${url.pathname.replace(/\/[a-f0-9]{8,}(?=\/|$)/g, '/:id')}` });
84
109
  send(res, err.status || 500, { error: err.message });
85
110
  }
86
111
  });
@@ -107,6 +132,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
107
132
  const body = await readJson(req);
108
133
  const s = store.get(body.session);
109
134
  if (!s) return send(res, 404, { error: 'no such session' });
135
+ telemetry.track('ask_user', { agent: s.agent });
110
136
  return send(res, 200, { id: runner.ask(s, body) });
111
137
  }
112
138
  if (id === 'ask' && action && m === 'GET') return send(res, 200, runner.pollAnswer(action));
@@ -148,7 +174,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
148
174
  }
149
175
 
150
176
  if (root === 'config') {
151
- if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS });
177
+ if (m === 'GET') return send(res, 200, { ...store.config, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled } });
152
178
  if (m === 'PATCH') {
153
179
  const body = await readJson(req);
154
180
  if (body.defaults) Object.assign(store.config.defaults, body.defaults);
@@ -235,7 +261,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
235
261
  const cwd = path.resolve((body.cwd || process.cwd()).replace(/^~(?=$|[\\/])/, os.homedir()));
236
262
  if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return send(res, 400, { error: `Not a directory: ${cwd}` });
237
263
  if (!adapters[body.agent || 'claude']) return send(res, 400, { error: 'unknown agent' });
238
- const s = runner.create({ prompt: body.prompt, cwd, agent: body.agent, mode: body.mode, model: body.model, attachments: resolveAttachments(body.attachments) });
264
+ const s = runner.create({ prompt: body.prompt, cwd, agent: body.agent, mode: body.mode, model: body.model, effort: /^[\w-]{1,20}$/.test(body.effort || '') ? body.effort : '', attachments: resolveAttachments(body.attachments) });
239
265
  return send(res, 201, summarize(s, store));
240
266
  }
241
267
  }
@@ -287,7 +313,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
287
313
  if (action === 'takeover') {
288
314
  const adapter = adapters[s.agent];
289
315
  if (!adapter?.resumeCommand || !s.agentSessionId) return send(res, 400, { error: 'This session cannot be resumed in a terminal.' });
290
- const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model });
316
+ const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
291
317
  const command = `cd ${shq(s.cwd)} && ${argv.map(shq).join(' ')}`;
292
318
  if (m === 'GET') return send(res, 200, { command });
293
319
  if (m === 'POST') {
@@ -297,6 +323,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
297
323
  }
298
324
  openInTerminal({ cwd: s.cwd, argv, terminal: store.config.defaults.terminal || 'auto', custom: store.config.defaults.terminalCommand });
299
325
  s.takenOverAt = Date.now();
326
+ telemetry.track('takeover', { agent: s.agent, terminal: store.config.defaults.terminal || 'auto' });
300
327
  store.save(s);
301
328
  return send(res, 200, { command });
302
329
  }
@@ -341,6 +368,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
341
368
  const shutdown = () => {
342
369
  for (const s of store.all()) runner.stop(s);
343
370
  store.flush();
371
+ telemetry.persist();
344
372
  };
345
373
  if (handleSignals) {
346
374
  const exit = () => (shutdown(), process.exit(0));
@@ -353,7 +381,14 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
353
381
  server.listen(port, host, resolve);
354
382
  });
355
383
 
356
- return { url: `http://localhost:${port}`, server, store, runner, shutdown };
384
+ if (telemetry.enabled) {
385
+ if (store.config.defaults.telemetryNotice !== 'seen') console.log('wisp sends anonymous usage stats and error reports (no prompts, paths or code). Opt out: Settings, or WISP_TELEMETRY=0 / DO_NOT_TRACK=1.');
386
+ listAgents()
387
+ .then((agents) => telemetry.track('app_started', { agentsInstalled: agents.filter((a) => a.available).map((a) => a.id), ...Object.fromEntries(agents.filter((a) => a.available).map((a) => [`${a.id}Version`, a.version])) }))
388
+ .catch(() => {});
389
+ }
390
+
391
+ return { url: `http://localhost:${port}`, server, store, runner, shutdown, telemetry };
357
392
  }
358
393
 
359
394
  function send(res, status, body) {
package/server/runner.js CHANGED
@@ -36,6 +36,7 @@ export function summarize(s, store) {
36
36
  color: project?.color || 'slate',
37
37
  mode: s.mode,
38
38
  model: s.model,
39
+ effort: s.effort || '',
39
40
  state: s.state,
40
41
  unread: s.unread,
41
42
  archived: !!s.archived,
@@ -74,7 +75,7 @@ export class Runner extends EventEmitter {
74
75
  }
75
76
  }
76
77
 
77
- create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', attachments = [] }) {
78
+ create({ prompt, cwd, agent = 'claude', mode = 'edits', model = '', effort = '', attachments = [] }) {
78
79
  if (!adapters[agent]) throw new Error(`Unknown agent: ${agent}`);
79
80
  const now = Date.now();
80
81
  const session = {
@@ -85,6 +86,7 @@ export class Runner extends EventEmitter {
85
86
  cwd,
86
87
  mode,
87
88
  model,
89
+ effort,
88
90
  state: 'running',
89
91
  unread: false,
90
92
  createdAt: now,
@@ -203,6 +205,7 @@ export class Runner extends EventEmitter {
203
205
  prompt,
204
206
  agent: session.agent,
205
207
  model: session.model,
208
+ effort: session.effort || '',
206
209
  attachments: attachments.map(({ id, name, mime, size }) => ({ id, name, mime, size })),
207
210
  startedAt: Date.now(),
208
211
  endedAt: null,
@@ -217,6 +220,7 @@ export class Runner extends EventEmitter {
217
220
  session.unread = false;
218
221
  delete session.archived;
219
222
  this.#touch(session, { turn: turn.id });
223
+ this.emit('started', { session, turn });
220
224
 
221
225
  let released = false;
222
226
  let linger;
@@ -228,6 +232,7 @@ export class Runner extends EventEmitter {
228
232
  resumeId: session.agentSessionId,
229
233
  mode: session.mode,
230
234
  model: session.model,
235
+ effort: session.effort || '',
231
236
  systemPrompt: systemPrompt({ askTool: adapter.askTool }),
232
237
  mcpServers: this.bridge && adapter.askTool !== null
233
238
  ? {
@@ -337,6 +342,7 @@ export class Runner extends EventEmitter {
337
342
  session.unread = !interrupted;
338
343
  this.store.save(session, { now: true });
339
344
  this.#touch(session, { turn: turn.id });
345
+ this.emit('ended', { session, turn, interrupted, errorText: ev.isError && !ev.stopped ? ev.text : '' });
340
346
  // The follow-up starts right away; don't notify for the run it replaced.
341
347
  if (!interrupted) this.emit('finished', { session: summarize(session, this.store) });
342
348
  }
package/server/store.js CHANGED
@@ -8,7 +8,7 @@ import { parsePresentation } from './protocol.js';
8
8
 
9
9
  const DEFAULT_CONFIG = {
10
10
  projects: [], // [{ id, name, path, color, addedAt }]
11
- defaults: { agent: 'claude', mode: 'edits', model: '' },
11
+ defaults: { agent: 'claude', mode: 'edits', model: '', effort: '' },
12
12
  };
13
13
 
14
14
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
@@ -0,0 +1,132 @@
1
+ // Anonymous usage analytics + error reports, sent to PostHog over its plain HTTP API (no SDK, to
2
+ // stay dependency-free). What goes out is fixed values only — agent, model, mode, status, timings,
3
+ // counts — never prompts, paths, project names or agent output. Error messages and stacks are
4
+ // scrubbed of the home directory and truncated.
5
+ //
6
+ // Off when any of: no API key, DO_NOT_TRACK=1, WISP_TELEMETRY=0, CI, or "telemetry": "off" in
7
+ // settings. See the Telemetry section of the README.
8
+
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ // PostHog project API key: write-only, safe to ship in the client.
16
+ const KEY = process.env.WISP_POSTHOG_KEY ?? 'phc_wowwZkbP8e4zYuwT67JtX6oxdPwkMWL5kKnrUmYYXhcD';
17
+ const HOST = (process.env.WISP_POSTHOG_HOST || 'https://us.i.posthog.com').replace(/\/$/, '');
18
+ const FLUSH_MS = 15_000;
19
+ const MAX_QUEUE = 50;
20
+ const TIMEOUT_MS = 3000;
21
+
22
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
+ const truthy = (v) => !!v && !/^(0|false|no|off)$/i.test(v);
24
+
25
+ export function envDisabled(env = process.env) {
26
+ return !KEY || truthy(env.DO_NOT_TRACK) || /^(0|false|no|off)$/i.test(env.WISP_TELEMETRY || '') || truthy(env.CI);
27
+ }
28
+
29
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
30
+
31
+ // Paths in messages and stacks would leak usernames and project names: wisp's own files keep
32
+ // their package-relative path, anything else is cut down to its file name.
33
+ export function scrub(text = '', { home = os.homedir(), root = ROOT } = {}) {
34
+ let s = String(text).split(root).join('<wisp>');
35
+ if (home) s = s.split(home).join('~');
36
+ // Any other absolute path keeps only its file name.
37
+ return s
38
+ .replace(/(?<![\w<>~.:/\\-])(?:~|[A-Za-z]:)?[/\\](?:[^\s'"`():/\\]+[/\\])+([^\s'"`():/\\]*)/g, (m, base) => `…/${base}`)
39
+ .slice(0, 500);
40
+ }
41
+
42
+ function frames(stack = '') {
43
+ return String(stack)
44
+ .split('\n')
45
+ .slice(1, 30)
46
+ .map((l) => l.trim().match(/^at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/))
47
+ .filter(Boolean)
48
+ .map(([, fn, file, line, col]) => ({ platform: 'custom', lang: 'javascript', function: fn || '?', filename: scrub(file.replace(/^file:\/\//, '')), lineno: +line, colno: +col, in_app: file.includes(ROOT) && !file.includes('node_modules') }))
49
+ .reverse(); // PostHog wants the innermost frame last
50
+ }
51
+
52
+ export class Telemetry {
53
+ constructor(store, { shell = process.versions.electron ? 'electron' : 'browser' } = {}) {
54
+ this.store = store;
55
+ this.queue = [];
56
+ this.pendingFile = path.join(store.home, 'telemetry-pending.json');
57
+ this.common = {
58
+ $lib: 'wisp',
59
+ $process_person_profile: false, // anonymous events: no person profiles
60
+ version: VERSION,
61
+ os: process.platform,
62
+ osRelease: os.release(),
63
+ arch: process.arch,
64
+ node: process.versions.node,
65
+ electron: process.versions.electron || undefined,
66
+ shell,
67
+ };
68
+ this.timer = setInterval(() => this.flush(), FLUSH_MS);
69
+ this.timer.unref();
70
+ this.#sendPending();
71
+ }
72
+
73
+ get enabled() {
74
+ return !envDisabled() && this.store.config.defaults.telemetry !== 'off';
75
+ }
76
+
77
+ track(event, props = {}) {
78
+ if (!this.enabled) return;
79
+ if (!this.store.config.telemetryId) {
80
+ this.store.config.telemetryId = randomUUID(); // random per install, not tied to the machine or user
81
+ this.store.saveConfig();
82
+ }
83
+ this.queue.push({ event, timestamp: new Date().toISOString(), properties: { ...this.common, ...props, distinct_id: this.store.config.telemetryId } });
84
+ if (this.queue.length >= MAX_QUEUE) this.flush();
85
+ }
86
+
87
+ // PostHog error tracking format.
88
+ captureError(err, props = {}) {
89
+ const e = err instanceof Error ? err : new Error(String(err));
90
+ this.track('$exception', {
91
+ ...props,
92
+ $exception_list: [{ type: props.type || e.name || 'Error', value: scrub(e.message), mechanism: { handled: props.handled ?? true, synthetic: false }, stacktrace: { type: 'raw', frames: frames(e.stack) } }],
93
+ });
94
+ }
95
+
96
+ async flush() {
97
+ if (!this.queue.length) return;
98
+ const batch = this.queue.splice(0);
99
+ try {
100
+ await fetch(`${HOST}/batch/`, {
101
+ method: 'POST',
102
+ headers: { 'content-type': 'application/json' },
103
+ body: JSON.stringify({ api_key: KEY, batch }),
104
+ signal: AbortSignal.timeout(TIMEOUT_MS),
105
+ });
106
+ } catch {} // offline or blocked: drop it, telemetry must never get in the way
107
+ }
108
+
109
+ // Shutdown and crashes leave no time for a request: write what's queued to disk (sync) and
110
+ // send it on the next start.
111
+ persist() {
112
+ clearInterval(this.timer);
113
+ if (!this.queue.length) return;
114
+ try {
115
+ fs.writeFileSync(this.pendingFile, JSON.stringify(this.queue.splice(0)));
116
+ } catch {}
117
+ }
118
+
119
+ crashed(err) {
120
+ if (!this.enabled) return;
121
+ this.captureError(err, { handled: false, type: err?.name || 'UncaughtException' });
122
+ this.persist();
123
+ }
124
+
125
+ #sendPending() {
126
+ try {
127
+ const pending = JSON.parse(fs.readFileSync(this.pendingFile, 'utf8'));
128
+ fs.rmSync(this.pendingFile, { force: true });
129
+ if (this.enabled && Array.isArray(pending)) this.queue.push(...pending.slice(-200));
130
+ } catch {}
131
+ }
132
+ }
@@ -13,6 +13,7 @@ const ALL_TERMINALS = [
13
13
  { id: 'terminal', label: 'Terminal.app', os: ['darwin'] },
14
14
  { id: 'iterm', label: 'iTerm', os: ['darwin'] },
15
15
  { id: 'warp', label: 'Warp', os: ['darwin'] },
16
+ { id: 'cmux', label: 'cmux', os: ['darwin'] },
16
17
  { id: 'wt', label: 'Windows Terminal', os: ['win32'] },
17
18
  { id: 'powershell', label: 'PowerShell', os: ['win32'] },
18
19
  { id: 'cmd', label: 'Command Prompt', os: ['win32'] },
@@ -34,11 +35,16 @@ export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
34
35
  const psq = (s) => `'${String(s).replace(/'/g, "''")}'`; // PowerShell single-quoted string
35
36
  const asStr = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; // AppleScript string
36
37
 
38
+ const CMUX_APP = '/Applications/cmux.app';
39
+ const CMUX_CLI = `${CMUX_APP}/Contents/Resources/bin/cmux`;
40
+
37
41
  const LINUX_ORDER = ['ghostty', 'kitty', 'wezterm', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'alacritty', 'x-terminal-emulator', 'xterm'];
38
42
 
39
43
  export function detectTerminal({ platform = process.platform, env = process.env, has = (b) => onPath(b, env), exists = fs.existsSync } = {}) {
40
44
  if (platform === 'darwin') {
41
45
  if (env.TERM_PROGRAM === 'iTerm.app' && exists('/Applications/iTerm.app')) return 'iterm';
46
+ // cmux is built on Ghostty and reports TERM_PROGRAM=ghostty, so check it first.
47
+ if ((env.CMUX_WORKSPACE_ID || env.__CFBundleIdentifier === 'com.cmuxterm.app') && exists(CMUX_APP)) return 'cmux';
42
48
  if (env.TERM_PROGRAM === 'ghostty' && exists('/Applications/Ghostty.app')) return 'ghostty';
43
49
  return 'terminal';
44
50
  }
@@ -69,6 +75,15 @@ export function terminalPlan({ cwd, argv, terminal = 'auto', custom = '', platfo
69
75
  case 'warp':
70
76
  // Warp has no scripting API; open a tab in the folder and type via System Events.
71
77
  return [{ command: 'open', args: ['-a', 'Warp', cwd] }, { delay: 1200 }, { osa: `tell application "System Events" to keystroke ${asStr(cmd)} & return` }];
78
+ case 'cmux': {
79
+ // `cmux new-workspace` talks to cmux's socket, which by default only accepts processes started
80
+ // inside cmux. When it's refused (or cmux isn't running), open the folder as a new workspace
81
+ // and type the command, like Warp.
82
+ const cli = (has || ((b) => onPath(b, env)))('cmux') ? 'cmux' : CMUX_CLI;
83
+ const typeIt = `tell application "System Events" to keystroke ${asStr(cmd)} & return`;
84
+ const script = '"$1" new-workspace --cwd "$2" --command "$3" >/dev/null 2>&1 && open -a cmux || { open -a cmux "$2" && sleep 1.5 && osascript -e "$4"; }';
85
+ return [{ command: '/bin/sh', args: ['-c', script, 'sh', cli, cwd, cmd, typeIt], options: detached }];
86
+ }
72
87
  case 'wt':
73
88
  // `;` separates wt subcommands, so it can't appear unescaped in the command line.
74
89
  return [{ command: 'wt.exe', args: ['-d', cwd, 'powershell.exe', '-NoExit', '-Command', ps.replace(/;/g, '\\;')], options: detached }];
package/web/css/app.css CHANGED
@@ -290,6 +290,7 @@ select {
290
290
  .rb-h { font-weight: 500; color: var(--ink); }
291
291
  .rb-m { position: relative; margin-left: 13px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
292
292
  .rb-m::before { content: ''; position: absolute; left: -7px; top: 50%; width: 1px; height: 12px; margin-top: -6px; background: var(--line-2); }
293
+ .rb-e { flex: none; margin-left: 6px; padding: 1px 6px; border-radius: 6px; font-size: 11.5px; background: var(--accent-soft); color: var(--accent-ink); }
293
294
  .runner-btn .chev { width: 12px; height: 12px; margin-left: 6px; color: var(--faint); transform: rotate(90deg); transition: transform 0.2s var(--ease); }
294
295
  .runner-btn.open .chev { transform: rotate(-90deg); }
295
296
 
@@ -323,6 +324,14 @@ select {
323
324
  .pk-check { width: 15px; height: 15px; flex: none; color: var(--accent); visibility: hidden; }
324
325
  .pk-m.on .pk-check { visibility: visible; }
325
326
  .pk-m.on b { color: var(--accent-ink); }
327
+ .pk-effort { flex: none; display: flex; flex-direction: column; gap: 6px; padding: 8px 8px 10px; border-top: 1px solid var(--line); background: var(--surface-2); }
328
+ .pk-effort[hidden] { display: none; }
329
+ .pk-ehead { padding: 0 2px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
330
+ .pk-seg { display: flex; min-width: 0; gap: 2px; padding: 2px; overflow-x: auto; border-radius: 9px; background: var(--bg-2); border: 1px solid var(--line); }
331
+ .pk-seg button { flex: 1 0 auto; padding: 4px 7px; border-radius: 7px; font-size: 12px; color: var(--muted); white-space: nowrap; outline: 0; transition: background 0.12s, color 0.12s; }
332
+ .pk-seg button:hover { color: var(--ink); }
333
+ .pk-seg button:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); }
334
+ .pk-seg button.on { background: var(--surface); color: var(--accent-ink); font-weight: 500; box-shadow: var(--shadow-1); }
326
335
  .pk-empty { padding: 30px 10px; text-align: center; font-size: 13px; color: var(--muted); }
327
336
  @media (max-width: 560px) {
328
337
  .picker { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
package/web/js/app.js CHANGED
@@ -73,10 +73,18 @@ function harnessLogo(agent) {
73
73
  const src = l && `/logos/${l.file}.svg`;
74
74
  return !l ? icon('spark') : l.mono ? `<i class="mono" style="--logo:url(${src})"></i>` : `<img src="${src}" alt="">`;
75
75
  }
76
- function modelBadge(agent, model) {
76
+ // Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
77
+ // else the harness-wide one. Empty means the harness has no knob for it.
78
+ function effortsFor(agent, model) {
79
+ const a = S.agents.find((x) => x.id === agent);
80
+ return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
81
+ }
82
+ const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
83
+
84
+ function modelBadge(agent, model, effort) {
77
85
  if (agent === 'wisp') return '';
78
86
  const label = (model && S.agents.find((a) => a.id === agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
79
- const tip = `${agentName(agent)} · ${label}`;
87
+ const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
80
88
  const logo = harnessLogo(agent);
81
89
  return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
82
90
  }
@@ -241,7 +249,7 @@ function renderHead(n, s) {
241
249
  <span class="it-side">
242
250
  ${pill}
243
251
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
244
- ${modelBadge(s.agent, s.model)}
252
+ ${modelBadge(s.agent, s.model, s.effort)}
245
253
  <span class="q-proj">${esc(s.project)}</span>
246
254
  <span class="q-time">${ago(s.updatedAt)}</span>
247
255
  ${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
@@ -484,21 +492,33 @@ function renderLauncher() {
484
492
  const models = S.agents.find((a) => a.id === id)?.models || [];
485
493
  return models.some((m) => m.id === want) ? want : '';
486
494
  };
495
+ // Thinking level too, and only if the chosen model still accepts it.
496
+ const savedEffort = (id, model) => {
497
+ const d = S.config.defaults;
498
+ const want = d.efforts?.[id] ?? (id === d.agent ? d.effort : '');
499
+ return effortsFor(id, model).includes(want) ? want : '';
500
+ };
487
501
  let curModel = savedModel(agent?.id);
502
+ let curEffort = savedEffort(agent?.id, curModel);
488
503
  const runnerBtn = $('#runner', box);
489
504
  const paintRunner = () => {
490
505
  const m = (agent?.models || []).find((x) => x.id === curModel);
491
- runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent?.id)}</span><span class="rb-h">${esc(agent?.name || 'No agent')}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${icon('chevron', 'chev')}`;
506
+ runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent?.id)}</span><span class="rb-h">${esc(agent?.name || 'No agent')}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${curEffort ? `<span class="rb-e">${esc(effortLabel(curEffort))}</span>` : ''}${icon('chevron', 'chev')}`;
507
+ runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
492
508
  };
493
509
  paintRunner();
494
510
  runnerBtn.onclick = () =>
495
- runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, savedModel }, (id, model) => {
511
+ runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
496
512
  agent = S.agents.find((a) => a.id === id);
497
513
  curModel = model;
514
+ curEffort = effort;
498
515
  paintRunner();
499
- const models = { ...S.config.defaults.models, [id]: model };
500
- Object.assign(S.config.defaults, { agent: id, model, models });
501
- api('/config', { method: 'PATCH', body: { defaults: { agent: id, model, models } } }).catch(() => {});
516
+ const d = S.config.defaults;
517
+ const models = { ...d.models, [id]: model };
518
+ // A model without levels leaves the harness's saved level alone, so switching back restores it.
519
+ const efforts = effort || effortsFor(id, model).length ? { ...d.efforts, [id]: effort } : d.efforts;
520
+ Object.assign(d, { agent: id, model, models, effort, efforts });
521
+ api('/config', { method: 'PATCH', body: { defaults: { agent: id, model, models, effort, efforts } } }).catch(() => {});
502
522
  });
503
523
 
504
524
  const launch = async () => {
@@ -510,7 +530,7 @@ function renderLauncher() {
510
530
  btn.disabled = true;
511
531
  try {
512
532
  const attachments = await tray.ids();
513
- const s = await api('/sessions', { method: 'POST', body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, attachments } });
533
+ const s = await api('/sessions', { method: 'POST', body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments } });
514
534
  S.sessions.set(s.id, s);
515
535
  ta.value = '';
516
536
  S.drafts.delete('home');
@@ -765,6 +785,7 @@ function renderBody(id, { fresh }) {
765
785
  <span class="m">${esc(agentName(s.agent))}</span>
766
786
  <span class="m">${esc(MODES.find((m) => m.id === s.mode)?.label || s.mode)}</span>
767
787
  ${s.model ? `<span class="m">${esc(s.model)}</span>` : ''}
788
+ ${s.effort ? `<span class="m">${esc(effortLabel(s.effort))} thinking</span>` : ''}
768
789
  <span class="m path" title="${esc(s.cwd)}">${esc((proj?.path || s.cwd).replace(S.config.home || '', '~'))}</span>`;
769
790
  // Done lives both in the top bar and next to the composer, so it's reachable after reading.
770
791
  $$('[data-a="done"]', body).forEach((b) => {
@@ -838,7 +859,7 @@ function renderTurn(s, t, isLatest, fold) {
838
859
  <div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
839
860
  ${long ? '<button class="link xs more-btn">Show all</button>' : ''}
840
861
  ${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
841
- <div class="ask-foot">${modelBadge(t.agent || s.agent, t.model ?? s.model)}<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time></div>
862
+ <div class="ask-foot">${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time></div>
842
863
  ${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
843
864
  </div>`);
844
865
  if (fold) ask.querySelector('.t-unfold').onclick = fold;
@@ -1199,9 +1220,10 @@ function rename(id) {
1199
1220
  title.onblur = () => done(true);
1200
1221
  }
1201
1222
 
1202
- // One popover for both choices: harnesses on the left, the focused harness's models on the right.
1203
- // Clicking (or arrowing to) a harness previews its models; picking a model commits both.
1204
- function runnerPicker(anchor, { agent, model, savedModel }, pick) {
1223
+ // One popover for all three choices: harnesses on the left, the focused harness's models on the
1224
+ // right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
1225
+ // models; picking a model commits harness + model, picking a thinking level commits it in place.
1226
+ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort }, pick) {
1205
1227
  const open = $('.picker');
1206
1228
  if (open) return open.close();
1207
1229
  let focusAgent = S.agents.find((a) => a.id === agent && a.available) ? agent : S.agents.find((a) => a.available)?.id;
@@ -1214,7 +1236,7 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
1214
1236
  const left = Math.max(12, Math.min(r.left, right - width));
1215
1237
  const below = r.bottom + 440 < innerHeight;
1216
1238
  const node = el(`
1217
- <div class="picker" role="dialog" aria-label="Harness and model" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
1239
+ <div class="picker" role="dialog" aria-label="Harness, model and thinking level" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
1218
1240
  <div class="pk-side" role="listbox" aria-label="Harness">
1219
1241
  <div class="pk-head">Harness</div>
1220
1242
  ${S.agents
@@ -1230,11 +1252,38 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
1230
1252
  <div class="pk-main">
1231
1253
  <label class="pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
1232
1254
  <div class="pk-list" role="listbox" aria-label="Model"></div>
1255
+ <div class="pk-effort" role="radiogroup" aria-label="Thinking level"></div>
1233
1256
  </div>
1234
1257
  </div>`);
1235
1258
  const input = $('.pk-search input', node);
1236
1259
  const list = $('.pk-list', node);
1237
1260
  const sideRows = $$('.pk-h', node);
1261
+ const seg = $('.pk-effort', node);
1262
+ const modelOf = (id) => (id === agent ? model : savedModel(id));
1263
+ const effortOf = (id) => (id === agent ? effort : savedEffort(id, modelOf(id)));
1264
+
1265
+ const paintEffort = () => {
1266
+ const levels = effortsFor(focusAgent, modelOf(focusAgent));
1267
+ const current = effortOf(focusAgent);
1268
+ seg.hidden = !levels.length;
1269
+ seg.innerHTML = `<span class="pk-ehead">Thinking</span><span class="pk-seg">${['', ...levels]
1270
+ .map((e) => `<button role="radio" aria-checked="${e === current}" class="${e === current ? 'on' : ''}" data-e="${esc(e)}" title="${e ? `${esc(effortLabel(e))} reasoning effort` : 'Whatever the harness is set to'}">${esc(e ? effortLabel(e) : 'Default')}</button>`)
1271
+ .join('')}</span>`;
1272
+ };
1273
+ const chooseEffort = (e) => {
1274
+ const m = modelOf(focusAgent);
1275
+ if (focusAgent === agent && e === effort) return;
1276
+ if (focusAgent !== agent) {
1277
+ $$('.pk-dot', node).forEach((d) => d.remove());
1278
+ sideRows.find((b) => b.dataset.id === focusAgent)?.insertAdjacentHTML('beforeend', '<span class="pk-dot" title="In use"></span>');
1279
+ }
1280
+ agent = focusAgent;
1281
+ model = m;
1282
+ effort = e;
1283
+ pick(agent, model, effort);
1284
+ paintEffort();
1285
+ $(`button[data-e="${CSS.escape(e)}"]`, seg)?.focus();
1286
+ };
1238
1287
 
1239
1288
  const paintModels = () => {
1240
1289
  const a = S.agents.find((x) => x.id === focusAgent);
@@ -1263,6 +1312,7 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
1263
1312
  )
1264
1313
  .join('') + '<div class="pk-empty" hidden>No models match</div>';
1265
1314
  filter();
1315
+ paintEffort();
1266
1316
  };
1267
1317
  const rows = () => $$('.pk-m', list).filter((b) => !b.hidden);
1268
1318
  const filter = () => {
@@ -1296,7 +1346,10 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
1296
1346
  const choose = (m) => {
1297
1347
  close();
1298
1348
  anchor.focus();
1299
- if (focusAgent !== agent || m !== model) pick(focusAgent, m);
1349
+ // Carry the thinking level over when the new model accepts it, else fall back to the saved one.
1350
+ const cur = effortOf(focusAgent);
1351
+ const e = effortsFor(focusAgent, m).includes(cur) ? cur : savedEffort(focusAgent, m);
1352
+ if (focusAgent !== agent || m !== model || e !== effort) pick(focusAgent, m, e);
1300
1353
  };
1301
1354
  const outside = (e) => !node.contains(e.target) && !anchor.contains(e.target) && close();
1302
1355
  const step = (items, d) => {
@@ -1310,8 +1363,22 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
1310
1363
  const keys = (e) => {
1311
1364
  const inSide = sideRows.includes(document.activeElement);
1312
1365
  const live = sideRows.filter((b) => !b.disabled);
1366
+ const segBtns = $$('button', seg);
1367
+ const inSeg = segBtns.includes(document.activeElement);
1313
1368
  if (e.key === 'Escape') return e.preventDefault(), e.stopPropagation(), close(), anchor.focus();
1369
+ // Tab hops from the model list to the thinking levels and back; anywhere else it leaves.
1370
+ if (e.key === 'Tab' && !seg.hidden && (inSeg ? e.shiftKey : !e.shiftKey && !inSide)) {
1371
+ e.preventDefault();
1372
+ return inSeg ? focusModels() : (segBtns.find((b) => b.classList.contains('on')) || segBtns[0]).focus();
1373
+ }
1314
1374
  if (e.key === 'Tab') return close();
1375
+ if (inSeg && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
1376
+ e.preventDefault();
1377
+ e.stopPropagation();
1378
+ return step(segBtns, e.key === 'ArrowRight' ? 1 : -1);
1379
+ }
1380
+ if (inSeg && e.key === 'ArrowUp') return e.preventDefault(), focusModels();
1381
+ if (inSeg && (e.key === 'Enter' || e.key === ' ')) return e.preventDefault(), chooseEffort(document.activeElement.dataset.e);
1315
1382
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1316
1383
  e.preventDefault();
1317
1384
  e.stopPropagation();
@@ -1337,6 +1404,10 @@ function runnerPicker(anchor, { agent, model, savedModel }, pick) {
1337
1404
  if (b) choose(b.dataset.id);
1338
1405
  };
1339
1406
  input.oninput = filter;
1407
+ seg.onclick = (e) => {
1408
+ const b = e.target.closest('button[data-e]');
1409
+ if (b) chooseEffort(b.dataset.e);
1410
+ };
1340
1411
 
1341
1412
  paintModels();
1342
1413
  setTimeout(() => document.addEventListener('mousedown', outside));
@@ -1630,6 +1701,7 @@ function openSettings() {
1630
1701
  <label class="custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><span>Terminal command</span>
1631
1702
  <input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
1632
1703
  <div class="setting"><span>Desktop notifications</span><span class="notif-hint muted xs"></span><button class="switch" id="notif" role="switch"></button></div>
1704
+ <div class="setting"><span>Anonymous usage stats</span><span class="tele-hint muted xs"></span><button class="switch" id="tele" role="switch"></button></div>
1633
1705
  <div class="agents">${S.agents
1634
1706
  .map((a) => `<div class="agent-row"><span class="dot ${a.available ? 'ok' : 'bad'}"></span><b>${esc(a.name)}</b><span class="muted">${esc(a.available ? a.version : a.error || 'not found')}</span></div>`)
1635
1707
  .join('')}<p class="muted xs">Pick the agent next to the model in the launcher. Others plug in via <code>server/adapters</code>.</p></div>
@@ -1662,6 +1734,28 @@ function openSettings() {
1662
1734
  paint();
1663
1735
  };
1664
1736
  paint();
1737
+ // Agent, model, status, timings and scrubbed errors only; never prompts, paths or code.
1738
+ const tele = $('#tele', node);
1739
+ const paintTele = () => {
1740
+ const t = S.config.telemetry || {};
1741
+ tele.setAttribute('aria-checked', !!t.enabled);
1742
+ tele.disabled = !t.available;
1743
+ $('.tele-hint', node).textContent = !t.available ? 'Off (DO_NOT_TRACK, WISP_TELEMETRY=0 or CI)' : 'No prompts, paths or code';
1744
+ };
1745
+ tele.onclick = async () => {
1746
+ await api('/config', { method: 'PATCH', body: { defaults: { telemetry: S.config.telemetry?.enabled ? 'off' : 'on' } } });
1747
+ await loadConfig();
1748
+ paintTele();
1749
+ };
1750
+ paintTele();
1751
+ }
1752
+
1753
+ // Say once that anonymous stats are on, with a way out.
1754
+ function telemetryNotice() {
1755
+ if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
1756
+ S.config.defaults.telemetryNotice = 'seen';
1757
+ api('/config', { method: 'PATCH', body: { defaults: { telemetryNotice: 'seen' } } }).catch(() => {});
1758
+ toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: openSettings } });
1665
1759
  }
1666
1760
 
1667
1761
  function shortcuts() {
@@ -1970,4 +2064,5 @@ window.addEventListener('focus', () => {
1970
2064
  connect();
1971
2065
  route();
1972
2066
  $('#prompt')?.focus();
2067
+ telemetryNotice();
1973
2068
  })();