wisp-agents 0.3.0 → 0.4.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,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.0] - 2026-10-17
9
+
10
+ ### Added
11
+
12
+ - 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.
13
+ - 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.
14
+
8
15
  ## [0.3.0] - 2026-10-17
9
16
 
10
17
  ### Added
@@ -76,7 +83,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
76
83
  - Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
77
84
  - Older turns are folded, with a floating collapse-all button and back-to-top.
78
85
 
79
- [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.3.0...HEAD
86
+ [Unreleased]: https://github.com/M0xoo/wisp/compare/v0.4.0...HEAD
87
+ [0.4.0]: https://github.com/M0xoo/wisp/compare/v0.3.0...v0.4.0
80
88
  [0.3.0]: https://github.com/M0xoo/wisp/compare/v0.2.2...v0.3.0
81
89
  [0.2.2]: https://github.com/M0xoo/wisp/compare/v0.2.1...v0.2.2
82
90
  [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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wisp-agents",
3
- "version": "0.3.0",
3
+ "version": "0.4.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
@@ -235,7 +235,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
235
235
  const cwd = path.resolve((body.cwd || process.cwd()).replace(/^~(?=$|[\\/])/, os.homedir()));
236
236
  if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return send(res, 400, { error: `Not a directory: ${cwd}` });
237
237
  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) });
238
+ 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
239
  return send(res, 201, summarize(s, store));
240
240
  }
241
241
  }
@@ -287,7 +287,7 @@ export async function startServer({ port = 4477, host = '127.0.0.1', handleSigna
287
287
  if (action === 'takeover') {
288
288
  const adapter = adapters[s.agent];
289
289
  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 });
290
+ const argv = adapter.resumeCommand({ resumeId: s.agentSessionId, model: s.model, effort: s.effort });
291
291
  const command = `cd ${shq(s.cwd)} && ${argv.map(shq).join(' ')}`;
292
292
  if (m === 'GET') return send(res, 200, { command });
293
293
  if (m === 'POST') {
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,
@@ -228,6 +231,7 @@ export class Runner extends EventEmitter {
228
231
  resumeId: session.agentSessionId,
229
232
  mode: session.mode,
230
233
  model: session.model,
234
+ effort: session.effort || '',
231
235
  systemPrompt: systemPrompt({ askTool: adapter.askTool }),
232
236
  mcpServers: this.bridge && adapter.askTool !== null
233
237
  ? {
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']);
@@ -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));