claude-code-kanban 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub-app.json +1 -0
- package/lib/terminal.js +54 -14
- package/package.json +1 -1
- package/public/app.js +155 -23
- package/public/index.html +1 -0
- package/public/style.css +61 -5
- package/public/terminal-frame.js +6 -4
- package/public/vendor/claude-hub-sdk.js +40 -6
package/hub-app.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"icon": "columns",
|
|
6
6
|
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
|
+
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s"] },
|
|
8
9
|
"provides": {
|
|
9
10
|
"projects": { "path": "/api/projects" },
|
|
10
11
|
"terminal": { "liveWork": "/api/terminals" }
|
package/lib/terminal.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// gates and hands the upgraded socket over, so no keystroke or output byte waits on
|
|
13
13
|
// cck's main thread.
|
|
14
14
|
|
|
15
|
+
const { spawnSync } = require('node:child_process');
|
|
15
16
|
const crypto = require('node:crypto');
|
|
16
17
|
const fs = require('node:fs');
|
|
17
18
|
const os = require('node:os');
|
|
@@ -147,10 +148,25 @@ function quoteArg(arg, family) {
|
|
|
147
148
|
return family === 'cmd' ? `"${arg}"` : `'${arg}'`;
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// Free text, unlike quoteArg's input. Inside single quotes pwsh only reads a quote mark (it
|
|
152
|
+
// counts the typographic ones too) and POSIX shells read nothing, so doubling or closing
|
|
153
|
+
// around the quote is enough. cmd has no quoting that holds % and ", so start() refuses it.
|
|
154
|
+
function quotePrompt(text, family) {
|
|
155
|
+
if (family === 'pwsh') return `'${text.replace(/['‘’‚‛]/g, '$&$&')}'`;
|
|
156
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The prompt goes first: -w takes an optional value, so a prompt after it would become the worktree name.
|
|
160
|
+
function claudeCommand(shell, claudeArgs, prompt) {
|
|
161
|
+
const family = shellFamily(shell);
|
|
162
|
+
const args = claudeArgs.map((a) => quoteArg(a, family));
|
|
163
|
+
return ['claude', ...(prompt ? [quotePrompt(prompt, family)] : []), ...args].join(' ');
|
|
164
|
+
}
|
|
165
|
+
|
|
150
166
|
function shellArgs(shell, claudeArgs) {
|
|
151
167
|
const family = shellFamily(shell);
|
|
152
168
|
if (!claudeArgs) return family === 'pwsh' ? ['-NoLogo'] : family === 'gitbash' ? ['--login', '-i'] : [];
|
|
153
|
-
const cmd =
|
|
169
|
+
const cmd = claudeCommand(shell, claudeArgs);
|
|
154
170
|
if (family === 'pwsh') return ['-NoLogo', '-NoExit', '-Command', cmd];
|
|
155
171
|
if (family === 'cmd') return ['/k', cmd];
|
|
156
172
|
if (family === 'gitbash') return ['--login', '-i', '-c', `${cmd}; exec bash --login -i`];
|
|
@@ -196,13 +212,17 @@ function parseNewSpec(msg) {
|
|
|
196
212
|
if (model && !MODELS.has(model)) return 'model';
|
|
197
213
|
const taskList = msg.taskList || null;
|
|
198
214
|
if (taskList && !(typeof taskList === 'string' && TASK_LIST_RE.test(taskList))) return 'task list id';
|
|
199
|
-
|
|
200
|
-
|
|
215
|
+
const edit = msg.edit === true;
|
|
216
|
+
// ESC so a prompt cannot carry terminal sequences; on a shell line (edit) every control
|
|
217
|
+
// character but tab and line breaks, since it could act as a key (Ctrl+C, Ctrl+U) instead of text.
|
|
218
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
219
|
+
const strip = edit ? /[\x00-\x08\x0b-\x1f\x7f]/g : /\x1b/g;
|
|
220
|
+
const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(strip, '').trim() : '';
|
|
201
221
|
if (prompt.length > MAX_PROMPT) return 'prompt';
|
|
202
222
|
const extraArgs = msg.extraArgs ?? [];
|
|
203
223
|
const bad = extraArgsError(extraArgs);
|
|
204
224
|
if (bad) return bad;
|
|
205
|
-
return { cwd: msg.cwd, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs };
|
|
225
|
+
return { cwd: msg.cwd, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit };
|
|
206
226
|
}
|
|
207
227
|
|
|
208
228
|
function samePath(a, b) {
|
|
@@ -442,19 +462,23 @@ function createTerminalService(o) {
|
|
|
442
462
|
return /\btrust\b/i.test(screenText(s.term));
|
|
443
463
|
}
|
|
444
464
|
|
|
445
|
-
|
|
446
|
-
|
|
465
|
+
// `submit` is claude's prompt: wait for its input box, then press Enter. Without it the text
|
|
466
|
+
// is the claude command typed at the shell prompt once the shell's startup output settles,
|
|
467
|
+
// and Enter is left to the user; bracketed paste keeps a prompt's line breaks from running
|
|
468
|
+
// the command early (checked in Git Bash and pwsh).
|
|
469
|
+
function queueText(s, raw, submit) {
|
|
470
|
+
let armed = !submit;
|
|
447
471
|
let timer = null;
|
|
448
|
-
const text = bracketed(
|
|
472
|
+
const text = bracketed(raw);
|
|
449
473
|
s.onOutput = (data) => {
|
|
450
474
|
if (!armed && data.includes('\x1b[?2004h')) armed = true;
|
|
451
475
|
if (!armed) return;
|
|
452
476
|
clearTimeout(timer);
|
|
453
477
|
timer = setTimeout(() => {
|
|
454
|
-
if (s.exited || onTrustScreen(s)) return;
|
|
478
|
+
if (s.exited || (submit && onTrustScreen(s))) return;
|
|
455
479
|
s.onOutput = null;
|
|
456
480
|
s.pty.write(text);
|
|
457
|
-
setTimeout(() => { if (!s.exited) s.pty.write('\r'); }, 100);
|
|
481
|
+
if (submit) setTimeout(() => { if (!s.exited) s.pty.write('\r'); }, 100);
|
|
458
482
|
}, PROMPT_QUIET_MS);
|
|
459
483
|
};
|
|
460
484
|
}
|
|
@@ -492,7 +516,10 @@ function createTerminalService(o) {
|
|
|
492
516
|
moved = true;
|
|
493
517
|
continue;
|
|
494
518
|
}
|
|
519
|
+
const had = s.claudePid;
|
|
495
520
|
const pid = inputPid(s, live);
|
|
521
|
+
// An edit terminal starts claude whenever the user presses Enter, after syncBoost stopped looking.
|
|
522
|
+
if (pid && pid !== had) syncBoost(s);
|
|
496
523
|
if (!pid || (live.some((l) => l.pid === pid) && pidAlive(pid))) continue;
|
|
497
524
|
s.claudeExitedAt = Date.now();
|
|
498
525
|
s.claudePid = null;
|
|
@@ -537,7 +564,8 @@ function createTerminalService(o) {
|
|
|
537
564
|
if (taskList) env.CLAUDE_CODE_TASK_LIST_ID = taskList;
|
|
538
565
|
// Git Bash's login profile cds to $HOME unless this is set.
|
|
539
566
|
if (shellFamily(shell) === 'gitbash') env.CHERE_INVOKING = '1';
|
|
540
|
-
const
|
|
567
|
+
const claudeArgs = claudeArgsFor(mode, id, spec);
|
|
568
|
+
const proc = pty.spawn(shell, shellArgs(shell, spec?.edit ? null : claudeArgs), {
|
|
541
569
|
name: 'xterm-256color',
|
|
542
570
|
cols,
|
|
543
571
|
rows,
|
|
@@ -549,9 +577,10 @@ function createTerminalService(o) {
|
|
|
549
577
|
term.loadAddon(serializer);
|
|
550
578
|
const s = {
|
|
551
579
|
id, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
552
|
-
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null,
|
|
580
|
+
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
|
|
553
581
|
};
|
|
554
|
-
if (
|
|
582
|
+
if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
|
|
583
|
+
else if (spec?.prompt) queueText(s, spec.prompt, true);
|
|
555
584
|
if (mode === 'pick') watchPick(s);
|
|
556
585
|
|
|
557
586
|
proc.onData((data) => {
|
|
@@ -697,6 +726,9 @@ function createTerminalService(o) {
|
|
|
697
726
|
if (mode === 'new') {
|
|
698
727
|
spec = parseNewSpec(msg);
|
|
699
728
|
if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
|
|
729
|
+
if (spec.edit && spec.prompt && shellFamily(shell) === 'cmd') {
|
|
730
|
+
return { code: 4002, status: 400, error: 'cmd cannot take a typed prompt; clear the prompt or set the terminal shell to pwsh or gitbash' };
|
|
731
|
+
}
|
|
700
732
|
}
|
|
701
733
|
const known = await o.resolveCwd(id);
|
|
702
734
|
let cwd = known;
|
|
@@ -751,7 +783,7 @@ function createTerminalService(o) {
|
|
|
751
783
|
function list() {
|
|
752
784
|
return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
|
|
753
785
|
id: s.id, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
|
|
754
|
-
name: s.name, worktree: s.worktree,
|
|
786
|
+
name: s.name, worktree: s.worktree, edit: s.edit,
|
|
755
787
|
}));
|
|
756
788
|
}
|
|
757
789
|
|
|
@@ -802,6 +834,8 @@ function createTerminalService(o) {
|
|
|
802
834
|
const s = sessions.get(id);
|
|
803
835
|
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell' || s.mode === 'fork') return false;
|
|
804
836
|
if (onTrustScreen(s)) return false;
|
|
837
|
+
// Until the user runs the typed command, the input box is the shell's command line.
|
|
838
|
+
if (s.edit && !s.claudePid) return false;
|
|
805
839
|
s.pty.write(bracketed(text));
|
|
806
840
|
return true;
|
|
807
841
|
}
|
|
@@ -810,6 +844,12 @@ function createTerminalService(o) {
|
|
|
810
844
|
shuttingDown = true;
|
|
811
845
|
clearTimeout(saveTimer);
|
|
812
846
|
clearInterval(exitTimer);
|
|
847
|
+
// On Windows pty.kill() returns before the tree is gone, and the host exits right after
|
|
848
|
+
// shutdown, so claude outlived it. One synchronous tree kill ends every session first.
|
|
849
|
+
const pids = [...sessions.values()].map((s) => s.pty.pid).filter(Boolean);
|
|
850
|
+
if (process.platform === 'win32' && pids.length) {
|
|
851
|
+
spawnSync('taskkill', ['/f', '/t', ...pids.flatMap((pid) => ['/pid', String(pid)])], { windowsHide: true, stdio: 'ignore' });
|
|
852
|
+
}
|
|
813
853
|
for (const s of sessions.values()) {
|
|
814
854
|
try { s.pty.kill(); } catch { /* already gone */ }
|
|
815
855
|
}
|
|
@@ -819,4 +859,4 @@ function createTerminalService(o) {
|
|
|
819
859
|
return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
|
|
820
860
|
}
|
|
821
861
|
|
|
822
|
-
module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
|
862
|
+
module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, claudeCommand, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -2494,8 +2494,13 @@ function clampModalZoom(v) {
|
|
|
2494
2494
|
return Math.min(MODAL_ZOOM_MAX, Math.max(MODAL_ZOOM_MIN, v));
|
|
2495
2495
|
}
|
|
2496
2496
|
|
|
2497
|
+
function modalZoomLabel() {
|
|
2498
|
+
return `${Math.round(modalZoom * 100)}%`;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2497
2501
|
function applyModalZoom() {
|
|
2498
2502
|
document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
|
|
2503
|
+
for (const el of document.querySelectorAll('.pane-zoom-val')) el.textContent = modalZoomLabel();
|
|
2499
2504
|
}
|
|
2500
2505
|
|
|
2501
2506
|
// delta 0 resets to 100%
|
|
@@ -2505,7 +2510,7 @@ function adjustModalZoom(delta) {
|
|
|
2505
2510
|
modalZoom = next;
|
|
2506
2511
|
store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
|
|
2507
2512
|
applyModalZoom();
|
|
2508
|
-
showToast(`Text ${
|
|
2513
|
+
showToast(`Text ${modalZoomLabel()}`);
|
|
2509
2514
|
}
|
|
2510
2515
|
|
|
2511
2516
|
// The markup decides what scales: `.modal-zoomable` marks a modal's reading
|
|
@@ -5917,9 +5922,9 @@ const SHORTCUT_TABS = [
|
|
|
5917
5922
|
{
|
|
5918
5923
|
title: 'Sessions',
|
|
5919
5924
|
rows: [
|
|
5920
|
-
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
|
|
5921
|
-
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
|
|
5922
|
-
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
|
|
5925
|
+
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, hubMod: true, label: 'New session' },
|
|
5926
|
+
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, hubMod: true, label: 'Resume session (claude -r)' },
|
|
5927
|
+
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, hubMod: true, label: 'Swap to previous session' },
|
|
5923
5928
|
],
|
|
5924
5929
|
},
|
|
5925
5930
|
],
|
|
@@ -5949,17 +5954,57 @@ const SHORTCUT_TABS = [
|
|
|
5949
5954
|
{ keys: ['M'], label: 'Jump to marketplace' },
|
|
5950
5955
|
{ keys: ['$'], label: 'Jump to cost' },
|
|
5951
5956
|
{ keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
|
|
5952
|
-
{
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
+
{
|
|
5958
|
+
keys: ['Ctrl', 'Alt', '←/→'],
|
|
5959
|
+
action: ['hub.prevApp', 'hub.nextApp'],
|
|
5960
|
+
combo: true,
|
|
5961
|
+
hubMod: true,
|
|
5962
|
+
label: 'Previous / next hub app',
|
|
5963
|
+
},
|
|
5964
|
+
{
|
|
5965
|
+
keys: ['Alt', '1…9'],
|
|
5966
|
+
action: 'hub.appByNumber',
|
|
5967
|
+
combo: true,
|
|
5968
|
+
hubMod: true,
|
|
5969
|
+
label: 'Jump to hub app by number',
|
|
5970
|
+
},
|
|
5971
|
+
{
|
|
5972
|
+
keys: ['Ctrl', 'Alt', 'P'],
|
|
5973
|
+
action: 'hub.projectPicker',
|
|
5974
|
+
combo: true,
|
|
5975
|
+
hubMod: true,
|
|
5976
|
+
label: 'Project picker',
|
|
5977
|
+
},
|
|
5978
|
+
{
|
|
5979
|
+
keys: ['Ctrl', 'Alt', 'W'],
|
|
5980
|
+
action: 'hub.configDirPicker',
|
|
5981
|
+
combo: true,
|
|
5982
|
+
hubMod: true,
|
|
5983
|
+
label: 'Config dir picker',
|
|
5984
|
+
},
|
|
5985
|
+
{ keys: ['Ctrl', 'Alt', 'A'], action: 'hub.appLauncher', combo: true, hubMod: true, label: 'App launcher' },
|
|
5957
5986
|
],
|
|
5958
5987
|
},
|
|
5959
5988
|
],
|
|
5960
5989
|
},
|
|
5961
5990
|
];
|
|
5962
5991
|
|
|
5992
|
+
// macOS names Control, Option and Shift by their symbols.
|
|
5993
|
+
const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platform || '');
|
|
5994
|
+
const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
|
|
5995
|
+
if (IS_MAC) document.getElementById('new-session-btn')?.setAttribute('title', 'New session (⌃⌥N)');
|
|
5996
|
+
|
|
5997
|
+
// The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
|
|
5998
|
+
// macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
|
|
5999
|
+
// row shows the keys the hub reports, which the user can change; row.keys until it answers.
|
|
6000
|
+
function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
|
|
6001
|
+
const live = row.action && keyLabel(row.action);
|
|
6002
|
+
if (live) return live;
|
|
6003
|
+
if (!mac) return row.keys;
|
|
6004
|
+
if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
|
|
6005
|
+
return row.keys.map((k) => MAC_KEYS[k] || k);
|
|
6006
|
+
}
|
|
6007
|
+
|
|
5963
6008
|
// Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
|
|
5964
6009
|
// them on shared row tracks (see .shortcuts in style.css).
|
|
5965
6010
|
function buildHelpShortcuts(groups) {
|
|
@@ -5975,9 +6020,10 @@ function buildHelpShortcuts(groups) {
|
|
|
5975
6020
|
for (let i = 0; i < Math.max(...sides.map(([g]) => g.rows.length)); i++) {
|
|
5976
6021
|
for (const [group, side] of sides) {
|
|
5977
6022
|
const row = group.rows[i];
|
|
5978
|
-
|
|
6023
|
+
const parts = row && helpKeys(row);
|
|
6024
|
+
if (!parts?.length) continue;
|
|
5979
6025
|
const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
|
|
5980
|
-
const keys =
|
|
6026
|
+
const keys = parts.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
|
|
5981
6027
|
const label = escapeHtml(row.label);
|
|
5982
6028
|
cells.push(`<dt class="${escapeHtml(side)}">${keys}</dt><dd class="${escapeHtml(side)}">${label}</dd>`);
|
|
5983
6029
|
}
|
|
@@ -5996,7 +6042,10 @@ const helpTabs = () => SHORTCUT_TABS.filter((t) => hub.inHub || !t.hub);
|
|
|
5996
6042
|
function filteredShortcutGroups(query) {
|
|
5997
6043
|
return helpTabs()
|
|
5998
6044
|
.flatMap((t) => t.groups)
|
|
5999
|
-
.map((g) => ({
|
|
6045
|
+
.map((g) => ({
|
|
6046
|
+
title: g.title,
|
|
6047
|
+
rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${helpKeys(r).join(' ')}`, query)),
|
|
6048
|
+
}))
|
|
6000
6049
|
.filter((g) => g.rows.length);
|
|
6001
6050
|
}
|
|
6002
6051
|
|
|
@@ -7630,9 +7679,10 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
|
|
|
7630
7679
|
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta']
|
|
7631
7680
|
.filter(Boolean)
|
|
7632
7681
|
.join('+');
|
|
7633
|
-
return [
|
|
7634
|
-
|
|
7635
|
-
|
|
7682
|
+
return [
|
|
7683
|
+
e.code && !e.getModifierState?.('AltGraph') && `${mods}|c:${e.code}`,
|
|
7684
|
+
typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`,
|
|
7685
|
+
].filter(Boolean);
|
|
7636
7686
|
};
|
|
7637
7687
|
let claims = new Set();
|
|
7638
7688
|
let forward = new Set();
|
|
@@ -12171,9 +12221,14 @@ function togglePaneSplit() {
|
|
|
12171
12221
|
return true;
|
|
12172
12222
|
}
|
|
12173
12223
|
|
|
12224
|
+
function shownPaneSplit() {
|
|
12225
|
+
const sid = paneSessionId();
|
|
12226
|
+
return sid && !wantsTerminal() && getPaneSplit(sid);
|
|
12227
|
+
}
|
|
12228
|
+
|
|
12174
12229
|
function focusPaneSide(side) {
|
|
12175
12230
|
const sid = paneSessionId();
|
|
12176
|
-
const split =
|
|
12231
|
+
const split = shownPaneSplit();
|
|
12177
12232
|
if (!split) return false;
|
|
12178
12233
|
if (getActivePaneId(sid) !== split[side]) {
|
|
12179
12234
|
setActivePaneId(sid, split[side]);
|
|
@@ -12363,6 +12418,20 @@ function updatePaneFades() {
|
|
|
12363
12418
|
paneTabs.classList.toggle('fade-r', scrollLeft + clientWidth < scrollWidth - 1);
|
|
12364
12419
|
}
|
|
12365
12420
|
|
|
12421
|
+
// A URL pane's page is on another origin, so its key presses never reach the board: the zoom
|
|
12422
|
+
// keys zoom the whole window and the pane keys do nothing. The bar zooms without keys and
|
|
12423
|
+
// says when the page has the keyboard; a click on it gives the keys back.
|
|
12424
|
+
function paneUrlBarHtml() {
|
|
12425
|
+
return `<div class="pane-url-bar"><button class="icon-btn pane-keys" title="The page has keyboard focus. Click to turn the board shortcuts back on">Board shortcuts off · click to turn on</button><button class="icon-btn" data-pane-zoom="-0.1" title="Smaller" aria-label="Smaller">−</button><button class="icon-btn pane-zoom-val" data-pane-zoom="0" title="Reset size">${modalZoomLabel()}</button><button class="icon-btn" data-pane-zoom="0.1" title="Larger" aria-label="Larger">+</button></div>`;
|
|
12426
|
+
}
|
|
12427
|
+
|
|
12428
|
+
function markPaneFrameFocus() {
|
|
12429
|
+
const el = document.activeElement;
|
|
12430
|
+
const view = el?.classList.contains('pane-url-frame') ? el.parentElement : null;
|
|
12431
|
+
for (const v of paneViews.querySelectorAll('.pane-view.frame-focus')) v.classList.remove('frame-focus');
|
|
12432
|
+
view?.classList.add('frame-focus');
|
|
12433
|
+
}
|
|
12434
|
+
|
|
12366
12435
|
function paneCardHtml(title, text, button) {
|
|
12367
12436
|
return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p><button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button></div></div>`;
|
|
12368
12437
|
}
|
|
@@ -12412,12 +12481,13 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
12412
12481
|
}
|
|
12413
12482
|
if (pane.kind === 'url') {
|
|
12414
12483
|
const frame = document.createElement('iframe');
|
|
12415
|
-
frame.className = 'pane-frame modal-zoomable';
|
|
12484
|
+
frame.className = 'pane-frame pane-url-frame modal-zoomable';
|
|
12416
12485
|
frame.setAttribute('sandbox', PANE_URL_SANDBOX);
|
|
12417
12486
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
12418
12487
|
frame.title = pane.title;
|
|
12419
12488
|
frame.src = pane.target;
|
|
12420
12489
|
view.appendChild(frame);
|
|
12490
|
+
view.insertAdjacentHTML('beforeend', paneUrlBarHtml());
|
|
12421
12491
|
// Framed at once; a refusal found by the probe swaps the frame for the card.
|
|
12422
12492
|
if ((await probePaneFraming(sid, pane)) === false) {
|
|
12423
12493
|
frame.remove();
|
|
@@ -12907,8 +12977,42 @@ function focusSideOf(el) {
|
|
|
12907
12977
|
// That blur is also the only sign that a click in a split moved into a pane's frame.
|
|
12908
12978
|
window.addEventListener('blur', () => {
|
|
12909
12979
|
closePaneMenu();
|
|
12910
|
-
setTimeout(() =>
|
|
12980
|
+
setTimeout(() => {
|
|
12981
|
+
syncPaneFrameFocus();
|
|
12982
|
+
watchPaneFrameFocus();
|
|
12983
|
+
});
|
|
12984
|
+
});
|
|
12985
|
+
window.addEventListener('focus', () => {
|
|
12986
|
+
clearInterval(paneFrameFocusPoll);
|
|
12987
|
+
markPaneFrameFocus();
|
|
12911
12988
|
});
|
|
12989
|
+
|
|
12990
|
+
function syncPaneFrameFocus() {
|
|
12991
|
+
focusSideOf(document.activeElement?.closest?.('.pane-view.on'));
|
|
12992
|
+
markPaneFrameFocus();
|
|
12993
|
+
}
|
|
12994
|
+
|
|
12995
|
+
function splitPaneFrameFocused() {
|
|
12996
|
+
const el = document.activeElement;
|
|
12997
|
+
return el?.tagName === 'IFRAME' && paneViews.contains(el) && !!shownPaneSplit();
|
|
12998
|
+
}
|
|
12999
|
+
|
|
13000
|
+
// A click from one frame into another fires no event in this document, so while a split's frame
|
|
13001
|
+
// has focus the board checks which one it is: one pointer compare four times a second.
|
|
13002
|
+
let paneFrameFocusPoll = 0;
|
|
13003
|
+
function watchPaneFrameFocus() {
|
|
13004
|
+
clearInterval(paneFrameFocusPoll);
|
|
13005
|
+
if (!splitPaneFrameFocused()) return;
|
|
13006
|
+
let last = document.activeElement;
|
|
13007
|
+
paneFrameFocusPoll = setInterval(() => {
|
|
13008
|
+
const el = document.activeElement;
|
|
13009
|
+
if (!splitPaneFrameFocused()) clearInterval(paneFrameFocusPoll);
|
|
13010
|
+
else if (el !== last) {
|
|
13011
|
+
last = el;
|
|
13012
|
+
syncPaneFrameFocus();
|
|
13013
|
+
}
|
|
13014
|
+
}, 250);
|
|
13015
|
+
}
|
|
12912
13016
|
paneSplitBox.addEventListener(
|
|
12913
13017
|
'pointerdown',
|
|
12914
13018
|
(e) => focusSideOf(paneLog.contains(e.target) ? paneBoard : e.target.closest('.side-l, .side-r')),
|
|
@@ -13024,6 +13128,8 @@ function applySplitRatio() {
|
|
|
13024
13128
|
|
|
13025
13129
|
paneViews.addEventListener('click', (e) => {
|
|
13026
13130
|
if (e.target.closest('[data-pane-open]')) openPaneExternally();
|
|
13131
|
+
const zoom = e.target.closest('[data-pane-zoom]');
|
|
13132
|
+
if (zoom) adjustModalZoom(Number(zoom.dataset.paneZoom));
|
|
13027
13133
|
});
|
|
13028
13134
|
bindPreviewRelativeLinks(paneViews, (a) => a.closest('.pane-doc')?.dataset.path);
|
|
13029
13135
|
paneTabs.addEventListener('scroll', updatePaneFades, { passive: true });
|
|
@@ -13193,6 +13299,12 @@ function terminalPaneFocused() {
|
|
|
13193
13299
|
return document.getElementById('terminal-pane').contains(document.activeElement);
|
|
13194
13300
|
}
|
|
13195
13301
|
|
|
13302
|
+
// Kanban's own session keys (new, resume, swap) use the hub's modifier, so the hub leaves them alone.
|
|
13303
|
+
// AltGr reaches the page as Ctrl+Alt; when it types a character (Polish AltGr+S is 'ś'), the press is text.
|
|
13304
|
+
function hubModDown(e) {
|
|
13305
|
+
return e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey && !e.getModifierState?.('AltGraph');
|
|
13306
|
+
}
|
|
13307
|
+
|
|
13196
13308
|
function inPageField(e) {
|
|
13197
13309
|
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
13198
13310
|
return !!field && !document.getElementById('terminal-pane').contains(field);
|
|
@@ -13200,7 +13312,7 @@ function inPageField(e) {
|
|
|
13200
13312
|
|
|
13201
13313
|
// `probe` asks which keys the terminal frame hands back, and the frame sees keys only while it has focus.
|
|
13202
13314
|
function terminalShortcut(e, probe = false) {
|
|
13203
|
-
const ctrlAlt = e
|
|
13315
|
+
const ctrlAlt = hubModDown(e);
|
|
13204
13316
|
const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
|
|
13205
13317
|
// Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
|
|
13206
13318
|
const zoom = e.key === '_' ? undefined : zoomDelta(e);
|
|
@@ -13808,6 +13920,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
13808
13920
|
worktree: spec.worktree,
|
|
13809
13921
|
model: spec.model,
|
|
13810
13922
|
prompt: spec.prompt,
|
|
13923
|
+
edit: spec.edit,
|
|
13811
13924
|
}),
|
|
13812
13925
|
},
|
|
13813
13926
|
});
|
|
@@ -14097,12 +14210,14 @@ const newSpecs = new Map();
|
|
|
14097
14210
|
const dispatchSpecs = new Map();
|
|
14098
14211
|
const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
|
|
14099
14212
|
const NEW_SESSION_STAY_KEY = 'new-session-stay';
|
|
14213
|
+
const NEW_SESSION_EDIT_KEY = 'new-session-edit';
|
|
14100
14214
|
|
|
14101
14215
|
const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
|
|
14102
14216
|
const PLACEHOLDER_HINTS = {
|
|
14103
14217
|
pick: 'pick a session to resume',
|
|
14104
14218
|
dispatch: 'starting',
|
|
14105
14219
|
new: 'waiting for your first message',
|
|
14220
|
+
edit: 'press Enter in the terminal to start',
|
|
14106
14221
|
};
|
|
14107
14222
|
|
|
14108
14223
|
function placeholderSession(id, spec) {
|
|
@@ -14110,6 +14225,7 @@ function placeholderSession(id, spec) {
|
|
|
14110
14225
|
id,
|
|
14111
14226
|
placeholder: true,
|
|
14112
14227
|
mode: spec.mode,
|
|
14228
|
+
edit: !!spec.edit,
|
|
14113
14229
|
name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
14114
14230
|
dispatchGroup: spec.group || undefined,
|
|
14115
14231
|
dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
|
|
@@ -14189,7 +14305,7 @@ function renderPlaceholderCard(session) {
|
|
|
14189
14305
|
<button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
|
|
14190
14306
|
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
14191
14307
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
14192
|
-
<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.mode] || PLACEHOLDER_HINTS.new}</div>
|
|
14308
|
+
<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>
|
|
14193
14309
|
</button>
|
|
14194
14310
|
`;
|
|
14195
14311
|
}
|
|
@@ -14203,7 +14319,14 @@ async function restorePendingSessions() {
|
|
|
14203
14319
|
try {
|
|
14204
14320
|
for (const t of await terminals) {
|
|
14205
14321
|
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
14206
|
-
newSpecs.set(t.id, {
|
|
14322
|
+
newSpecs.set(t.id, {
|
|
14323
|
+
cwd: t.cwd,
|
|
14324
|
+
name: t.name,
|
|
14325
|
+
worktree: t.worktree,
|
|
14326
|
+
mode: t.mode,
|
|
14327
|
+
edit: t.edit,
|
|
14328
|
+
startedAt: t.startedAt,
|
|
14329
|
+
});
|
|
14207
14330
|
}
|
|
14208
14331
|
} catch (_) {}
|
|
14209
14332
|
}
|
|
@@ -14241,6 +14364,7 @@ function openNewSession(folder, resume = false) {
|
|
|
14241
14364
|
document.getElementById('ns-wt').checked = false;
|
|
14242
14365
|
document.getElementById('ns-model').value = '';
|
|
14243
14366
|
document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
|
|
14367
|
+
document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
|
|
14244
14368
|
setNewSessionError('');
|
|
14245
14369
|
const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
|
|
14246
14370
|
setNewSessionFolder(folder || current || '');
|
|
@@ -14340,12 +14464,15 @@ function folderListOpen() {
|
|
|
14340
14464
|
function newSessionValues() {
|
|
14341
14465
|
const wtOn = document.getElementById('ns-wt').checked;
|
|
14342
14466
|
const wtName = document.getElementById('ns-wt-name').value.trim();
|
|
14467
|
+
const edit = !ns.resume && document.getElementById('ns-edit').checked;
|
|
14343
14468
|
return {
|
|
14344
14469
|
cwd: ns.folder,
|
|
14345
14470
|
name: document.getElementById('ns-name').value.trim() || null,
|
|
14346
14471
|
worktree: wtOn ? wtName || true : false,
|
|
14347
14472
|
model: document.getElementById('ns-model').value || null,
|
|
14348
14473
|
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
14474
|
+
edit,
|
|
14475
|
+
stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
|
|
14349
14476
|
};
|
|
14350
14477
|
}
|
|
14351
14478
|
|
|
@@ -14371,11 +14498,12 @@ function renderNewSessionForm() {
|
|
|
14371
14498
|
const hint = document.getElementById('ns-wt-hint');
|
|
14372
14499
|
hint.hidden = !wtOn;
|
|
14373
14500
|
hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
|
|
14501
|
+
document.getElementById('ns-stay').disabled = v.edit;
|
|
14374
14502
|
const problem = newSessionProblem(v);
|
|
14375
14503
|
setNewSessionError(problem);
|
|
14376
14504
|
const start = document.getElementById('ns-start');
|
|
14377
14505
|
start.disabled = !v.cwd || !!problem || ns.busy;
|
|
14378
|
-
const verb = ns.resume ? 'Resume' : 'Start';
|
|
14506
|
+
const verb = ns.resume ? 'Resume' : v.edit ? 'Open' : 'Start';
|
|
14379
14507
|
start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
|
|
14380
14508
|
}
|
|
14381
14509
|
|
|
@@ -14424,7 +14552,7 @@ function startNewSession() {
|
|
|
14424
14552
|
const id = crypto.randomUUID();
|
|
14425
14553
|
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
14426
14554
|
setTerminalMode(id, true);
|
|
14427
|
-
if (
|
|
14555
|
+
if (v.stay) return startNewSessionInBackground(id, v);
|
|
14428
14556
|
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
14429
14557
|
closeNewSession();
|
|
14430
14558
|
fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
|
|
@@ -14528,6 +14656,10 @@ function initNewSession() {
|
|
|
14528
14656
|
document
|
|
14529
14657
|
.getElementById('ns-stay')
|
|
14530
14658
|
.addEventListener('change', (e) => store.setItem(NEW_SESSION_STAY_KEY, String(e.target.checked)));
|
|
14659
|
+
document.getElementById('ns-edit').addEventListener('change', (e) => {
|
|
14660
|
+
store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
|
|
14661
|
+
renderNewSessionForm();
|
|
14662
|
+
});
|
|
14531
14663
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
|
|
14532
14664
|
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
14533
14665
|
}
|
package/public/index.html
CHANGED
|
@@ -849,6 +849,7 @@
|
|
|
849
849
|
</div>
|
|
850
850
|
<div class="modal-footer ns-footer">
|
|
851
851
|
<label class="ns-check ns-stay" title="Start in the background and keep the current view"><input type="checkbox" id="ns-stay"> Stay here</label>
|
|
852
|
+
<label class="ns-check ns-edit" title="Open a shell in the folder with the claude command typed in. Press Enter to run it."><input type="checkbox" id="ns-edit"> Edit before run</label>
|
|
852
853
|
<button type="button" class="btn btn-secondary" onclick="closeNewSession()">Cancel</button>
|
|
853
854
|
<button type="button" id="ns-start" class="btn btn-primary" onclick="startNewSession()" disabled>Start</button>
|
|
854
855
|
</div>
|
package/public/style.css
CHANGED
|
@@ -3909,6 +3909,49 @@ code.language-tree .hljs-title {
|
|
|
3909
3909
|
background: #fff;
|
|
3910
3910
|
}
|
|
3911
3911
|
|
|
3912
|
+
.pane-url-bar {
|
|
3913
|
+
position: absolute;
|
|
3914
|
+
right: 12px;
|
|
3915
|
+
bottom: 12px;
|
|
3916
|
+
display: flex;
|
|
3917
|
+
align-items: center;
|
|
3918
|
+
gap: 2px;
|
|
3919
|
+
padding: 3px;
|
|
3920
|
+
border: 1px solid var(--border);
|
|
3921
|
+
border-radius: 8px;
|
|
3922
|
+
background: var(--bg-elevated);
|
|
3923
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
|
3924
|
+
font-family: var(--font-mono);
|
|
3925
|
+
font-size: 12px;
|
|
3926
|
+
opacity: 0;
|
|
3927
|
+
pointer-events: none;
|
|
3928
|
+
transition: opacity 0.15s;
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
.pane-view:hover > .pane-url-bar,
|
|
3932
|
+
.pane-view.frame-focus > .pane-url-bar {
|
|
3933
|
+
opacity: 1;
|
|
3934
|
+
pointer-events: auto;
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
.pane-url-bar .icon-btn {
|
|
3938
|
+
width: auto;
|
|
3939
|
+
min-width: 26px;
|
|
3940
|
+
height: 24px;
|
|
3941
|
+
padding: 0 6px;
|
|
3942
|
+
border-color: transparent;
|
|
3943
|
+
font: inherit;
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
.pane-url-bar .pane-keys {
|
|
3947
|
+
display: none;
|
|
3948
|
+
color: var(--accent);
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
.pane-view.frame-focus .pane-keys {
|
|
3952
|
+
display: flex;
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3912
3955
|
.pane-doc {
|
|
3913
3956
|
flex: 1;
|
|
3914
3957
|
min-height: 0;
|
|
@@ -7333,7 +7376,7 @@ select.form-input option:checked {
|
|
|
7333
7376
|
|
|
7334
7377
|
/* #region NEW_SESSION */
|
|
7335
7378
|
.modal.new-session-modal {
|
|
7336
|
-
width:
|
|
7379
|
+
width: 640px;
|
|
7337
7380
|
max-width: calc(100vw - 32px);
|
|
7338
7381
|
overflow: visible;
|
|
7339
7382
|
}
|
|
@@ -7462,7 +7505,8 @@ select.form-input option:checked {
|
|
|
7462
7505
|
accent-color: var(--accent);
|
|
7463
7506
|
}
|
|
7464
7507
|
|
|
7465
|
-
.ns-inline .form-input:disabled
|
|
7508
|
+
.ns-inline .form-input:disabled,
|
|
7509
|
+
.ns-check:has(input:disabled) {
|
|
7466
7510
|
opacity: 0.5;
|
|
7467
7511
|
cursor: not-allowed;
|
|
7468
7512
|
}
|
|
@@ -7545,15 +7589,27 @@ select.form-input option:checked {
|
|
|
7545
7589
|
border-top: 1px solid var(--border);
|
|
7546
7590
|
}
|
|
7547
7591
|
|
|
7548
|
-
.ns-stay
|
|
7549
|
-
|
|
7592
|
+
.ns-stay,
|
|
7593
|
+
.ns-edit {
|
|
7550
7594
|
color: var(--text-tertiary);
|
|
7551
7595
|
}
|
|
7552
7596
|
|
|
7553
|
-
.
|
|
7597
|
+
.ns-edit {
|
|
7598
|
+
margin-right: auto;
|
|
7599
|
+
}
|
|
7600
|
+
|
|
7601
|
+
.new-session-modal.ns-resume .ns-stay,
|
|
7602
|
+
.new-session-modal.ns-resume .ns-edit {
|
|
7554
7603
|
visibility: hidden;
|
|
7555
7604
|
}
|
|
7556
7605
|
|
|
7606
|
+
.ns-footer .btn-primary {
|
|
7607
|
+
min-width: 0;
|
|
7608
|
+
overflow: hidden;
|
|
7609
|
+
text-overflow: ellipsis;
|
|
7610
|
+
white-space: nowrap;
|
|
7611
|
+
}
|
|
7612
|
+
|
|
7557
7613
|
.ns-footer .btn-primary:disabled {
|
|
7558
7614
|
opacity: 0.5;
|
|
7559
7615
|
cursor: not-allowed;
|
package/public/terminal-frame.js
CHANGED
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
const modsOf = (e) =>
|
|
12
12
|
[e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'].filter(Boolean).join('+');
|
|
13
13
|
|
|
14
|
-
// A claim names a key by its physical code, or by its character when the layout moves it.
|
|
14
|
+
// A claim names a key by its physical code, or by its character when the layout moves it. A press
|
|
15
|
+
// that AltGr turns into a character (Polish AltGr+S is 'ś') is matched by its character only.
|
|
15
16
|
function keySigs(e) {
|
|
16
17
|
const mods = modsOf(e);
|
|
17
|
-
return [
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
return [
|
|
19
|
+
e.code && !e.getModifierState?.('AltGraph') && `${mods}|c:${e.code}`,
|
|
20
|
+
typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`,
|
|
21
|
+
].filter(Boolean);
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
const claimSig = (c) => keySigs(c)[0];
|
|
@@ -1,18 +1,51 @@
|
|
|
1
|
-
// claude-hub-sdk 1.
|
|
2
|
-
// Claude Code Hub SDK
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// claude-hub-sdk 1.4.0 (sha256 7d559a8888ce). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
|
|
2
|
+
// Claude Code Hub SDK: the one rule that names a key press, shared by the hub page, client.js and
|
|
3
|
+
// stub.js. The hub serves it to its page as /sdk/keys.js; mount() and sdk:sync put it in front of
|
|
4
|
+
// client.js and stub.js, so an app still loads one file.
|
|
5
|
+
// var, not const: the hub page and an app page each load it as a classic script, and a second
|
|
6
|
+
// const declaration in the same page would throw.
|
|
7
|
+
var ClaudeHubKeys = (() => {
|
|
5
8
|
'use strict';
|
|
6
9
|
|
|
7
|
-
//
|
|
10
|
+
// Modifiers in ctrl, alt, shift, meta order, joined by '+' to the key. macOS composes
|
|
11
|
+
// Option+<key> into a character (Option+1 is '¡', Option+P is 'π') and holding Control does not
|
|
12
|
+
// undo it, so e.key alone cannot name these presses there. e.code is the physical key, which is
|
|
13
|
+
// wrong for non-US layouts, hence only as a fallback. No fallback under AltGraph: Chromium sets it
|
|
14
|
+
// when AltGr types a character, and that press is text. Takes a real KeyboardEvent or a forwarded
|
|
15
|
+
// {key, code} payload; a payload without code degrades to key. A payload needs no AltGraph: the
|
|
16
|
+
// SDK forwards only a press whose key already names a bound combo.
|
|
17
|
+
// Self-contained on purpose: cck puts its source text into a sandboxed frame.
|
|
8
18
|
function comboOf(e) {
|
|
9
19
|
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
10
|
-
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
20
|
+
const m = !e.getModifierState?.('AltGraph') && /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
11
21
|
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
12
22
|
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
13
23
|
return [...mods, key].filter(Boolean).join('+');
|
|
14
24
|
}
|
|
15
25
|
|
|
26
|
+
// The keys of a combo as a help row shows them: ['Ctrl', 'Alt', 'P'], or ['⌃', '⌥', 'P'] on macOS.
|
|
27
|
+
// The {n} of a numbered combo reads 1…9.
|
|
28
|
+
function keyParts(combo, mac) {
|
|
29
|
+
const mods = mac
|
|
30
|
+
? { ctrl: '⌃', alt: '⌥', shift: '⇧', meta: '⌘' }
|
|
31
|
+
: { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Win' };
|
|
32
|
+
const named = { '{n}': '1…9', ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓' };
|
|
33
|
+
return combo.split('+').map((k) => mods[k] ?? named[k] ?? (k.length === 1 ? k.toUpperCase() : k));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { comboOf, keyParts };
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
if (typeof module === 'object' && module.exports) module.exports = ClaudeHubKeys;
|
|
40
|
+
|
|
41
|
+
// Claude Code Hub SDK stub: what an app serves when no hub runs it. Under a hub, the hub hands the
|
|
42
|
+
// app its real SDK (client.js) in place of this file. The API must match client.js; a test checks it.
|
|
43
|
+
((root) => {
|
|
44
|
+
'use strict';
|
|
45
|
+
|
|
46
|
+
// keys.js comes first in the synced file. Required as a module, stub.js loads it itself.
|
|
47
|
+
const { comboOf } = typeof ClaudeHubKeys === 'object' ? ClaudeHubKeys : require('./keys');
|
|
48
|
+
|
|
16
49
|
function createClaudeHub(win) {
|
|
17
50
|
let hub = null;
|
|
18
51
|
const off = () => {};
|
|
@@ -43,6 +76,7 @@
|
|
|
43
76
|
can: (action) => !!standaloneFn(action),
|
|
44
77
|
forwards: () => false,
|
|
45
78
|
forwardCombos: () => [],
|
|
79
|
+
keyLabel: () => null,
|
|
46
80
|
closeGuard() {},
|
|
47
81
|
openExternal(url) {
|
|
48
82
|
win.open(url, '_blank', 'noopener');
|