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 +9 -1
- package/README.md +2 -2
- package/package.json +1 -1
- package/server/adapters/claude.js +5 -3
- package/server/adapters/codex.js +5 -3
- package/server/adapters/index.js +6 -4
- package/server/adapters/opencode.js +21 -4
- package/server/index.js +2 -2
- package/server/runner.js +5 -1
- package/server/store.js +1 -1
- package/server/terminal.js +15 -0
- package/web/css/app.css +9 -0
- package/web/js/app.js +86 -15
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.
|
|
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
|
|
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
|
@@ -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 }));
|
package/server/adapters/codex.js
CHANGED
|
@@ -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 || [])}`);
|
package/server/adapters/index.js
CHANGED
|
@@ -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
|
|
21
|
-
|
|
22
|
-
|
|
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']);
|
package/server/terminal.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
1203
|
-
// Clicking (or arrowing to) a harness previews its
|
|
1204
|
-
|
|
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
|
|
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
|
-
|
|
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));
|