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 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 = ['claude', ...claudeArgs].map((a) => quoteArg(a, family)).join(' ');
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
- // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
200
- const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
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
- function queuePrompt(s, prompt) {
446
- let armed = false;
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(prompt);
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 proc = pty.spawn(shell, shellArgs(shell, claudeArgsFor(mode, id, spec)), {
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 (spec?.prompt) queuePrompt(s, spec.prompt);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
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 ${Math.round(modalZoom * 100)}%`);
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
- { keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
5953
- { keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
5954
- { keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
5955
- { keys: ['Ctrl', 'Alt', 'W'], combo: true, label: 'Config dir picker' },
5956
- { keys: ['Ctrl', 'Alt', 'A'], combo: true, label: 'App launcher' },
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
- if (!row) continue;
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 = row.keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
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) => ({ title: g.title, rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${r.keys.join(' ')}`, query)) }))
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 [e.code && `${mods}|c:${e.code}`, typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`].filter(
7634
- Boolean,
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 = sid && !wantsTerminal() && getPaneSplit(sid);
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(() => focusSideOf(document.activeElement?.closest?.('.pane-view.on')));
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.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
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, { cwd: t.cwd, name: t.name, worktree: t.worktree, mode: t.mode, startedAt: t.startedAt });
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 (!ns.resume && document.getElementById('ns-stay').checked) return startNewSessionInBackground(id, v);
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: 520px;
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
- margin-right: auto;
7592
+ .ns-stay,
7593
+ .ns-edit {
7550
7594
  color: var(--text-tertiary);
7551
7595
  }
7552
7596
 
7553
- .new-session-modal.ns-resume .ns-stay {
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;
@@ -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 [e.code && `${mods}|c:${e.code}`, typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`].filter(
18
- Boolean,
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.0 (sha256 62c7d9f477b2). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
2
- // Claude Code Hub SDK stub: what an app serves when no hub runs it. Under a hub, the hub hands the
3
- // app its real SDK (client.js) in place of this file. The API must match client.js; a test checks it.
4
- ((root) => {
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
- // A copy of client.js comboOf(); a test checks they match.
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');