wisp-agents 0.2.2 → 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.
@@ -1,73 +1,142 @@
1
1
  // Open a command in the user's terminal so they can take over an agent session interactively.
2
+ //
3
+ // terminalPlan() is pure (platform and PATH lookups are injected) so every platform's command
4
+ // line can be tested anywhere; openInTerminal() runs the plan.
2
5
 
3
- import { spawn, execFileSync } from 'node:child_process';
6
+ import { spawn } from 'node:child_process';
4
7
  import fs from 'node:fs';
8
+ import { escapeCmdArgument, escapeCmdCommand, onPath } from './proc.js';
5
9
 
6
- export const TERMINALS = [
10
+ // os: which platforms list the entry (all if omitted).
11
+ const ALL_TERMINALS = [
7
12
  { id: 'auto', label: 'Auto-detect' },
8
- { id: 'terminal', label: 'Terminal.app', mac: true },
9
- { id: 'iterm', label: 'iTerm', mac: true, app: '/Applications/iTerm.app' },
10
- { id: 'ghostty', label: 'Ghostty', app: '/Applications/Ghostty.app', bin: 'ghostty' },
11
- { id: 'warp', label: 'Warp', mac: true, app: '/Applications/Warp.app' },
12
- { id: 'kitty', label: 'kitty', app: '/Applications/kitty.app', bin: 'kitty' },
13
- { id: 'wezterm', label: 'WezTerm', app: '/Applications/WezTerm.app', bin: 'wezterm' },
13
+ { id: 'terminal', label: 'Terminal.app', os: ['darwin'] },
14
+ { id: 'iterm', label: 'iTerm', os: ['darwin'] },
15
+ { id: 'warp', label: 'Warp', os: ['darwin'] },
16
+ { id: 'cmux', label: 'cmux', os: ['darwin'] },
17
+ { id: 'wt', label: 'Windows Terminal', os: ['win32'] },
18
+ { id: 'powershell', label: 'PowerShell', os: ['win32'] },
19
+ { id: 'cmd', label: 'Command Prompt', os: ['win32'] },
20
+ { id: 'ghostty', label: 'Ghostty', os: ['darwin', 'linux'] },
21
+ { id: 'kitty', label: 'kitty', os: ['darwin', 'linux'] },
22
+ { id: 'wezterm', label: 'WezTerm' },
23
+ { id: 'gnome-terminal', label: 'GNOME Terminal', os: ['linux'] },
24
+ { id: 'konsole', label: 'Konsole', os: ['linux'] },
25
+ { id: 'xfce4-terminal', label: 'Xfce Terminal', os: ['linux'] },
26
+ { id: 'alacritty', label: 'Alacritty', os: ['linux'] },
27
+ { id: 'xterm', label: 'xterm', os: ['linux'] },
14
28
  { id: 'custom', label: 'Custom command' },
15
29
  ];
16
30
 
31
+ export const terminalsFor = (platform = process.platform) => ALL_TERMINALS.filter((t) => !t.os || t.os.includes(platform === 'darwin' || platform === 'win32' ? platform : 'linux')).map(({ id, label }) => ({ id, label }));
32
+ export const TERMINALS = terminalsFor();
33
+
17
34
  export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
35
+ const psq = (s) => `'${String(s).replace(/'/g, "''")}'`; // PowerShell single-quoted string
36
+ const asStr = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; // AppleScript string
18
37
 
19
- const has = (bin) => {
20
- try {
21
- execFileSync('which', [bin], { stdio: 'ignore' });
22
- return true;
23
- } catch {
24
- return false;
25
- }
26
- };
38
+ const CMUX_APP = '/Applications/cmux.app';
39
+ const CMUX_CLI = `${CMUX_APP}/Contents/Resources/bin/cmux`;
40
+
41
+ const LINUX_ORDER = ['ghostty', 'kitty', 'wezterm', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'alacritty', 'x-terminal-emulator', 'xterm'];
27
42
 
28
- function detect() {
29
- if (process.platform === 'darwin') {
30
- if (process.env.TERM_PROGRAM === 'iTerm.app' && fs.existsSync('/Applications/iTerm.app')) return 'iterm';
31
- if (process.env.TERM_PROGRAM === 'ghostty' && fs.existsSync('/Applications/Ghostty.app')) return 'ghostty';
43
+ export function detectTerminal({ platform = process.platform, env = process.env, has = (b) => onPath(b, env), exists = fs.existsSync } = {}) {
44
+ if (platform === 'darwin') {
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';
48
+ if (env.TERM_PROGRAM === 'ghostty' && exists('/Applications/Ghostty.app')) return 'ghostty';
32
49
  return 'terminal';
33
50
  }
34
- for (const t of ['ghostty', 'kitty', 'wezterm']) if (has(t)) return t;
35
- return 'x-terminal-emulator';
51
+ if (platform === 'win32') return env.WT_SESSION || has('wt') ? 'wt' : 'powershell';
52
+ return LINUX_ORDER.find((t) => has(t)) || 'xterm';
36
53
  }
37
54
 
38
- const osa = (script) => spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
39
- const asStr = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
40
-
41
- // argv: the agent's resume command, e.g. ['claude', '--resume', 'abc'].
42
- export function openInTerminal({ cwd, argv, terminal = 'auto', custom = '' }) {
55
+ // Returns a list of steps: { command, args, options? } to spawn, { osa } to run as AppleScript,
56
+ // or { delay } in ms. argv is the agent's resume command, e.g. ['claude', '--resume', 'abc'].
57
+ export function terminalPlan({ cwd, argv, terminal = 'auto', custom = '', platform = process.platform, env = process.env, has, exists } = {}) {
58
+ const t = terminal === 'auto' ? detectTerminal({ platform, env, has, exists }) : terminal;
59
+ const mac = platform === 'darwin';
60
+ const win = platform === 'win32';
43
61
  const cmd = argv.map(shq).join(' ');
44
62
  const line = `cd ${shq(cwd)} && ${cmd}`;
45
- const t = terminal === 'auto' ? detect() : terminal;
46
- const mac = process.platform === 'darwin';
63
+ // Linux terminals: run the command, then leave an interactive shell open in the project.
64
+ const keepOpen = ['sh', '-c', `${cmd}; exec "\${SHELL:-sh}"`];
65
+ // Windows: `& 'codex' 'resume' 'id'` works for .exe, .cmd and .ps1 alike.
66
+ const ps = `& ${argv.map(psq).join(' ')}`;
67
+ const cmdLine = [escapeCmdCommand(argv[0]), ...argv.slice(1).map((a) => escapeCmdArgument(a, true))].join(' ');
68
+ const detached = { cwd, detached: true, stdio: 'ignore' };
47
69
 
48
70
  switch (t) {
49
71
  case 'terminal':
50
- return osa(`tell application "Terminal"\nactivate\ndo script ${asStr(line)}\nend tell`);
72
+ return [{ osa: `tell application "Terminal"\nactivate\ndo script ${asStr(line)}\nend tell` }];
51
73
  case 'iterm':
52
- return osa(`tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${asStr(line)}\nend tell`);
74
+ return [{ osa: `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${asStr(line)}\nend tell` }];
53
75
  case 'warp':
54
76
  // Warp has no scripting API; open a tab in the folder and type via System Events.
55
- spawn('open', ['-a', 'Warp', cwd], { detached: true, stdio: 'ignore' }).unref();
56
- return setTimeout(() => osa(`tell application "System Events" to keystroke ${asStr(cmd)} & return`), 1200);
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
+ }
87
+ case 'wt':
88
+ // `;` separates wt subcommands, so it can't appear unescaped in the command line.
89
+ return [{ command: 'wt.exe', args: ['-d', cwd, 'powershell.exe', '-NoExit', '-Command', ps.replace(/;/g, '\\;')], options: detached }];
90
+ case 'powershell':
91
+ // detached gives the child its own console window on Windows.
92
+ return [{ command: 'powershell.exe', args: ['-NoExit', '-Command', ps], options: detached }];
93
+ case 'cmd':
94
+ return [{ command: env.ComSpec || env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/k', `"${cmdLine}"`], options: { ...detached, windowsVerbatimArguments: true } }];
57
95
  case 'ghostty':
58
- if (mac) return spawn('open', ['-na', 'Ghostty', '--args', `--working-directory=${cwd}`, '-e', ...argv], { detached: true, stdio: 'ignore' }).unref();
59
- return spawn('ghostty', [`--working-directory=${cwd}`, '-e', ...argv], { detached: true, stdio: 'ignore' }).unref();
96
+ if (mac) return [{ command: 'open', args: ['-na', 'Ghostty', '--args', `--working-directory=${cwd}`, '-e', ...argv] }];
97
+ return [{ command: 'ghostty', args: [`--working-directory=${cwd}`, '-e', ...keepOpen] }];
60
98
  case 'kitty':
61
- if (mac) return spawn('open', ['-na', 'kitty', '--args', '--directory', cwd, ...argv], { detached: true, stdio: 'ignore' }).unref();
62
- return spawn('kitty', ['--directory', cwd, ...argv], { detached: true, stdio: 'ignore' }).unref();
99
+ if (mac) return [{ command: 'open', args: ['-na', 'kitty', '--args', '--directory', cwd, ...argv] }];
100
+ return [{ command: 'kitty', args: ['--directory', cwd, ...keepOpen] }];
63
101
  case 'wezterm':
64
- return spawn(mac ? '/Applications/WezTerm.app/Contents/MacOS/wezterm' : 'wezterm', ['start', '--cwd', cwd, '--', ...argv], { detached: true, stdio: 'ignore' }).unref();
102
+ return [{ command: mac ? '/Applications/WezTerm.app/Contents/MacOS/wezterm' : 'wezterm', args: ['start', '--cwd', cwd, '--', ...(win ? ['powershell.exe', '-NoExit', '-Command', ps] : mac ? argv : keepOpen)], ...(win ? { options: detached } : {}) }];
103
+ case 'gnome-terminal':
104
+ return [{ command: 'gnome-terminal', args: [`--working-directory=${cwd}`, '--', ...keepOpen] }];
105
+ case 'konsole':
106
+ return [{ command: 'konsole', args: ['--workdir', cwd, '-e', ...keepOpen] }];
107
+ case 'xfce4-terminal':
108
+ return [{ command: 'xfce4-terminal', args: [`--working-directory=${cwd}`, '-x', ...keepOpen] }];
109
+ case 'alacritty':
110
+ return [{ command: 'alacritty', args: ['--working-directory', cwd, '-e', ...keepOpen] }];
65
111
  case 'custom': {
66
112
  if (!custom) throw new Error('Set a custom terminal command in settings, e.g. alacritty --working-directory {cwd} -e {cmd}');
113
+ if (win) {
114
+ const full = custom.replaceAll('{cwd}', `"${cwd}"`).replaceAll('{cmd}', cmdLine).replaceAll('{line}', `cd /d "${cwd}" ^&^& ${cmdLine}`);
115
+ return [{ command: env.ComSpec || env.COMSPEC || 'cmd.exe', args: ['/d', '/s', '/c', `"${full}"`], options: { ...detached, windowsVerbatimArguments: true } }];
116
+ }
67
117
  const full = custom.replaceAll('{cwd}', shq(cwd)).replaceAll('{cmd}', cmd).replaceAll('{line}', shq(line));
68
- return spawn('sh', ['-c', full], { detached: true, stdio: 'ignore' }).unref();
118
+ return [{ command: 'sh', args: ['-c', full], options: detached }];
119
+ }
120
+ default: // x-terminal-emulator (Debian/Ubuntu alternatives), xterm
121
+ return [{ command: t, args: ['-e', ...keepOpen], options: detached }];
122
+ }
123
+ }
124
+
125
+ export function openInTerminal(opts) {
126
+ const steps = terminalPlan(opts);
127
+ let wait = 0;
128
+ for (const step of steps) {
129
+ if (step.delay) {
130
+ wait += step.delay;
131
+ continue;
69
132
  }
70
- default:
71
- return spawn('x-terminal-emulator', ['-e', 'sh', '-c', `${line}; exec $SHELL`], { detached: true, stdio: 'ignore' }).unref();
133
+ const run = () => {
134
+ const [command, args] = step.osa ? ['osascript', ['-e', step.osa]] : [step.command, step.args];
135
+ const child = spawn(command, args, { cwd: opts.cwd, detached: true, stdio: 'ignore', ...step.options });
136
+ child.on('error', () => {}); // a missing terminal shouldn't crash the server
137
+ child.unref();
138
+ };
139
+ if (wait) setTimeout(run, wait);
140
+ else run();
72
141
  }
73
142
  }
package/web/css/app.css CHANGED
@@ -166,7 +166,26 @@ select {
166
166
  @keyframes orb-spin { to { rotate: 360deg; } }
167
167
 
168
168
  /* ---------- layout: one column — top bar, launcher, feed ---------- */
169
- .stage { position: relative; height: 100vh; overflow-y: auto; overflow-x: hidden; scroll-padding-top: 64px; }
169
+ .stage {
170
+ position: relative; height: 100vh; overflow-y: auto; overflow-x: hidden; scroll-padding-top: 64px;
171
+ /* reserve the gutter on both sides so the scrollbar appearing never shifts or off-centres the layout */
172
+ scrollbar-gutter: stable both-edges;
173
+ }
174
+
175
+ /* scrollbars: a hairline pill on a clear track — faint at rest, firmer and wider when grabbed, gone when idle */
176
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
177
+ ::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }
178
+ ::-webkit-scrollbar-thumb {
179
+ border-radius: 99px; border: 3px solid transparent; min-height: 40px;
180
+ background: color-mix(in srgb, var(--ink) 16%, transparent) padding-box;
181
+ }
182
+ ::-webkit-scrollbar-thumb:hover { border-width: 2px; background-color: color-mix(in srgb, var(--ink) 32%, transparent); }
183
+ ::-webkit-scrollbar-thumb:active { border-width: 2px; background-color: color-mix(in srgb, var(--ink) 45%, transparent); }
184
+ :not(:hover)::-webkit-scrollbar-thumb { background-color: transparent; }
185
+ ::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
186
+ @supports not selector(::-webkit-scrollbar) {
187
+ * { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--ink) 20%, transparent) transparent; }
188
+ }
170
189
  .feed-wrap { max-width: 800px; margin: 0 auto; padding: 20px 24px 120px; }
171
190
 
172
191
  .topbar {
@@ -187,7 +206,6 @@ select {
187
206
  .to-top:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
188
207
  .brand { display: flex; align-items: center; gap: 9px; flex: none; }
189
208
  .brand .word { font: 600 15px/1 var(--display); letter-spacing: -0.02em; }
190
- .agent-status { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); white-space: nowrap; }
191
209
  .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex: none; }
192
210
  .dot.ok { background: var(--good); }
193
211
  .dot.bad { background: var(--bad); }
@@ -227,10 +245,24 @@ select {
227
245
  }
228
246
  .launcher textarea::placeholder, .composer textarea::placeholder { color: var(--faint); }
229
247
  .l-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 6px; }
230
- .seg { display: inline-flex; padding: 2px; border-radius: 8px; background: var(--bg-2); }
231
- .seg button { height: 26px; padding: 0 10px; border-radius: 6px; font-size: 12.5px; color: var(--muted); transition: all 0.15s; }
232
- .seg button.on { background: var(--surface); color: var(--ink); box-shadow: 0 0 0 1px var(--line-2); font-weight: 500; }
233
- .seg button[data-mode='auto'].on { color: var(--warn); }
248
+ /* permission mode: icon-only on the default, labelled otherwise */
249
+ .mode-btn {
250
+ display: inline-flex; align-items: center; gap: 6px; height: 32px; min-width: 32px; padding: 0 9px; border-radius: 9px;
251
+ font-size: 12.5px; font-weight: 500; color: var(--muted); transition: background 0.15s, color 0.15s;
252
+ }
253
+ .mode-btn:hover, .mode-btn.open { background: var(--bg-2); color: var(--ink); }
254
+ .mode-btn:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
255
+ .mode-btn.m-plan { color: var(--accent-ink); }
256
+ .mode-btn.m-auto, .menu.mode .m-auto > .i:first-child { color: var(--warn); }
257
+ .menu.mode { width: 290px; }
258
+ .menu.mode button { align-items: flex-start; text-align: left; }
259
+ .menu.mode button > .i:first-child { margin-top: 2px; }
260
+ .mm-text { display: grid; gap: 1px; flex: 1; }
261
+ .mm-text b { font-weight: 500; color: var(--ink); }
262
+ .mm-text small { font-size: 12px; color: var(--muted); line-height: 1.35; }
263
+ .mm-check { margin-top: 2px; color: var(--accent); visibility: hidden; }
264
+ .menu.mode .on .mm-check { visibility: visible; }
265
+ .menu.mode button:focus-visible { outline: 0; background: var(--bg-2); }
234
266
  .model-btn {
235
267
  display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px 0 9px; border-radius: 8px;
236
268
  font-size: 12.5px; color: var(--muted); transition: background 0.15s, color 0.15s;
@@ -243,6 +275,71 @@ select {
243
275
  .model-btn.set > .i:first-child { color: var(--accent); }
244
276
  .model-btn:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
245
277
 
278
+ /* harness · model button in the launcher */
279
+ .runner-btn {
280
+ display: inline-flex; align-items: center; height: 30px; padding: 0 8px 0 4px; border-radius: 9px; gap: 0;
281
+ border: 1px solid var(--line); background: var(--surface); font-size: 12.5px; color: var(--ink-2);
282
+ transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
283
+ }
284
+ .runner-btn:hover, .runner-btn.open { background: var(--bg-2); border-color: var(--line-2); }
285
+ .runner-btn:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
286
+ .rb-logo { display: grid; place-items: center; width: 22px; height: 22px; margin-right: 6px; border-radius: 6px; background: var(--bg-2); color: var(--ink); }
287
+ .runner-btn:hover .rb-logo, .runner-btn.open .rb-logo { background: var(--surface); }
288
+ .rb-logo > * { display: block; width: 13px; height: 13px; }
289
+ .rb-logo .mono, .pk-logo .mono { background: currentColor; -webkit-mask: var(--logo) center / contain no-repeat; mask: var(--logo) center / contain no-repeat; }
290
+ .rb-h { font-weight: 500; color: var(--ink); }
291
+ .rb-m { position: relative; margin-left: 13px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
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); }
294
+ .runner-btn .chev { width: 12px; height: 12px; margin-left: 6px; color: var(--faint); transform: rotate(90deg); transition: transform 0.2s var(--ease); }
295
+ .runner-btn.open .chev { transform: rotate(-90deg); }
296
+
297
+ /* harness + model picker */
298
+ .picker {
299
+ position: fixed; z-index: 40; display: grid; grid-template-columns: 200px 1fr; height: min(420px, 70vh); overflow: hidden;
300
+ border-radius: 14px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-2); animation: pop 0.2s var(--ease);
301
+ }
302
+ .pk-side { display: flex; flex-direction: column; gap: 2px; padding: 6px; overflow-y: auto; background: var(--surface-2); border-right: 1px solid var(--line); }
303
+ .pk-head, .pk-ghead { padding: 8px 10px 5px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
304
+ .pk-h, .pk-m { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 9px; border-radius: 9px; text-align: left; outline: 0; color: var(--ink-2); transition: background 0.12s; }
305
+ .pk-h:hover:not(:disabled), .pk-m:hover, .pk-m:focus-visible { background: var(--bg-2); }
306
+ .pk-h:focus-visible { box-shadow: inset 0 0 0 1px var(--line-2); }
307
+ .pk-h.on { background: var(--surface); box-shadow: 0 0 0 1px var(--line-2), var(--shadow-1); }
308
+ .pk-h.off { opacity: 0.45; cursor: not-allowed; }
309
+ .pk-logo { display: grid; place-items: center; flex: none; width: 28px; height: 28px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line); color: var(--ink); }
310
+ .pk-h.on .pk-logo { background: var(--bg-2); border-color: transparent; }
311
+ .pk-logo > * { display: block; width: 15px; height: 15px; }
312
+ .pk-text { flex: 1; display: flex; flex-direction: column; gap: 1px; min-width: 0; }
313
+ .pk-text b { font-weight: 500; font-size: 13.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
314
+ .pk-text small { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
315
+ .pk-text small:empty { display: none; }
316
+ .pk-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
317
+ .pk-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
318
+ .pk-search { flex: none; display: flex; align-items: center; gap: 8px; margin: 8px 8px 2px; padding: 0 10px; height: 34px; border-radius: 9px; border: 1px solid var(--line); background: var(--bg-2); color: var(--faint); transition: border-color 0.15s, background 0.15s; }
319
+ .pk-search:focus-within { border-color: var(--accent); background: var(--surface); }
320
+ .pk-search .i { width: 14px; height: 14px; flex: none; }
321
+ .pk-search input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; font: inherit; font-size: 13px; color: var(--ink); }
322
+ .pk-list { flex: 1; overflow-y: auto; padding: 6px; }
323
+ .pk-group + .pk-group { margin-top: 4px; }
324
+ .pk-check { width: 15px; height: 15px; flex: none; color: var(--accent); visibility: hidden; }
325
+ .pk-m.on .pk-check { visibility: visible; }
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); }
335
+ .pk-empty { padding: 30px 10px; text-align: center; font-size: 13px; color: var(--muted); }
336
+ @media (max-width: 560px) {
337
+ .picker { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
338
+ .pk-side { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }
339
+ .pk-side .pk-head, .pk-h .pk-text small { display: none; }
340
+ .pk-h { width: auto; flex: none; }
341
+ }
342
+
246
343
  /* ---------- feed ---------- */
247
344
  .feed-head { display: flex; align-items: center; gap: 14px; min-height: 22px; margin: 26px 2px 10px; font-size: 13px; }
248
345
  .fh-status { display: flex; gap: 14px; }
@@ -259,6 +356,7 @@ select {
259
356
  }
260
357
  .f-group h4 .n { color: var(--faint); }
261
358
  .collapse-all {
359
+ display: inline-flex; align-items: center;
262
360
  position: fixed; right: 22px; bottom: 22px; z-index: 30; height: 30px; padding: 0 12px; border-radius: 15px;
263
361
  background: var(--surface); border: 1px solid var(--line-2); box-shadow: var(--shadow-2);
264
362
  font-size: 12.5px; font-weight: 500; color: var(--ink-2);
@@ -266,6 +364,7 @@ select {
266
364
  }
267
365
  .collapse-all.on { opacity: 1; visibility: visible; translate: 0 0; }
268
366
  .collapse-all:hover { color: var(--ink); }
367
+ .collapse-all kbd { margin-left: 8px; }
269
368
  .toggle-done { cursor: pointer; user-select: none; }
270
369
  .toggle-done:hover { color: var(--ink-2); }
271
370
  .toggle-done .chev { width: 12px; height: 12px; transition: transform 0.2s; }
@@ -369,9 +468,22 @@ select {
369
468
  .ask { position: relative; margin: 0 0 20px; padding: 10px 14px; border-radius: var(--radius); background: var(--bg-2); }
370
469
  .ask-text { white-space: pre-wrap; color: var(--ink); font-size: 14px; }
371
470
  .ask-text.clamped { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
372
- .ask time { display: block; margin-top: 3px; font-size: 11.5px; color: var(--faint); }
471
+ .ask-foot { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
472
+ .ask time { font-size: 11.5px; color: var(--faint); }
473
+ .model-logo { position: relative; display: inline-flex; width: 13px; height: 13px; color: var(--ink-2); cursor: default; outline: none; }
474
+ .model-logo > * { display: block; width: 100%; height: 100%; }
475
+ .model-logo .mono { background: currentColor; -webkit-mask: var(--logo) center / contain no-repeat; mask: var(--logo) center / contain no-repeat; }
476
+ .model-logo::after {
477
+ content: attr(data-tip); position: absolute; left: -6px; bottom: calc(100% + 6px); transform: translateY(2px);
478
+ padding: 3px 7px; border-radius: 6px; background: var(--ink); color: var(--bg); font-size: 11px; white-space: nowrap;
479
+ opacity: 0; pointer-events: none; transition: opacity 0.12s, transform 0.12s; z-index: 5;
480
+ }
481
+ .model-logo:hover::after, .model-logo:focus-visible::after { opacity: 1; transform: none; }
482
+ /* In the card header the logo sits at the right edge, so the tooltip opens down and leftwards. */
483
+ .it-side .model-logo::after { left: auto; right: -6px; bottom: auto; top: calc(100% + 6px); transform: translateY(-2px); }
484
+ .it-side .model-logo:hover::after, .it-side .model-logo:focus-visible::after { transform: none; }
373
485
  .ask-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
374
- .ask-atts img { height: 72px; border-radius: 10px; border: 1px solid var(--line); cursor: zoom-in; object-fit: cover; }
486
+ .ask-atts img { height: 72px; border-radius: 10px; border: 1px solid var(--line); cursor: zoom-in; object-fit: cover; max-width: min(100%, 320px); }
375
487
  .att-pill { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line); font-size: 12.5px; }
376
488
 
377
489
  /* ---------- working panel (not `.working` — the feed group and head badge use that) ---------- */
@@ -752,11 +864,19 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
752
864
  .composer textarea { min-height: 26px; font-size: 14px; padding: 6px 2px; }
753
865
  .composer textarea:disabled { cursor: not-allowed; }
754
866
  .c-bar { display: flex; align-items: center; gap: 8px; }
867
+ .c-queued { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
868
+ .c-queued:not(:empty) { padding: 4px 0 6px; }
869
+ .cq { position: relative; display: flex; align-items: baseline; gap: 8px; padding: 6px 30px 6px 10px; border-radius: 8px; border: 1px dashed var(--line-2); font-size: 12.5px; animation: pop 0.35s var(--ease); }
870
+ .cq-tag { flex: none; font-size: 11px; font-weight: 600; color: var(--accent-ink); }
871
+ .cq .att-x:hover { color: var(--bad); }
872
+ .cq-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
755
873
  .c-hint { flex: 1; font-size: 11.5px; color: var(--faint); }
756
874
  .send { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--ink); color: var(--bg); transition: opacity 0.15s; }
757
875
  .send:hover { opacity: 0.86; }
758
876
  .send:disabled { opacity: 0.5; }
759
877
  .c-done { color: var(--muted); }
878
+ .c-queue { color: var(--faint); font-size: 12px; }
879
+ .c-queue.on { color: var(--ink); background: var(--bg-2); }
760
880
  .c-done:hover { color: var(--good); }
761
881
 
762
882
  /* attachments tray */
@@ -822,6 +942,13 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
822
942
  .found-item span { font: 11px var(--mono); color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
823
943
  .settings-modal label { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--line); }
824
944
  .settings-modal label > span { font-weight: 500; }
945
+ .settings-modal .setting { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); }
946
+ .settings-modal .setting > span:first-child { font-weight: 500; margin-right: auto; }
947
+ .switch { position: relative; flex: none; width: 34px; height: 20px; border-radius: 10px; background: var(--line-2); transition: background 0.15s; cursor: pointer; }
948
+ .switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); transition: transform 0.15s; }
949
+ .switch[aria-checked='true'] { background: var(--accent); }
950
+ .switch[aria-checked='true']::after { transform: translateX(14px); }
951
+ .switch:disabled { opacity: 0.4; cursor: not-allowed; }
825
952
  .settings-modal input { flex: 1; max-width: 340px; height: 30px; border-radius: 9px; border: 1px solid var(--line); background: var(--surface-2); padding: 0 10px; font: 12px var(--mono); outline: 0; }
826
953
  .agents { margin-top: 18px; }
827
954
  .agent-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; font-size: 13px; }
@@ -829,18 +956,46 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
829
956
  .krow { display: flex; align-items: center; gap: 14px; padding: 6px 0; }
830
957
  .krow kbd { min-width: 56px; text-align: center; font-size: 11.5px; }
831
958
 
959
+ /* ---------- command palette (⌘K) ---------- */
960
+ .search-btn {
961
+ display: inline-flex; align-items: center; gap: 7px; height: 30px; padding: 0 6px 0 10px; border-radius: 9px;
962
+ border: 1px solid var(--line); background: var(--surface-2); color: var(--muted); font-size: 13px; transition: border-color 0.15s, color 0.15s;
963
+ }
964
+ .search-btn:hover { border-color: var(--line-2); color: var(--ink); }
965
+ .search-btn .i { width: 14px; height: 14px; }
966
+ .search-btn kbd { margin-left: 8px; }
967
+ .overlay:has(.palette) { place-items: start center; padding-top: min(14vh, 120px); background: rgba(0, 0, 0, 0.18); }
968
+ .palette {
969
+ display: flex; flex-direction: column; width: min(640px, 100%); max-height: min(560px, 72vh); overflow: hidden; border-radius: 14px;
970
+ background: var(--surface); border: 1px solid var(--line-2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 30px 70px -18px rgba(0, 0, 0, 0.4);
971
+ transform: scale(0.98) translateY(-4px); transition: transform 0.18s var(--ease);
972
+ }
973
+ .overlay.on .palette { transform: none; }
974
+ .pal-input { flex: none; display: flex; align-items: center; gap: 10px; padding: 0 16px; height: 54px; border-bottom: 1px solid var(--line); }
975
+ .pal-input .i { width: 18px; height: 18px; color: var(--faint); }
976
+ .pal-input input { flex: 1; border: 0; outline: 0; background: transparent; font-size: 16px; color: var(--ink); }
977
+ .pal-input input::placeholder { color: var(--faint); }
978
+ .pal-list { flex: 1; min-height: 0; overflow-y: auto; padding: 6px; scroll-padding: 28px 0 6px; overscroll-behavior: contain; }
979
+ .pal-group { padding: 10px 10px 5px; font-size: 11.5px; font-weight: 500; color: var(--faint); letter-spacing: 0.01em; }
980
+ .pal-group:first-child { padding-top: 6px; }
981
+ .pal-row { display: flex; align-items: center; gap: 10px; height: 38px; padding: 0 10px; border-radius: 8px; cursor: pointer; color: var(--ink-2); font-size: 13.5px; }
982
+ .pal-row.on { background: var(--bg-2); color: var(--ink); }
983
+ .pal-lead { display: grid; place-items: center; width: 18px; flex: none; color: var(--muted); }
984
+ .pal-row.on .pal-lead { color: var(--ink); }
985
+ .pal-lead .i { width: 15px; height: 15px; }
986
+ .pal-label { flex: none; max-width: 62%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
987
+ .pal-label mark { background: none; color: var(--accent-ink); font-weight: 600; }
988
+ .pal-sub { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--faint); }
989
+ .pal-kbd { margin-left: auto; display: flex; gap: 3px; flex: none; }
990
+ .pal-empty { padding: 34px 12px; text-align: center; color: var(--muted); font-size: 13.5px; }
991
+ .pal-foot { flex: none; display: flex; gap: 16px; padding: 8px 14px; border-top: 1px solid var(--line); background: var(--surface-2); font-size: 11.5px; color: var(--faint); }
992
+ .pal-foot kbd { margin-right: 3px; }
993
+
832
994
  .menu { position: fixed; z-index: 40; min-width: 220px; padding: 5px; border-radius: 13px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-2); animation: pop 0.2s var(--ease); }
833
995
  .menu button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 10px; border-radius: 8px; font-size: 13.5px; color: var(--ink-2); }
834
996
  .menu button:hover { background: var(--bg-2); color: var(--ink); }
835
997
  .menu .danger:hover { color: var(--bad); background: var(--del-bg); }
836
- .menu.models { width: 260px; }
837
- .menu.models button { padding: 8px 10px; outline: 0; }
838
- .menu.models button:focus-visible { background: var(--bg-2); color: var(--ink); }
839
- .mm-text { flex: 1; display: flex; flex-direction: column; gap: 1px; text-align: left; min-width: 0; }
840
- .mm-text b { font-weight: 500; font-size: 13.5px; color: var(--ink); }
841
- .mm-text small { font-size: 12px; color: var(--muted); }
842
- .mm-check { width: 15px; height: 15px; color: var(--accent); visibility: hidden; }
843
- .menu.models .on .mm-check { visibility: visible; }
998
+ .menu-kbd { margin-left: auto; }
844
999
 
845
1000
  .toasts { position: fixed; bottom: 22px; left: 50%; translate: -50% 0; z-index: 60; display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; }
846
1001
  .toast {
@@ -866,7 +1021,8 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
866
1021
  /* ---------- responsive ---------- */
867
1022
  @media (max-width: 720px) {
868
1023
  .topbar { padding: 0 12px; gap: 10px; }
869
- .agent-status { display: none; }
1024
+ .search-btn span, .search-btn kbd { display: none; }
1025
+ .search-btn { padding: 0 8px; border-color: transparent; background: none; }
870
1026
  .feed-wrap { padding: 14px 16px 80px; }
871
1027
  .it-side .q-proj, .it-actions span, .c-done span { display: none; }
872
1028
  .turns { padding: 6px 14px 8px; }
package/web/index.html CHANGED
@@ -31,9 +31,11 @@
31
31
  <symbol id="i-stop" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2" fill="currentColor"/></symbol>
32
32
  <symbol id="i-send" viewBox="0 0 24 24"><path d="M12 19V5m-6 6l6-6 6 6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
33
33
  <symbol id="i-enter" viewBox="0 0 24 24"><path d="M19 5v7a3 3 0 01-3 3H5m4-4l-4 4 4 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
34
+ <symbol id="i-queue" viewBox="0 0 24 24"><path d="M4 6h12M4 12h12M4 18h7M17 15v6m-3-3h6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
34
35
  <symbol id="i-clip" viewBox="0 0 24 24"><path d="M20 11.5l-8 8a5 5 0 01-7-7l8.5-8.5a3.3 3.3 0 014.7 4.7L9.7 17.2a1.7 1.7 0 01-2.4-2.4l7.6-7.6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
35
36
  <symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="15" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M7 9.5l3 2.5-3 2.5M12.5 15H17" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
36
37
  <symbol id="i-done" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M8 12.3l2.8 2.8L16.2 9.5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></symbol>
38
+ <symbol id="i-archive" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="4" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M5 8.5V18a1.5 1.5 0 001.5 1.5h11A1.5 1.5 0 0019 18V8.5M10 12.5h4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
37
39
  <symbol id="i-trash" viewBox="0 0 24 24"><path d="M4.5 7h15M10 7V4.5h4V7M6.5 7l1 12.5h9l1-12.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
38
40
  <symbol id="i-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M12 2.8v2.4M12 18.8v2.4M2.8 12h2.4M18.8 12h2.4M5.5 5.5l1.7 1.7M16.8 16.8l1.7 1.7M5.5 18.5l1.7-1.7M16.8 7.2l1.7-1.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
39
41
  <symbol id="i-read" viewBox="0 0 24 24"><path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="12" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
@@ -56,7 +58,9 @@
56
58
  <span class="word">wisp</span>
57
59
  </a>
58
60
  <div class="projects" id="projects"></div>
59
- <span class="agent-status" id="agentStatus"></span>
61
+ <button class="search-btn" id="searchBtn" title="Search">
62
+ <svg class="i"><use href="#i-search"/></svg><span>Search</span><kbd class="mod-k"></kbd>
63
+ </button>
60
64
  <button class="icon-btn" id="settingsBtn" title="Settings">
61
65
  <svg class="i"><use href="#i-settings"/></svg>
62
66
  </button>
@@ -68,6 +72,7 @@
68
72
  <section class="f-group needs" id="secNeeds"><h4>Needs you<span class="n"></span></h4><div class="f-list"></div></section>
69
73
  <section class="f-group working" id="secWorking"><h4>Working<span class="n"></span></h4><div class="f-list"></div></section>
70
74
  <section class="f-group done" id="secDone"><h4 class="toggle-done">Done<span class="n"></span><svg class="i chev"><use href="#i-chevron"/></svg></h4><div class="f-list"></div></section>
75
+ <section class="f-group done archived" id="secArchived"><h4 class="toggle-done">Archived<span class="n"></span><svg class="i chev"><use href="#i-chevron"/></svg></h4><div class="f-list"></div></section>
71
76
  </div>
72
77
  <div class="feed-empty" id="feedEmpty" hidden></div>
73
78
  </div>