claude-code-kanban 4.30.0 → 4.31.1
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/README.md +2 -2
- package/cli.js +32 -14
- package/install.js +2 -1
- package/lib/linked-docs.js +4 -2
- package/lib/plugin-status.js +38 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -1
- package/public/app.js +255 -64
- package/public/index.html +5 -2
- package/public/link-url.js +15 -0
- package/public/style.css +79 -9
- package/public/sw.js +1 -1
- package/public/themes.css +49 -0
- package/server.js +20 -14
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
|
|
|
59
59
|
- **Session log.** Every prompt, reply and tool call in order (<kbd>Shift</kbd>+<kbd>L</kbd>). Follow the newest message with <kbd>Shift</kbd>+<kbd>M</kbd>, and pin the messages that matter. [Session log and details](https://nikiforovall.blog/claude-code-kanban/guides/session-details/)
|
|
60
60
|
- **Subagents.** The agents log lists each subagent with its model, status and run time. Open one to read its prompt and response. Team sessions get colored owner badges and an owner filter. [Subagents](https://nikiforovall.blog/claude-code-kanban/guides/subagents/)
|
|
61
61
|
- **Session info and tool stats.** Model, branch, context window use, cost, and which tools ran. The sidebar footer shows your 5-hour and 7-day rate limit use.
|
|
62
|
-
- **Zen mode.** <kbd>Shift</kbd>+<kbd>Z</kbd> shows only the current session in the sidebar, with its context use, scratchpad folder and linked documents.
|
|
62
|
+
- **Zen mode.** <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> shows only the current session in the sidebar, with its context use, scratchpad folder and linked documents.
|
|
63
63
|
|
|
64
64
|
<picture>
|
|
65
65
|
<source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-02-subagent-preview-dark.webp">
|
|
@@ -88,7 +88,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
|
|
|
88
88
|
|
|
89
89
|
- **Session groups.** Drag sessions and projects from different folders into one named group in the sidebar. [Session groups](https://nikiforovall.blog/claude-code-kanban/guides/session-groups/)
|
|
90
90
|
- **Filters, search and pins.** Filter by project and activity, search across sessions and tasks, pin a session or make it sticky. <kbd>Shift</kbd>+<kbd>P</kbd> opens the session picker.
|
|
91
|
-
- **Scratchpad and linked documents.** Keep a note per session, project or group (<kbd>N</kbd>), and link files to a session to
|
|
91
|
+
- **Scratchpad and linked documents.** Keep a note per session, project or group (<kbd>N</kbd>), and link files or web URLs to a session to open them in one click.
|
|
92
92
|
- **Themes.** 17 color themes, each in light and dark. <kbd>T</kbd> switches the mode.
|
|
93
93
|
- **Keyboard first.** Arrow keys move through tasks, and <kbd>Tab</kbd> moves between the sidebar and the board. Press <kbd>?</kbd> for the full list. [Keyboard shortcuts](https://nikiforovall.blog/claude-code-kanban/reference/keyboard-shortcuts/)
|
|
94
94
|
|
package/cli.js
CHANGED
|
@@ -2,25 +2,26 @@ const fs = require('node:fs');
|
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const { getClaudeDir, displayPath } = require('./lib/claude-dir');
|
|
4
4
|
const { isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
5
|
+
const { linkUrl } = require('./public/link-url');
|
|
5
6
|
// Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
|
|
6
7
|
const COMMANDS = {
|
|
7
8
|
'preview-doc': {
|
|
8
9
|
summary: 'Open a markdown or HTML file in the preview modal on connected browser tabs',
|
|
9
|
-
usage: 'claude-code-kanban preview-doc <file.md|file.html> [--session <id>]',
|
|
10
|
+
usage: 'claude-code-kanban preview-doc <file.md|file.html|url> [--session <id>]',
|
|
10
11
|
flags: {
|
|
11
|
-
'--session <id>': 'Switch focused session in the browser (does not link the file)',
|
|
12
|
+
'--session <id>': 'Switch focused session in the browser (does not link the file). Required for a URL.',
|
|
12
13
|
},
|
|
13
|
-
notes: 'HTML renders in a sandboxed iframe; local stylesheets, scripts and images are inlined. Relative paths resolve against the current dir.',
|
|
14
|
+
notes: 'HTML renders in a sandboxed iframe; local stylesheets, scripts and images are inlined. Relative paths resolve against the current dir. An http(s) URL is linked to the session instead, and the tab on screen shows an Open button.',
|
|
14
15
|
examples: [
|
|
15
16
|
'claude-code-kanban preview-doc ./notes.md --session $CLAUDE_SESSION_ID',
|
|
16
17
|
],
|
|
17
18
|
run: runPreviewCli,
|
|
18
19
|
},
|
|
19
20
|
'link-doc': {
|
|
20
|
-
summary: 'Link a file to a session in the sidebar without opening the preview modal',
|
|
21
|
-
usage: 'claude-code-kanban link-doc <file> --session <id> [--unlink] | link-doc --list --session <id> [--json]',
|
|
21
|
+
summary: 'Link a file or URL to a session in the sidebar without opening the preview modal',
|
|
22
|
+
usage: 'claude-code-kanban link-doc <file|url> --session <id> [--unlink] | link-doc --list --session <id> [--json]',
|
|
22
23
|
flags: {
|
|
23
|
-
'<file>': 'Any file type; one the preview cannot render opens in the editor',
|
|
24
|
+
'<file|url>': 'Any file type; one the preview cannot render opens in the editor. An http(s) URL opens in a new tab.',
|
|
24
25
|
'--session <id>': 'Session to link the file to (required unless $PREVIEW_SESSION is set); full id or unique prefix',
|
|
25
26
|
'--unlink': 'Remove the link instead of adding it (the file need not exist)',
|
|
26
27
|
'--list': 'Print the docs the server holds for the session',
|
|
@@ -29,6 +30,7 @@ const COMMANDS = {
|
|
|
29
30
|
notes: 'The server keeps the link, so it shows when a browser tab opens later.',
|
|
30
31
|
examples: [
|
|
31
32
|
'claude-code-kanban link-doc ./design.md --session $CLAUDE_SESSION_ID',
|
|
33
|
+
'claude-code-kanban link-doc https://github.com/org/repo/pull/12 --session $CLAUDE_SESSION_ID',
|
|
32
34
|
'claude-code-kanban link-doc --list --session $CLAUDE_SESSION_ID',
|
|
33
35
|
],
|
|
34
36
|
run: runLinkDocCli,
|
|
@@ -464,6 +466,8 @@ async function runPreviewCli(args) {
|
|
|
464
466
|
return 1;
|
|
465
467
|
}
|
|
466
468
|
const sessionId = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
|
|
469
|
+
const url = linkUrl(filePathArg);
|
|
470
|
+
if (url) return previewUrlCli(url, sessionId);
|
|
467
471
|
const abs = path.resolve(filePathArg);
|
|
468
472
|
try {
|
|
469
473
|
if (!await cliPostJson('/api/preview', { path: abs, sessionId }, 'Preview')) return 1;
|
|
@@ -472,6 +476,27 @@ async function runPreviewCli(args) {
|
|
|
472
476
|
} catch (e) { reportCliError(e); return 1; }
|
|
473
477
|
}
|
|
474
478
|
|
|
479
|
+
// The modal cannot show a web page, so a URL is linked instead and the tab on screen
|
|
480
|
+
// offers to open it.
|
|
481
|
+
async function previewUrlCli(url, sessionArg) {
|
|
482
|
+
const entry = COMMANDS['preview-doc'];
|
|
483
|
+
if (!sessionArg) return usageError(entry, '--session is required for a URL: it is linked to the session.');
|
|
484
|
+
const resolved = await resolveSessionByIdOrPrefix(sessionArg);
|
|
485
|
+
if (!resolved) return 1;
|
|
486
|
+
return postDocLink(url, resolved.id, { open: true });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function postDocLink(target, sessionId, { unlink = false, open = false } = {}) {
|
|
490
|
+
try {
|
|
491
|
+
const out = await cliPostJson('/api/document/link', { path: target, sessionId, unlink, open }, 'Link');
|
|
492
|
+
if (!out) return 1;
|
|
493
|
+
const what = linkUrl(target) ? 'URL' : 'Document';
|
|
494
|
+
console.log(`${what} ${unlink ? 'unlinked from' : 'linked to'} session ${sessionId.slice(0, 8)}: ${out.path}`);
|
|
495
|
+
if (!unlink && out.tabs === 0) console.log('No browser tab is open; the board shows it when one opens.');
|
|
496
|
+
return 0;
|
|
497
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
498
|
+
}
|
|
499
|
+
|
|
475
500
|
async function runLinkDocCli(args) {
|
|
476
501
|
const entry = COMMANDS['link-doc'];
|
|
477
502
|
const [filePathArg] = positionals(args, ['--session']);
|
|
@@ -487,14 +512,7 @@ async function runLinkDocCli(args) {
|
|
|
487
512
|
const resolved = await resolveSessionByIdOrPrefix(sessionArg);
|
|
488
513
|
if (!resolved) return 1;
|
|
489
514
|
if (list) return printLinkedDocs(resolved.id, args.includes('--json'));
|
|
490
|
-
|
|
491
|
-
try {
|
|
492
|
-
const out = await cliPostJson('/api/document/link', { path: abs, sessionId: resolved.id, unlink }, 'Link');
|
|
493
|
-
if (!out) return 1;
|
|
494
|
-
console.log(`Document ${unlink ? 'unlinked from' : 'linked to'} session ${resolved.id.slice(0, 8)}: ${abs}`);
|
|
495
|
-
if (!unlink && out.tabs === 0) console.log('No browser tab is open; the board shows it when one opens.');
|
|
496
|
-
return 0;
|
|
497
|
-
} catch (e) { reportCliError(e); return 1; }
|
|
515
|
+
return postDocLink(linkUrl(filePathArg) || path.resolve(filePathArg), resolved.id, { unlink });
|
|
498
516
|
}
|
|
499
517
|
|
|
500
518
|
async function printLinkedDocs(sessionId, asJson) {
|
package/install.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('node:path');
|
|
|
5
5
|
const readline = require('node:readline');
|
|
6
6
|
const { execSync } = require('node:child_process');
|
|
7
7
|
const { getClaudeDir, claudeCliEnv, displayPath } = require('./lib/claude-dir');
|
|
8
|
+
const { PLUGIN_ID } = require('./lib/plugin-status');
|
|
8
9
|
|
|
9
10
|
const CLAUDE_DIR = getClaudeDir();
|
|
10
11
|
const CLI_ENV = claudeCliEnv(CLAUDE_DIR);
|
|
@@ -128,7 +129,7 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
128
129
|
const upd = runCLI('claude plugin marketplace update claude-code-kanban');
|
|
129
130
|
if (!upd.ok) console.log(` ${yellow('⚠')} Marketplace refresh failed: ${upd.error}`);
|
|
130
131
|
|
|
131
|
-
const inst = runCLI(
|
|
132
|
+
const inst = runCLI(`claude plugin install ${PLUGIN_ID}`,['already installed', 'already exists']);
|
|
132
133
|
const alreadyInstalled = inst.idempotent || /already installed/i.test(inst.output || '');
|
|
133
134
|
if (inst.ok) {
|
|
134
135
|
console.log(` ${green('✓')} ${alreadyInstalled ? 'Already installed' : 'Plugin installed'}`);
|
package/lib/linked-docs.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Server copy of each session's linked docs. The browser keeps its own list in localStorage
|
|
1
|
+
// Server copy of each session's linked docs: file paths and http(s) URLs. The browser keeps its own list in localStorage
|
|
2
2
|
// and merges this one in when it connects, so a `link-doc` sent while no tab is open is not lost.
|
|
3
3
|
// Newest first, capped like the browser list.
|
|
4
4
|
|
|
5
|
+
const { linkUrl } = require('../public/link-url');
|
|
6
|
+
|
|
5
7
|
const MAX_PER_SESSION = 20;
|
|
6
8
|
|
|
7
9
|
// Same rule as `canonicalPath` in public/app.js: one Windows file is reached as `C:\a` and `c:/a`.
|
|
@@ -53,4 +55,4 @@ function createLinkedDocStore({ load, save }) {
|
|
|
53
55
|
return { link, unlink, get, all };
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
module.exports = { createLinkedDocStore, canonicalPath, MAX_PER_SESSION };
|
|
58
|
+
module.exports = { createLinkedDocStore, canonicalPath, linkUrl, MAX_PER_SESSION };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { isDefaultClaudeDir, displayPath } = require('./claude-dir');
|
|
4
|
+
|
|
5
|
+
const PLUGIN_ID = 'claude-code-kanban@claude-code-kanban';
|
|
6
|
+
const BUNDLED_MANIFEST = path.join(__dirname, '..', 'plugin', 'plugins', 'claude-code-kanban', '.claude-plugin', 'plugin.json');
|
|
7
|
+
|
|
8
|
+
function readJson(file) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The claude CLI records installs in <config dir>/plugins/installed_plugins.json, one entry per scope.
|
|
17
|
+
// A user-scope entry wins, because install.js installs at user scope.
|
|
18
|
+
function pluginStatus(claudeDir, bundledManifest = BUNDLED_MANIFEST) {
|
|
19
|
+
const bundled = readJson(bundledManifest)?.version ?? null;
|
|
20
|
+
const installs = readJson(path.join(claudeDir, 'plugins', 'installed_plugins.json'))?.plugins?.[PLUGIN_ID];
|
|
21
|
+
const entries = Array.isArray(installs) ? installs : [];
|
|
22
|
+
const entry = entries.find((e) => e?.scope === 'user') ?? entries[0];
|
|
23
|
+
const installed = typeof entry?.version === 'string' ? entry.version : null;
|
|
24
|
+
const enabled = readJson(path.join(claudeDir, 'settings.json'))?.enabledPlugins?.[PLUGIN_ID] !== false;
|
|
25
|
+
let state = 'ok';
|
|
26
|
+
if (!installed) state = 'missing';
|
|
27
|
+
else if (!enabled) state = 'disabled';
|
|
28
|
+
else if (bundled && installed !== bundled) state = 'mismatch';
|
|
29
|
+
return { bundled, installed, state, configDir: displayPath(claudeDir), installCommand: installCommand(claudeDir) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --dir rather than CLAUDE_CONFIG_DIR=…, so the same line runs in bash, PowerShell and cmd.
|
|
33
|
+
function installCommand(claudeDir) {
|
|
34
|
+
const base = 'claude-code-kanban --install --plugin-only';
|
|
35
|
+
return isDefaultClaudeDir(claudeDir) ? base : `${base} --dir "${claudeDir}"`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { pluginStatus, installCommand, PLUGIN_ID, BUNDLED_MANIFEST };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.0",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -24,7 +24,7 @@ claude-code-kanban help <command> <subcommand> # flags and examples
|
|
|
24
24
|
| `open` (or none) | `session open ${CLAUDE_SESSION_ID}` |
|
|
25
25
|
| `pin` / `unpin` | `session pin ${CLAUDE_SESSION_ID}` (`--sticky`, `--unpin`) |
|
|
26
26
|
| `preview` | `preview-doc <file> --session ${CLAUDE_SESSION_ID}` — opens a modal on the user's screen |
|
|
27
|
-
| `link` | `link-doc <file> --session ${CLAUDE_SESSION_ID}` — no modal, so it is the safe choice while the user is working |
|
|
27
|
+
| `link` | `link-doc <file\|url> --session ${CLAUDE_SESSION_ID}` — no modal, so it is the safe choice while the user is working. An http(s) URL (a PR, an artifact) opens in a new tab |
|
|
28
28
|
| `list` / `search` | `session list`, `session search <text>` |
|
|
29
29
|
| `view` / `peek` | `session view <id>`, `session peek <id>` |
|
|
30
30
|
| tasks, projects | `task list`, `project list` |
|
package/public/app.js
CHANGED
|
@@ -749,6 +749,34 @@ function toggleMessagePanel() {
|
|
|
749
749
|
updateUrl();
|
|
750
750
|
}
|
|
751
751
|
|
|
752
|
+
function applyMessagePanelFull(on) {
|
|
753
|
+
document.getElementById('message-panel').classList.toggle('full-width', on);
|
|
754
|
+
const btn = document.getElementById('message-panel-full-btn');
|
|
755
|
+
btn.classList.toggle('active', on);
|
|
756
|
+
btn.setAttribute('aria-pressed', String(on));
|
|
757
|
+
const label = on ? 'Restore width' : 'Expand to full width';
|
|
758
|
+
btn.title = label;
|
|
759
|
+
btn.setAttribute('aria-label', label);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
763
|
+
function toggleMessagePanelFull() {
|
|
764
|
+
const on = !document.getElementById('message-panel').classList.contains('full-width');
|
|
765
|
+
store.setItem('message-panel-full', on);
|
|
766
|
+
applyMessagePanelFull(on);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function initMessagePanelFull() {
|
|
770
|
+
const panel = document.getElementById('message-panel');
|
|
771
|
+
const sidebar = document.querySelector('.sidebar');
|
|
772
|
+
if (sidebar) {
|
|
773
|
+
const sync = () => panel.style.setProperty('--sidebar-edge', `${sidebar.getBoundingClientRect().right}px`);
|
|
774
|
+
new ResizeObserver(sync).observe(sidebar);
|
|
775
|
+
sync();
|
|
776
|
+
}
|
|
777
|
+
applyMessagePanelFull(store.getItem('message-panel-full') === 'true');
|
|
778
|
+
}
|
|
779
|
+
|
|
752
780
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
753
781
|
async function openSessionWithBookmarks(sessionId) {
|
|
754
782
|
if (!messagePanelOpen) {
|
|
@@ -2346,22 +2374,41 @@ function isAnyModalOpen() {
|
|
|
2346
2374
|
|
|
2347
2375
|
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
2348
2376
|
|
|
2377
|
+
function zoomDelta(e) {
|
|
2378
|
+
if (!(e.ctrlKey || e.metaKey) || e.altKey) return undefined;
|
|
2379
|
+
return ZOOM_KEYS[e.key] ?? ZOOM_KEYS[e.code];
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2349
2382
|
let _toastTimer = null;
|
|
2350
2383
|
let _manualRefreshing = false;
|
|
2351
2384
|
//#endregion
|
|
2352
2385
|
|
|
2353
2386
|
//#region TOAST
|
|
2354
|
-
|
|
2387
|
+
// `action` ({label, onClick}) adds a button and keeps the toast up long enough to reach it.
|
|
2388
|
+
function showToast(msg, type, action) {
|
|
2355
2389
|
const el = document.getElementById('toast');
|
|
2356
2390
|
clearTimeout(_toastTimer);
|
|
2357
2391
|
el.style.transition = 'none';
|
|
2358
|
-
el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info');
|
|
2392
|
+
el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info', 'has-action');
|
|
2359
2393
|
void el.offsetHeight;
|
|
2360
2394
|
el.style.transition = '';
|
|
2361
2395
|
el.textContent = msg;
|
|
2396
|
+
const hide = () => el.classList.remove('visible', 'has-action');
|
|
2397
|
+
if (action) {
|
|
2398
|
+
const btn = document.createElement('button');
|
|
2399
|
+
btn.type = 'button';
|
|
2400
|
+
btn.className = 'toast-action';
|
|
2401
|
+
btn.textContent = action.label;
|
|
2402
|
+
btn.addEventListener('click', () => {
|
|
2403
|
+
hide();
|
|
2404
|
+
action.onClick();
|
|
2405
|
+
});
|
|
2406
|
+
el.append(btn);
|
|
2407
|
+
el.classList.add('has-action');
|
|
2408
|
+
}
|
|
2362
2409
|
if (type) el.classList.add(`toast-${type}`);
|
|
2363
2410
|
el.classList.add('visible');
|
|
2364
|
-
_toastTimer = setTimeout(
|
|
2411
|
+
_toastTimer = setTimeout(hide, action ? 8000 : 2000);
|
|
2365
2412
|
}
|
|
2366
2413
|
|
|
2367
2414
|
async function copyWithFeedback(text, btn) {
|
|
@@ -3343,7 +3390,7 @@ function renderSessions() {
|
|
|
3343
3390
|
|
|
3344
3391
|
if (zenMode) {
|
|
3345
3392
|
emptyMsg = 'Zen mode: no session open';
|
|
3346
|
-
emptyHint = 'Press Shift+Z to leave zen mode, then open a session';
|
|
3393
|
+
emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
|
|
3347
3394
|
} else if (searchQuery) {
|
|
3348
3395
|
emptyMsg = `No results for "${searchQuery}"`;
|
|
3349
3396
|
emptyHint = 'Try a different search term or clear the search';
|
|
@@ -5499,7 +5546,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5499
5546
|
{ keys: ['['], label: 'Toggle sidebar' },
|
|
5500
5547
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5501
5548
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5502
|
-
{ keys: ['Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
5549
|
+
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
5503
5550
|
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
5504
5551
|
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
5505
5552
|
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
@@ -5536,6 +5583,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5536
5583
|
{ keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
|
|
5537
5584
|
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
|
|
5538
5585
|
{ keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
|
|
5586
|
+
{ keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
|
|
5539
5587
|
],
|
|
5540
5588
|
},
|
|
5541
5589
|
{
|
|
@@ -6081,11 +6129,12 @@ function _renderStorageLinkedDocs() {
|
|
|
6081
6129
|
const name = linkedDocLabel(p);
|
|
6082
6130
|
const sid = escAttrJs(sessionId);
|
|
6083
6131
|
const jsPath = escAttrJs(p);
|
|
6132
|
+
const what = LINKED_DOC_OPENERS[linkedDocOpener(p)].noun;
|
|
6084
6133
|
return `<div class="storage-item" style="padding-left:24px;">
|
|
6085
6134
|
<span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
|
|
6086
6135
|
<div class="storage-item-actions">
|
|
6087
6136
|
<button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
|
|
6088
|
-
<button onclick="copyWithFeedback('${jsPath}', this)" title="Copy
|
|
6137
|
+
<button onclick="copyWithFeedback('${jsPath}', this)" title="Copy ${escapeHtml(what)}" aria-label="Copy ${escapeHtml(what)}">${ICON_COPY}</button>
|
|
6089
6138
|
<button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
|
|
6090
6139
|
</div>
|
|
6091
6140
|
</div>`;
|
|
@@ -6234,13 +6283,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
6234
6283
|
// Scale the open modal's reading surface instead of letting the browser zoom
|
|
6235
6284
|
// the whole page. Sits above the text-field guard so it still works with the
|
|
6236
6285
|
// caret in a field inside the modal.
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
return;
|
|
6243
|
-
}
|
|
6286
|
+
const zoom = zoomDelta(e);
|
|
6287
|
+
if (zoom !== undefined && isZoomableModalOpen()) {
|
|
6288
|
+
e.preventDefault();
|
|
6289
|
+
adjustModalZoom(zoom);
|
|
6290
|
+
return;
|
|
6244
6291
|
}
|
|
6245
6292
|
|
|
6246
6293
|
// Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
|
|
@@ -6911,10 +6958,13 @@ async function handlePreviewOpenEvent(data) {
|
|
|
6911
6958
|
// Linked docs live in localStorage, so the CLI can only reach them through a tab:
|
|
6912
6959
|
// the server broadcasts the resolved path and every tab applies it idempotently.
|
|
6913
6960
|
function handleDocumentLinkEvent(data) {
|
|
6914
|
-
const { path: filePath, sessionId, unlink } = data;
|
|
6961
|
+
const { path: filePath, sessionId, unlink, open } = data;
|
|
6915
6962
|
if (!filePath || !sessionId) return;
|
|
6916
6963
|
setSessionDocLink(sessionId, filePath, unlink);
|
|
6917
|
-
|
|
6964
|
+
const msg = `${unlink ? 'Unlinked' : 'Linked'} ${linkedDocLabel(filePath)}`;
|
|
6965
|
+
// Every tab gets the event; only the one on screen offers to open the URL.
|
|
6966
|
+
if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => openExternal(filePath) });
|
|
6967
|
+
else showToast(msg);
|
|
6918
6968
|
}
|
|
6919
6969
|
|
|
6920
6970
|
function getSessionBaseDir(sessionId) {
|
|
@@ -6996,10 +7046,13 @@ function isScratchpadPath(p) {
|
|
|
6996
7046
|
// `linkedDocOpener` is the only place a linked path is classified; the row's tag,
|
|
6997
7047
|
// tooltip and action are all read from here, so a new destination is one entry.
|
|
6998
7048
|
const LINKED_DOC_OPENERS = {
|
|
6999
|
-
preview: { tag: '', hint: '' },
|
|
7049
|
+
preview: { tag: '', hint: '', cls: '', noun: 'path' },
|
|
7050
|
+
url: { tag: '', hint: ' — opens in a new tab', cls: ' is-url', noun: 'URL' },
|
|
7000
7051
|
scratch: {
|
|
7001
7052
|
tag: '(scratchpad)',
|
|
7002
7053
|
hint: ' — opens in the scratch viewer',
|
|
7054
|
+
cls: ' is-editor',
|
|
7055
|
+
noun: 'path',
|
|
7003
7056
|
url: '/api/scratchpad/open',
|
|
7004
7057
|
body: (p) => ({ path: p }),
|
|
7005
7058
|
toast: 'in the scratch viewer',
|
|
@@ -7007,6 +7060,8 @@ const LINKED_DOC_OPENERS = {
|
|
|
7007
7060
|
editor: {
|
|
7008
7061
|
tag: '(editor)',
|
|
7009
7062
|
hint: ' — opens in editor',
|
|
7063
|
+
cls: ' is-editor',
|
|
7064
|
+
noun: 'path',
|
|
7010
7065
|
url: '/api/open-in-editor',
|
|
7011
7066
|
body: (p) => ({ file: p }),
|
|
7012
7067
|
toast: 'in editor',
|
|
@@ -7017,6 +7072,7 @@ const LINKED_DOC_OPENERS = {
|
|
|
7017
7072
|
// text, but the pad is what the user linked, so the viewer wins over the raw file.
|
|
7018
7073
|
// Without the CLI there is nothing to launch, so it falls back to the editor.
|
|
7019
7074
|
function linkedDocOpener(p) {
|
|
7075
|
+
if (linkUrl(p)) return 'url';
|
|
7020
7076
|
if (isScratchpadPath(p)) return appConfig.scratchAvailable ? 'scratch' : 'editor';
|
|
7021
7077
|
// A path whose kind has not arrived yet renders as previewable, which is what the
|
|
7022
7078
|
// click does anyway — openPreviewByPath falls back to the editor on a null kind.
|
|
@@ -7026,12 +7082,17 @@ function linkedDocOpener(p) {
|
|
|
7026
7082
|
// Every manifest is called scratchpad.json, so the pad's folder names it. Kept
|
|
7027
7083
|
// independent of the opener: the pad is still the pad when the CLI is missing.
|
|
7028
7084
|
function linkedDocLabel(p) {
|
|
7085
|
+
if (linkUrl(p)) return p.replace(/^https?:\/\//i, '').replace(/\/$/, '');
|
|
7029
7086
|
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
7030
7087
|
return isScratchpadPath(p) ? parts[parts.length - 2] || parts[parts.length - 1] : parts[parts.length - 1];
|
|
7031
7088
|
}
|
|
7032
7089
|
|
|
7033
7090
|
function openLinkedDoc(p, baseDir) {
|
|
7034
7091
|
const opener = linkedDocOpener(p);
|
|
7092
|
+
if (opener === 'url') {
|
|
7093
|
+
openExternal(p);
|
|
7094
|
+
return;
|
|
7095
|
+
}
|
|
7035
7096
|
if (opener === 'preview') {
|
|
7036
7097
|
openPreviewByPath(p, baseDir, openFileInEditor);
|
|
7037
7098
|
return;
|
|
@@ -7044,21 +7105,24 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
7044
7105
|
const paths = getSessionPreviewPaths(sessionId);
|
|
7045
7106
|
// Kicked off from the render rather than the three call sites, so a new surface that
|
|
7046
7107
|
// shows linked docs cannot forget it. Returns at once once every path is cached.
|
|
7047
|
-
|
|
7108
|
+
const rows = paths.map((p) => ({ p, opener: linkedDocOpener(p) }));
|
|
7109
|
+
const filePaths = rows.filter((r) => r.opener !== 'url').map((r) => r.p);
|
|
7110
|
+
loadPreviewKinds(sessionId, filePaths);
|
|
7048
7111
|
const baseDir = getSessionBaseDir(sessionId);
|
|
7049
|
-
const items =
|
|
7050
|
-
.map((p) => {
|
|
7051
|
-
const
|
|
7052
|
-
const { tag, hint } = LINKED_DOC_OPENERS[opener];
|
|
7112
|
+
const items = rows
|
|
7113
|
+
.map(({ p, opener }) => {
|
|
7114
|
+
const { tag, hint, cls, noun } = LINKED_DOC_OPENERS[opener];
|
|
7053
7115
|
const name = linkedDocLabel(p);
|
|
7116
|
+
const isUrl = opener === 'url';
|
|
7054
7117
|
const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
|
|
7055
7118
|
const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
|
|
7056
7119
|
const attr = escapeHtml(p);
|
|
7057
|
-
|
|
7058
|
-
|
|
7120
|
+
const href = isUrl ? `href="${attr}" target="_blank" rel="noopener"` : 'href="#"';
|
|
7121
|
+
return `<li class="linked-doc-item${escapeHtml(cls)}">
|
|
7122
|
+
<a ${href} class="linked-doc-link" data-path="${attr}" title="${escapeHtml(p + hint)}">${escapeHtml(name)}</a>${isUrl ? '<span class="linked-doc-external" aria-hidden="true">↗</span>' : ''}
|
|
7059
7123
|
${pathSpan}${tag ? `<span class="linked-doc-path">${tag}</span>` : ''}
|
|
7060
7124
|
<span class="row-actions linked-doc-actions">
|
|
7061
|
-
<button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy
|
|
7125
|
+
<button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy ${escapeHtml(noun)}" aria-label="Copy ${escapeHtml(noun)} of ${escapeHtml(name)}">${ICON_COPY}</button>
|
|
7062
7126
|
<button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">×</button>
|
|
7063
7127
|
</span>
|
|
7064
7128
|
</li>`;
|
|
@@ -7067,13 +7131,13 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
7067
7131
|
// Rendered even when empty — the add button has to stay reachable.
|
|
7068
7132
|
const body = paths.length
|
|
7069
7133
|
? `<ul class="linked-doc-list">${items}</ul>`
|
|
7070
|
-
: '<div class="linked-docs-empty">No linked
|
|
7134
|
+
: '<div class="linked-docs-empty">No linked documents yet</div>';
|
|
7071
7135
|
return `<div class="linked-docs-section panel-section">
|
|
7072
7136
|
<div class="panel-section-header">
|
|
7073
7137
|
${linkSvg(12)}
|
|
7074
7138
|
<span>Linked documents</span>
|
|
7075
7139
|
<span class="panel-section-count">${paths.length}</span>
|
|
7076
|
-
<button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
|
|
7140
|
+
<button type="button" class="linked-docs-add-btn" title="Link a file or URL" aria-label="Link a file or URL">+</button>
|
|
7077
7141
|
</div>
|
|
7078
7142
|
<div class="linked-doc-editor-slot"></div>
|
|
7079
7143
|
${body}
|
|
@@ -7342,7 +7406,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
7342
7406
|
if (!container) return;
|
|
7343
7407
|
container.addEventListener('click', (e) => {
|
|
7344
7408
|
const hit = e.target.closest('.linked-doc-link, .linked-doc-copy, .linked-doc-remove, .linked-docs-add-btn');
|
|
7345
|
-
if (!hit) return;
|
|
7409
|
+
if (!hit || hit.target === '_blank') return;
|
|
7346
7410
|
e.preventDefault();
|
|
7347
7411
|
if (hit.classList.contains('linked-docs-add-btn')) {
|
|
7348
7412
|
startLinkedDocInput(container, sessionId);
|
|
@@ -7361,7 +7425,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
7361
7425
|
function startLinkedDocInput(container, sessionId) {
|
|
7362
7426
|
const slot = container.querySelector('.linked-doc-editor-slot');
|
|
7363
7427
|
if (!slot) return;
|
|
7364
|
-
slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="
|
|
7428
|
+
slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Path, file:// or https:// URL">
|
|
7365
7429
|
<div class="linked-doc-hint">${escapeHtml(getSessionBaseDir(sessionId) || 'no session cwd — absolute paths only')}</div>
|
|
7366
7430
|
<div class="linked-doc-error"></div>
|
|
7367
7431
|
<div class="edit-actions">
|
|
@@ -7375,6 +7439,9 @@ function startLinkedDocInput(container, sessionId) {
|
|
|
7375
7439
|
};
|
|
7376
7440
|
slot.querySelector('.edit-save').addEventListener('click', save);
|
|
7377
7441
|
slot.querySelector('.edit-cancel').addEventListener('click', cancel);
|
|
7442
|
+
input.addEventListener('input', () => {
|
|
7443
|
+
slot.querySelector('.linked-doc-error').textContent = '';
|
|
7444
|
+
});
|
|
7378
7445
|
input.addEventListener('keydown', (e) => {
|
|
7379
7446
|
if (e.key === 'Enter') {
|
|
7380
7447
|
e.preventDefault();
|
|
@@ -7397,27 +7464,38 @@ async function linkFileByPath(sessionId, raw, slot) {
|
|
|
7397
7464
|
else showToast(msg, 'error');
|
|
7398
7465
|
};
|
|
7399
7466
|
if (!value) {
|
|
7400
|
-
fail('Enter a file path');
|
|
7467
|
+
fail('Enter a file path or URL');
|
|
7401
7468
|
return;
|
|
7402
7469
|
}
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
if (
|
|
7407
|
-
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7470
|
+
let target;
|
|
7471
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) && !/^file:/i.test(value)) {
|
|
7472
|
+
target = linkUrl(value);
|
|
7473
|
+
if (!target) {
|
|
7474
|
+
fail('Only http and https URLs can be linked');
|
|
7475
|
+
return;
|
|
7476
|
+
}
|
|
7477
|
+
} else {
|
|
7478
|
+
try {
|
|
7479
|
+
const qs = new URLSearchParams({ path: value });
|
|
7480
|
+
const base = getSessionBaseDir(sessionId);
|
|
7481
|
+
if (base) qs.set('base', base);
|
|
7482
|
+
const r = await fetch(`/api/file/resolve?${qs}`);
|
|
7483
|
+
const data = await r.json().catch(() => ({}));
|
|
7484
|
+
if (!r.ok || !data.exists) {
|
|
7485
|
+
fail(data.error || 'File not found');
|
|
7486
|
+
return;
|
|
7487
|
+
}
|
|
7488
|
+
target = data.path;
|
|
7489
|
+
} catch {
|
|
7490
|
+
fail('Failed to resolve file');
|
|
7411
7491
|
return;
|
|
7412
7492
|
}
|
|
7413
|
-
// Close the editor before the refresh: a sidebar panel defers its re-render while an
|
|
7414
|
-
// input is open, so leaving it up would hide the file that was just linked.
|
|
7415
|
-
if (slot) slot.innerHTML = '';
|
|
7416
|
-
setSessionDocLink(sessionId, data.path, false);
|
|
7417
|
-
showToast('Linked to session', 'success');
|
|
7418
|
-
} catch {
|
|
7419
|
-
fail('Failed to resolve file');
|
|
7420
7493
|
}
|
|
7494
|
+
// Close the editor before the refresh: a sidebar panel defers its re-render while an
|
|
7495
|
+
// input is open, so leaving it up would hide the file that was just linked.
|
|
7496
|
+
if (slot) slot.innerHTML = '';
|
|
7497
|
+
setSessionDocLink(sessionId, target, false);
|
|
7498
|
+
showToast('Linked to session', 'success');
|
|
7421
7499
|
}
|
|
7422
7500
|
//#endregion
|
|
7423
7501
|
|
|
@@ -8246,10 +8324,44 @@ function getMermaidTheme() {
|
|
|
8246
8324
|
return isLightTheme() ? 'default' : 'dark';
|
|
8247
8325
|
}
|
|
8248
8326
|
|
|
8327
|
+
// Mermaid is about 3 MB, so it loads on the first diagram instead of on the page's critical path.
|
|
8328
|
+
const MERMAID_SCRIPT = {
|
|
8329
|
+
src: 'https://cdn.jsdelivr.net/npm/mermaid@12.0.0/dist/mermaid.min.js',
|
|
8330
|
+
integrity: 'sha384-xzghz1GQ5u9HCpVskeDPqMsdogD1yvuMQbEK53+wi+G70+6J1AG0L2cfi9PHjDWI',
|
|
8331
|
+
};
|
|
8332
|
+
let mermaidLoading = null;
|
|
8333
|
+
|
|
8334
|
+
function loadMermaid() {
|
|
8335
|
+
mermaidLoading ??= new Promise((resolve, reject) => {
|
|
8336
|
+
const script = document.createElement('script');
|
|
8337
|
+
script.src = MERMAID_SCRIPT.src;
|
|
8338
|
+
script.integrity = MERMAID_SCRIPT.integrity;
|
|
8339
|
+
script.crossOrigin = 'anonymous';
|
|
8340
|
+
script.onload = () => {
|
|
8341
|
+
mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
|
|
8342
|
+
resolve();
|
|
8343
|
+
};
|
|
8344
|
+
script.onerror = () => {
|
|
8345
|
+
script.remove();
|
|
8346
|
+
mermaidLoading = null;
|
|
8347
|
+
reject(new Error('mermaid failed to load'));
|
|
8348
|
+
};
|
|
8349
|
+
document.head.appendChild(script);
|
|
8350
|
+
});
|
|
8351
|
+
return mermaidLoading;
|
|
8352
|
+
}
|
|
8353
|
+
|
|
8249
8354
|
function initMermaidBlocks(container) {
|
|
8250
|
-
if (typeof mermaid === 'undefined') return;
|
|
8251
8355
|
const blocks = (container || document).querySelectorAll('pre.mermaid:not([data-processed])');
|
|
8252
|
-
if (blocks.length)
|
|
8356
|
+
if (!blocks.length) return;
|
|
8357
|
+
if (typeof mermaid === 'undefined') {
|
|
8358
|
+
loadMermaid().then(
|
|
8359
|
+
() => initMermaidBlocks(container),
|
|
8360
|
+
(err) => console.warn(err.message),
|
|
8361
|
+
);
|
|
8362
|
+
return;
|
|
8363
|
+
}
|
|
8364
|
+
mermaid.run({ nodes: [...blocks] });
|
|
8253
8365
|
}
|
|
8254
8366
|
|
|
8255
8367
|
function reinitMermaidTheme() {
|
|
@@ -8563,7 +8675,9 @@ function renderZenState() {
|
|
|
8563
8675
|
if (!btn) return;
|
|
8564
8676
|
btn.classList.toggle('active', zenMode);
|
|
8565
8677
|
btn.setAttribute('aria-pressed', String(zenMode));
|
|
8566
|
-
btn.title = zenMode
|
|
8678
|
+
btn.title = zenMode
|
|
8679
|
+
? 'Zen mode on — show all sessions (Ctrl+Shift+Z)'
|
|
8680
|
+
: 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
8567
8681
|
}
|
|
8568
8682
|
|
|
8569
8683
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -10153,6 +10267,9 @@ const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
|
10153
10267
|
// Swap to the previous session.
|
|
10154
10268
|
const CCK_CTRL_ALT_KEYS = new Set(['KeyN', 'KeyR', 'KeyS']);
|
|
10155
10269
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
10270
|
+
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
10271
|
+
const TERMINAL_FONT_MIN = 8;
|
|
10272
|
+
const TERMINAL_FONT_MAX = 32;
|
|
10156
10273
|
const ACK_BATCH_BYTES = 32 * 1024;
|
|
10157
10274
|
const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
|
|
10158
10275
|
const TERMINAL_STABLE_MS = 5000;
|
|
@@ -10265,11 +10382,24 @@ function terminalPaneFocused() {
|
|
|
10265
10382
|
return document.getElementById('terminal-pane').contains(document.activeElement);
|
|
10266
10383
|
}
|
|
10267
10384
|
|
|
10385
|
+
const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
|
|
10386
|
+
|
|
10268
10387
|
function terminalShortcut(e) {
|
|
10269
10388
|
const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
|
|
10389
|
+
const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
|
|
10390
|
+
// Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
|
|
10391
|
+
const zoom = e.key === '_' ? undefined : zoomDelta(e);
|
|
10392
|
+
if (zoom !== undefined && termState.term && terminalPaneFocused()) {
|
|
10393
|
+
return () => adjustTerminalFontSize(Math.sign(zoom));
|
|
10394
|
+
}
|
|
10270
10395
|
if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
|
|
10271
10396
|
// Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
|
|
10272
|
-
if (
|
|
10397
|
+
if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
|
|
10398
|
+
// Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
|
|
10399
|
+
if (ctrlShift && e.code === 'KeyZ') {
|
|
10400
|
+
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
10401
|
+
return field && !document.getElementById('terminal-pane').contains(field) ? null : toggleZenMode;
|
|
10402
|
+
}
|
|
10273
10403
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
10274
10404
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
10275
10405
|
}
|
|
@@ -10379,7 +10509,7 @@ function takeTerminalFocus(sessionId) {
|
|
|
10379
10509
|
// The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
|
|
10380
10510
|
function promptAwaitsUser(sessionId) {
|
|
10381
10511
|
if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
|
|
10382
|
-
return !document.activeElement?.matches(
|
|
10512
|
+
return !document.activeElement?.matches(TEXT_FIELD_SELECTOR);
|
|
10383
10513
|
}
|
|
10384
10514
|
|
|
10385
10515
|
function onTerminalShown(focus = true) {
|
|
@@ -10480,9 +10610,10 @@ function ensureTerm() {
|
|
|
10480
10610
|
const host = document.getElementById('terminal-host');
|
|
10481
10611
|
const fontFamily =
|
|
10482
10612
|
cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
|
|
10613
|
+
const fontSize = terminalFontSize();
|
|
10483
10614
|
const term = new window.Terminal({
|
|
10484
10615
|
fontFamily,
|
|
10485
|
-
fontSize
|
|
10616
|
+
fontSize,
|
|
10486
10617
|
scrollback: cfg.scrollback,
|
|
10487
10618
|
cursorBlink: true,
|
|
10488
10619
|
allowProposedApi: true,
|
|
@@ -10506,7 +10637,7 @@ function ensureTerm() {
|
|
|
10506
10637
|
// No WebGL: xterm falls back to its DOM renderer.
|
|
10507
10638
|
}
|
|
10508
10639
|
document.fonts
|
|
10509
|
-
?.load(`${
|
|
10640
|
+
?.load(`${fontSize}px ${fontFamily}`)
|
|
10510
10641
|
.then(repaintTerminal)
|
|
10511
10642
|
.catch(() => {});
|
|
10512
10643
|
term.attachCustomKeyEventHandler(terminalKeyFilter);
|
|
@@ -10586,6 +10717,27 @@ function fitTerminal() {
|
|
|
10586
10717
|
} catch (_) {}
|
|
10587
10718
|
}
|
|
10588
10719
|
|
|
10720
|
+
function clampTerminalFont(v) {
|
|
10721
|
+
return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, v));
|
|
10722
|
+
}
|
|
10723
|
+
|
|
10724
|
+
function terminalFontSize() {
|
|
10725
|
+
const saved = Number.parseInt(store.getItem(TERMINAL_FONT_KEY), 10);
|
|
10726
|
+
return saved ? clampTerminalFont(saved) : appConfig.terminal.fontSize;
|
|
10727
|
+
}
|
|
10728
|
+
|
|
10729
|
+
function adjustTerminalFontSize(step) {
|
|
10730
|
+
const term = termState.term;
|
|
10731
|
+
const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(term.options.fontSize + step);
|
|
10732
|
+
if (size !== term.options.fontSize) {
|
|
10733
|
+
if (step === 0) store.removeItem(TERMINAL_FONT_KEY);
|
|
10734
|
+
else store.setItem(TERMINAL_FONT_KEY, String(size));
|
|
10735
|
+
term.options.fontSize = size;
|
|
10736
|
+
fitTerminal();
|
|
10737
|
+
}
|
|
10738
|
+
showToast(`Terminal ${size}px`);
|
|
10739
|
+
}
|
|
10740
|
+
|
|
10589
10741
|
function terminalSend(msg) {
|
|
10590
10742
|
if (termState.ws?.readyState === WebSocket.OPEN) termState.ws.send(JSON.stringify(msg));
|
|
10591
10743
|
}
|
|
@@ -11464,19 +11616,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
11464
11616
|
marked.use({ renderer });
|
|
11465
11617
|
}
|
|
11466
11618
|
|
|
11467
|
-
|
|
11468
|
-
|
|
11469
|
-
|
|
11470
|
-
|
|
11471
|
-
|
|
11472
|
-
mermaidPending =
|
|
11473
|
-
|
|
11474
|
-
mermaidPending = false;
|
|
11475
|
-
initMermaidBlocks();
|
|
11476
|
-
});
|
|
11619
|
+
let mermaidPending = false;
|
|
11620
|
+
const mo = new MutationObserver(() => {
|
|
11621
|
+
if (mermaidPending) return;
|
|
11622
|
+
mermaidPending = true;
|
|
11623
|
+
queueMicrotask(() => {
|
|
11624
|
+
mermaidPending = false;
|
|
11625
|
+
initMermaidBlocks();
|
|
11477
11626
|
});
|
|
11478
|
-
|
|
11479
|
-
}
|
|
11627
|
+
});
|
|
11628
|
+
mo.observe(document.body, { childList: true, subtree: true });
|
|
11480
11629
|
});
|
|
11481
11630
|
|
|
11482
11631
|
loadSidebarState();
|
|
@@ -11500,6 +11649,7 @@ initModalResize();
|
|
|
11500
11649
|
loadPanelWidths();
|
|
11501
11650
|
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
|
|
11502
11651
|
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
|
|
11652
|
+
initMessagePanelFull();
|
|
11503
11653
|
|
|
11504
11654
|
const msgContentEl = document.getElementById('message-panel-content');
|
|
11505
11655
|
const jumpLatestBtn = document.createElement('button');
|
|
@@ -11536,7 +11686,7 @@ msgContentEl.addEventListener('wheel', function (e) {
|
|
|
11536
11686
|
}
|
|
11537
11687
|
});
|
|
11538
11688
|
|
|
11539
|
-
const footerState = { version: null, limitsKey: null, timer: null };
|
|
11689
|
+
const footerState = { version: null, plugin: null, limitsKey: null, timer: null };
|
|
11540
11690
|
function formatResetIn(epochSec) {
|
|
11541
11691
|
if (!epochSec) return null;
|
|
11542
11692
|
const ms = epochSec * 1000 - Date.now();
|
|
@@ -11582,12 +11732,45 @@ function renderSidebarFooter(rateLimits) {
|
|
|
11582
11732
|
const children = [];
|
|
11583
11733
|
if (footerState.version) {
|
|
11584
11734
|
const v = document.createElement('span');
|
|
11735
|
+
v.className = 'footer-version';
|
|
11585
11736
|
v.textContent = `v${footerState.version}`;
|
|
11737
|
+
const warn = pluginWarning(footerState.plugin);
|
|
11738
|
+
if (warn) v.append(warn);
|
|
11586
11739
|
children.push(v);
|
|
11587
11740
|
}
|
|
11588
11741
|
if (fh != null || sd != null) children.push(makeLimitSpan(rateLimits));
|
|
11589
11742
|
el.replaceChildren(...children);
|
|
11590
11743
|
}
|
|
11744
|
+
function pluginWarning(plugin) {
|
|
11745
|
+
const problem =
|
|
11746
|
+
plugin &&
|
|
11747
|
+
{
|
|
11748
|
+
missing: 'The claude-code-kanban plugin is not installed',
|
|
11749
|
+
disabled: `The claude-code-kanban plugin ${plugin.installed} is disabled`,
|
|
11750
|
+
mismatch: `Plugin ${plugin.installed} is installed, this board ships ${plugin.bundled}`,
|
|
11751
|
+
}[plugin.state];
|
|
11752
|
+
if (!problem) return null;
|
|
11753
|
+
const fix =
|
|
11754
|
+
plugin.state === 'disabled'
|
|
11755
|
+
? 'Enable it with /plugin in Claude Code.'
|
|
11756
|
+
: `Click to copy: ${plugin.installCommand}\nThen restart open sessions, they keep the old hooks.`;
|
|
11757
|
+
const btn = document.createElement('button');
|
|
11758
|
+
btn.type = 'button';
|
|
11759
|
+
btn.className = 'footer-plugin-warning';
|
|
11760
|
+
btn.textContent = '⚠';
|
|
11761
|
+
btn.title = `${problem} (${plugin.configDir}).\n${fix}`;
|
|
11762
|
+
btn.setAttribute('aria-label', problem);
|
|
11763
|
+
if (plugin.state !== 'disabled') {
|
|
11764
|
+
btn.addEventListener('click', () =>
|
|
11765
|
+
navigator.clipboard
|
|
11766
|
+
.writeText(plugin.installCommand)
|
|
11767
|
+
.then(() => showToast('Install command copied', 'success'))
|
|
11768
|
+
.catch(() => showToast('Could not copy the command', 'error')),
|
|
11769
|
+
);
|
|
11770
|
+
}
|
|
11771
|
+
return btn;
|
|
11772
|
+
}
|
|
11773
|
+
|
|
11591
11774
|
function refreshRateLimits() {
|
|
11592
11775
|
if (footerState.timer) return;
|
|
11593
11776
|
footerState.timer = setTimeout(() => {
|
|
@@ -11614,6 +11797,7 @@ fetch('/api/version')
|
|
|
11614
11797
|
.then((r) => r.json())
|
|
11615
11798
|
.then((d) => {
|
|
11616
11799
|
footerState.version = d.version;
|
|
11800
|
+
footerState.plugin = d.plugin ?? null;
|
|
11617
11801
|
renderSidebarFooter(null);
|
|
11618
11802
|
refreshRateLimits();
|
|
11619
11803
|
})
|
|
@@ -11782,6 +11966,13 @@ document.addEventListener('click', (e) => {
|
|
|
11782
11966
|
hubPost({ type: 'hub:openExternal', url: url.href });
|
|
11783
11967
|
});
|
|
11784
11968
|
|
|
11969
|
+
// The click handler above only catches anchors. A script open goes the same way, because
|
|
11970
|
+
// in the hub's installed PWA window a framed app's own `_blank` open does nothing.
|
|
11971
|
+
function openExternal(url) {
|
|
11972
|
+
if (window.__HUB__?.enabled) hubPost({ type: 'hub:openExternal', url });
|
|
11973
|
+
else window.open(url, '_blank', 'noopener');
|
|
11974
|
+
}
|
|
11975
|
+
|
|
11785
11976
|
window.hubNavigate = function hubNavigate(app, url) {
|
|
11786
11977
|
if (!window.__HUB__?.enabled) return;
|
|
11787
11978
|
hubPost({ type: 'hub:navigate', app, url });
|
package/public/index.html
CHANGED
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github-dark.min.css" integrity="sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH" crossorigin="anonymous" id="hljs-theme-dark">
|
|
27
27
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css" integrity="sha384-eFTL69TLRZTkNfYZOLM+G04821K1qZao/4QLJbet1pP4tcF+fdXq/9CdqAbWRl/L" crossorigin="anonymous" id="hljs-theme-light" disabled>
|
|
28
28
|
<script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js" integrity="sha384-wjfDDhOPPdjtva8vWBhWeVprSpmxisEu5aYT3q1JyACqXpdKpo3PWZTMVq24MBix" crossorigin="anonymous"></script>
|
|
29
|
-
<script defer src="https://cdn.jsdelivr.net/npm/mermaid@12.0.0/dist/mermaid.min.js" integrity="sha384-xzghz1GQ5u9HCpVskeDPqMsdogD1yvuMQbEK53+wi+G70+6J1AG0L2cfi9PHjDWI" crossorigin="anonymous"></script>
|
|
30
29
|
<script>window.__STORAGE_NS__ = null;</script>
|
|
31
30
|
<script defer src="/project-match.js"></script>
|
|
31
|
+
<script defer src="/link-url.js"></script>
|
|
32
32
|
<script defer src="/app.js"></script>
|
|
33
33
|
</head>
|
|
34
34
|
<body class="booting">
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
id="zen-mode-btn"
|
|
83
83
|
class="icon-btn section-btn"
|
|
84
84
|
onclick="event.stopPropagation(); toggleZenMode()"
|
|
85
|
-
title="Zen mode: current session only (Shift+Z)"
|
|
85
|
+
title="Zen mode: current session only (Ctrl+Shift+Z)"
|
|
86
86
|
aria-label="Zen mode: current session only"
|
|
87
87
|
aria-pressed="false"
|
|
88
88
|
>
|
|
@@ -367,6 +367,9 @@
|
|
|
367
367
|
<button class="icon-btn" onclick="openLiveLatestMessage()" title="Follow latest message (Shift+M)" aria-label="Follow latest message">
|
|
368
368
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
369
369
|
</button>
|
|
370
|
+
<button id="message-panel-full-btn" class="icon-btn" onclick="toggleMessagePanelFull()" title="Expand to full width" aria-label="Expand to full width" aria-pressed="false">
|
|
371
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
|
372
|
+
</button>
|
|
370
373
|
<button class="detail-close" onclick="toggleMessagePanel()" aria-label="Close message panel">
|
|
371
374
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
372
375
|
<path d="M6 18L18 6M6 6l12 12"/>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Loaded by the page as a plain script and by lib/linked-docs.js through require, so the
|
|
2
|
+
// sidebar, the API and the CLI accept the same URLs. Only http(s): linked docs render as
|
|
3
|
+
// links, so a `javascript:` or `data:` value sent through the API must not become one.
|
|
4
|
+
function linkUrl(value) {
|
|
5
|
+
if (typeof value !== 'string') return null;
|
|
6
|
+
const v = value.trim();
|
|
7
|
+
if (!/^https?:\/\//i.test(v)) return null;
|
|
8
|
+
try {
|
|
9
|
+
return new URL(v).href;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (typeof module === 'object' && module.exports) module.exports = { linkUrl };
|
package/public/style.css
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
--text-muted: #7d808a;
|
|
17
17
|
--accent: #e86f33;
|
|
18
18
|
--accent-text: #f0a070;
|
|
19
|
-
--accent-dim: rgba(232, 111, 51, 0.
|
|
19
|
+
--accent-dim: rgba(232, 111, 51, 0.25);
|
|
20
20
|
--accent-glow: rgba(232, 111, 51, 0.55);
|
|
21
21
|
--gold: #d9b667;
|
|
22
22
|
--success: #3ecf8e;
|
|
@@ -1304,6 +1304,28 @@ body.booting .boot-splash {
|
|
|
1304
1304
|
color: var(--text-secondary);
|
|
1305
1305
|
}
|
|
1306
1306
|
|
|
1307
|
+
.sidebar-footer .footer-version {
|
|
1308
|
+
display: inline-flex;
|
|
1309
|
+
align-items: center;
|
|
1310
|
+
gap: 6px;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
.sidebar-footer .footer-plugin-warning {
|
|
1314
|
+
padding: 0;
|
|
1315
|
+
border: none;
|
|
1316
|
+
background: none;
|
|
1317
|
+
font: inherit;
|
|
1318
|
+
font-size: 11px;
|
|
1319
|
+
line-height: 1;
|
|
1320
|
+
color: var(--warning);
|
|
1321
|
+
cursor: pointer;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
.sidebar-footer .footer-plugin-warning:hover,
|
|
1325
|
+
.sidebar-footer .footer-plugin-warning:focus-visible {
|
|
1326
|
+
filter: brightness(1.2);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1307
1329
|
.sidebar.collapsed {
|
|
1308
1330
|
width: 48px;
|
|
1309
1331
|
}
|
|
@@ -3208,6 +3230,16 @@ body.booting .boot-splash {
|
|
|
3208
3230
|
.message-panel.msg-expanded-wide {
|
|
3209
3231
|
width: 60vw;
|
|
3210
3232
|
}
|
|
3233
|
+
.message-panel.full-width {
|
|
3234
|
+
left: var(--sidebar-edge, 0);
|
|
3235
|
+
width: auto;
|
|
3236
|
+
}
|
|
3237
|
+
.message-panel.full-width .panel-resize-handle {
|
|
3238
|
+
display: none;
|
|
3239
|
+
}
|
|
3240
|
+
#message-panel-full-btn.active {
|
|
3241
|
+
color: var(--accent);
|
|
3242
|
+
}
|
|
3211
3243
|
.message-panel-header {
|
|
3212
3244
|
padding: 16px 20px;
|
|
3213
3245
|
display: flex;
|
|
@@ -3627,6 +3659,28 @@ body.booting .boot-splash {
|
|
|
3627
3659
|
opacity: 1;
|
|
3628
3660
|
transform: translateY(0);
|
|
3629
3661
|
}
|
|
3662
|
+
.toast.has-action.visible {
|
|
3663
|
+
pointer-events: auto;
|
|
3664
|
+
display: flex;
|
|
3665
|
+
align-items: center;
|
|
3666
|
+
gap: 10px;
|
|
3667
|
+
max-width: min(480px, calc(100vw - 32px));
|
|
3668
|
+
overflow-wrap: anywhere;
|
|
3669
|
+
}
|
|
3670
|
+
.toast-action {
|
|
3671
|
+
flex-shrink: 0;
|
|
3672
|
+
font: inherit;
|
|
3673
|
+
font-weight: 600;
|
|
3674
|
+
color: var(--accent-text);
|
|
3675
|
+
background: transparent;
|
|
3676
|
+
border: 1px solid var(--accent);
|
|
3677
|
+
border-radius: 4px;
|
|
3678
|
+
padding: 2px 10px;
|
|
3679
|
+
cursor: pointer;
|
|
3680
|
+
}
|
|
3681
|
+
.toast-action:hover {
|
|
3682
|
+
background: var(--accent-dim);
|
|
3683
|
+
}
|
|
3630
3684
|
.toast.toast-success {
|
|
3631
3685
|
border-color: var(--success);
|
|
3632
3686
|
color: var(--success);
|
|
@@ -4705,6 +4759,21 @@ body.booting .boot-splash {
|
|
|
4705
4759
|
text-decoration-style: dashed;
|
|
4706
4760
|
}
|
|
4707
4761
|
|
|
4762
|
+
.linked-doc-item.is-url .linked-doc-link {
|
|
4763
|
+
flex-shrink: 1;
|
|
4764
|
+
min-width: 0;
|
|
4765
|
+
overflow: hidden;
|
|
4766
|
+
text-overflow: ellipsis;
|
|
4767
|
+
white-space: nowrap;
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
.linked-doc-external {
|
|
4771
|
+
flex-shrink: 0;
|
|
4772
|
+
color: var(--text-muted);
|
|
4773
|
+
font-size: 10px;
|
|
4774
|
+
margin-left: -4px;
|
|
4775
|
+
}
|
|
4776
|
+
|
|
4708
4777
|
/* A section of the session panel: linked documents, artifacts. */
|
|
4709
4778
|
.panel-section {
|
|
4710
4779
|
margin-bottom: 16px;
|
|
@@ -5085,11 +5154,11 @@ body.light {
|
|
|
5085
5154
|
--session-active-bg: var(--bg-elevated);
|
|
5086
5155
|
--border: #cfcbc4;
|
|
5087
5156
|
--text-primary: #0a0a0a;
|
|
5088
|
-
--text-secondary: #
|
|
5089
|
-
--text-tertiary: #
|
|
5090
|
-
--text-muted: #
|
|
5157
|
+
--text-secondary: #2c2c2c;
|
|
5158
|
+
--text-tertiary: #565656;
|
|
5159
|
+
--text-muted: #767676;
|
|
5091
5160
|
--accent-text: #a0501c;
|
|
5092
|
-
--accent-dim: rgba(
|
|
5161
|
+
--accent-dim: rgba(184, 90, 32, 0.18);
|
|
5093
5162
|
--accent-glow: rgba(232, 111, 51, 0.5);
|
|
5094
5163
|
--gold: #a8842f;
|
|
5095
5164
|
--success: #1a8a5a;
|
|
@@ -6677,10 +6746,11 @@ select.form-input option:checked {
|
|
|
6677
6746
|
--session-active-bg: var(--bg-elevated);
|
|
6678
6747
|
--border: #cfcbc4;
|
|
6679
6748
|
--text-primary: #0a0a0a;
|
|
6680
|
-
--text-secondary: #
|
|
6681
|
-
--text-tertiary: #
|
|
6682
|
-
--text-muted: #
|
|
6683
|
-
--accent-
|
|
6749
|
+
--text-secondary: #2c2c2c;
|
|
6750
|
+
--text-tertiary: #565656;
|
|
6751
|
+
--text-muted: #767676;
|
|
6752
|
+
--accent-text: #a0501c;
|
|
6753
|
+
--accent-dim: rgba(184, 90, 32, 0.18);
|
|
6684
6754
|
--accent-glow: rgba(232, 111, 51, 0.5);
|
|
6685
6755
|
--gold: #a8842f;
|
|
6686
6756
|
--success: #1a8a5a;
|
package/public/sw.js
CHANGED
package/public/themes.css
CHANGED
|
@@ -38,6 +38,55 @@ body.light .theme-swatch-vitesse { --sw-bg: #f7f7f7; --sw-accent: #1c6b48; --sw-
|
|
|
38
38
|
.theme-swatch-synthwave { --sw-bg: #262335; --sw-accent: #ff7edb; --sw-ink: #ffffff; --sw-border: #495495; }
|
|
39
39
|
body.light .theme-swatch-synthwave { --sw-bg: #efecf6; --sw-accent: #c936a6; --sw-ink: #241b2f; --sw-border: #c5bcdd; }
|
|
40
40
|
|
|
41
|
+
/* Ember */
|
|
42
|
+
body:not([data-color-theme]):not(.light) {
|
|
43
|
+
--accent: #e86f33;
|
|
44
|
+
--accent-text: #f0a070;
|
|
45
|
+
--accent-dim: rgba(232,111,51,0.25);
|
|
46
|
+
--accent-glow: rgba(232, 111, 51, 0.55);
|
|
47
|
+
--bg-deep: #101114;
|
|
48
|
+
--bg-surface: #16181c;
|
|
49
|
+
--bg-elevated: #1e2025;
|
|
50
|
+
--bg-hover: #282a30;
|
|
51
|
+
--border: #363840;
|
|
52
|
+
--text-primary: #f0f1f3;
|
|
53
|
+
--text-secondary: #c2c4c9;
|
|
54
|
+
--text-tertiary: #9a9da5;
|
|
55
|
+
--text-muted: #7d808a;
|
|
56
|
+
}
|
|
57
|
+
body.light:not([data-color-theme]) {
|
|
58
|
+
--accent: #e86f33;
|
|
59
|
+
--accent-text: #a0501c;
|
|
60
|
+
--accent-dim: rgba(184,90,32,0.18);
|
|
61
|
+
--accent-glow: rgba(232, 111, 51, 0.5);
|
|
62
|
+
--bg-deep: #e8e6e3;
|
|
63
|
+
--bg-surface: #efede9;
|
|
64
|
+
--bg-elevated: #fbfaf9;
|
|
65
|
+
--bg-hover: #d2d0cc;
|
|
66
|
+
--border: #cfcbc4;
|
|
67
|
+
--text-primary: #0a0a0a;
|
|
68
|
+
--text-secondary: #2c2c2c;
|
|
69
|
+
--text-tertiary: #565656;
|
|
70
|
+
--text-muted: #767676;
|
|
71
|
+
}
|
|
72
|
+
@media (prefers-color-scheme: light) {
|
|
73
|
+
body:not(.dark-forced):not([data-color-theme]) {
|
|
74
|
+
--accent: #e86f33;
|
|
75
|
+
--accent-text: #a0501c;
|
|
76
|
+
--accent-dim: rgba(184,90,32,0.18);
|
|
77
|
+
--accent-glow: rgba(232, 111, 51, 0.5);
|
|
78
|
+
--bg-deep: #e8e6e3;
|
|
79
|
+
--bg-surface: #efede9;
|
|
80
|
+
--bg-elevated: #fbfaf9;
|
|
81
|
+
--bg-hover: #d2d0cc;
|
|
82
|
+
--border: #cfcbc4;
|
|
83
|
+
--text-primary: #0a0a0a;
|
|
84
|
+
--text-secondary: #2c2c2c;
|
|
85
|
+
--text-tertiary: #565656;
|
|
86
|
+
--text-muted: #767676;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
41
90
|
/* Gruvbox */
|
|
42
91
|
body[data-color-theme="gruvbox"]:not(.light) {
|
|
43
92
|
--accent: #fe8019;
|
package/server.js
CHANGED
|
@@ -15,6 +15,7 @@ const { createNetGuard } = require('./lib/net-guard');
|
|
|
15
15
|
const { isContained } = require('./lib/contain');
|
|
16
16
|
const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
|
|
17
17
|
const { fileUrlToPath } = require('./lib/file-url');
|
|
18
|
+
const { pluginStatus } = require('./lib/plugin-status');
|
|
18
19
|
|
|
19
20
|
const {
|
|
20
21
|
readRecentMessages: _readRecentMessagesUncached,
|
|
@@ -41,7 +42,7 @@ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = requ
|
|
|
41
42
|
const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
|
|
42
43
|
const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
|
|
43
44
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
44
|
-
const { createLinkedDocStore } = require('./lib/linked-docs');
|
|
45
|
+
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
45
46
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
46
47
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
47
48
|
const { countTaskDir } = require('./lib/task-counts');
|
|
@@ -93,8 +94,12 @@ const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
|
|
|
93
94
|
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
94
95
|
const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
|
|
95
96
|
const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
|
|
97
|
+
// os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
|
|
98
|
+
const TEMP_ROOT = (() => {
|
|
99
|
+
try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
|
|
100
|
+
})();
|
|
96
101
|
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
97
|
-
const SCRATCHPAD_ROOT = path.join(
|
|
102
|
+
const SCRATCHPAD_ROOT = path.join(TEMP_ROOT, 'claude');
|
|
98
103
|
|
|
99
104
|
// #endregion
|
|
100
105
|
|
|
@@ -1604,10 +1609,6 @@ app.get('/api/sessions/search', (req, res) => {
|
|
|
1604
1609
|
res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
|
|
1605
1610
|
});
|
|
1606
1611
|
|
|
1607
|
-
// os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
|
|
1608
|
-
const TEMP_ROOT = (() => {
|
|
1609
|
-
try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
|
|
1610
|
-
})();
|
|
1611
1612
|
function isTempPath(p) {
|
|
1612
1613
|
const rel = path.relative(TEMP_ROOT, p);
|
|
1613
1614
|
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
@@ -1842,8 +1843,9 @@ async function readCreatedPads(meta) {
|
|
|
1842
1843
|
// The project, not `meta.cwd`: cwd is wherever the session last stood, which drifts
|
|
1843
1844
|
// into subdirectories — a session that made a pad and then worked inside it reports
|
|
1844
1845
|
// a cwd below the pad, and a scan from there finds nothing above it.
|
|
1845
|
-
|
|
1846
|
-
const
|
|
1846
|
+
// The session's own scratchpad dir too: a pad made there sits outside the project.
|
|
1847
|
+
const roots = unresolved.length ? [meta.project || meta.cwd, getScratchpadDir(sessionId, meta)] : [];
|
|
1848
|
+
const scanned = (await Promise.all(roots.filter(Boolean).map((r) => findPads(r)))).flat();
|
|
1847
1849
|
const candidates = [...new Set([...reported, ...scanned.map((dir) => path.join(dir, SCRATCHPAD_MANIFEST))])];
|
|
1848
1850
|
const rows = await Promise.all(
|
|
1849
1851
|
candidates.map(async (file) => {
|
|
@@ -3160,7 +3162,7 @@ app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
|
|
|
3160
3162
|
// #region META_ROUTES
|
|
3161
3163
|
app.get('/api/version', (_req, res) => {
|
|
3162
3164
|
const pkg = require('./package.json');
|
|
3163
|
-
res.json({ version: pkg.version });
|
|
3165
|
+
res.json({ version: pkg.version, plugin: pluginStatus(CLAUDE_DIR) });
|
|
3164
3166
|
});
|
|
3165
3167
|
|
|
3166
3168
|
app.get('/api/config', (_req, res) => {
|
|
@@ -3696,16 +3698,19 @@ app.post('/api/preview', async (req, res) => {
|
|
|
3696
3698
|
// API: Link a file to a session's sidebar docs without opening the preview modal.
|
|
3697
3699
|
// Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
|
|
3698
3700
|
// opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
|
|
3701
|
+
// An http(s) URL is linked as it is. `open` comes from `preview-doc <url>`: a tab cannot
|
|
3702
|
+
// open a URL without a click, so the tab on screen offers an Open button.
|
|
3699
3703
|
app.post('/api/document/link', async (req, res) => {
|
|
3700
3704
|
try {
|
|
3701
|
-
const { path: filePath, sessionId, unlink } = req.body || {};
|
|
3705
|
+
const { path: filePath, sessionId, unlink, open } = req.body || {};
|
|
3702
3706
|
if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
|
|
3703
|
-
const
|
|
3707
|
+
const url = linkUrl(filePath);
|
|
3708
|
+
const abs = url || resolvePreviewPath(filePath);
|
|
3704
3709
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
3705
|
-
if (!unlink) await statFileTarget(abs);
|
|
3710
|
+
if (!unlink && !url) await statFileTarget(abs);
|
|
3706
3711
|
if (unlink) linkedDocs.unlink(sessionId, abs);
|
|
3707
3712
|
else linkedDocs.link(sessionId, abs);
|
|
3708
|
-
broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
|
|
3713
|
+
broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink, open: !!(url && open && !unlink) });
|
|
3709
3714
|
res.json({ success: true, path: abs, tabs: clients.size });
|
|
3710
3715
|
} catch (error) {
|
|
3711
3716
|
console.error('Error in /api/document/link:', error);
|
|
@@ -3722,7 +3727,8 @@ app.get('/api/document/links', (req, res) => {
|
|
|
3722
3727
|
// The browser's own unlink: it already updated its list, so this only drops the server
|
|
3723
3728
|
// copy that would bring the doc back on the next merge. No path clears the session.
|
|
3724
3729
|
app.delete('/api/document/links/:sessionId', (req, res) => {
|
|
3725
|
-
const
|
|
3730
|
+
const raw = String(req.query.path || '');
|
|
3731
|
+
const filePath = linkUrl(raw) || resolvePreviewPath(raw);
|
|
3726
3732
|
res.json({ removed: linkedDocs.unlink(req.params.sessionId, filePath) });
|
|
3727
3733
|
});
|
|
3728
3734
|
|