claude-code-kanban 7.23.0 → 7.24.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 +20 -1
- package/lib/terminal-client.js +8 -3
- package/lib/terminal-host.js +2 -1
- package/lib/terminal.js +47 -14
- package/package.json +2 -1
- package/public/app.js +453 -28
- package/public/index.html +43 -0
- package/public/style.css +135 -2
- package/public/vendor/claude-hub-sdk.js +2 -2
- package/server.js +43 -13
package/hub-app.json
CHANGED
|
@@ -7,7 +7,26 @@
|
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
8
|
"actions": {
|
|
9
9
|
"handles": {
|
|
10
|
-
"session.open": { "params": { "session": "string" }, "mode": "message" }
|
|
10
|
+
"session.open": { "params": { "session": "string" }, "mode": "message" },
|
|
11
|
+
"session.find": { "params": { "name": "string?" }, "mode": "message" },
|
|
12
|
+
"session.paste": { "params": { "text": "string", "submit": "string?", "session": "string?" }, "mode": "message" },
|
|
13
|
+
"task.add": { "params": { "subject": "string?", "description": "string?" }, "mode": "message" },
|
|
14
|
+
"task.live": { "params": { "subject": "string?", "description": "string?", "pending": "string?", "final": "string?", "submit": "string?", "cancel": "string?" }, "mode": "message" },
|
|
15
|
+
"session.live": { "params": { "text": "string", "final": "string?", "submit": "string?", "cancel": "string?", "session": "string?" }, "mode": "message" },
|
|
16
|
+
"session.new": {
|
|
17
|
+
"params": {
|
|
18
|
+
"cwd": "string?",
|
|
19
|
+
"name": "string?",
|
|
20
|
+
"prompt": "string?",
|
|
21
|
+
"worktree": "string?",
|
|
22
|
+
"model": "string?",
|
|
23
|
+
"effort": "string?",
|
|
24
|
+
"args": "string?",
|
|
25
|
+
"edit": "string?",
|
|
26
|
+
"stay": "string?"
|
|
27
|
+
},
|
|
28
|
+
"mode": "message"
|
|
29
|
+
}
|
|
11
30
|
}
|
|
12
31
|
},
|
|
13
32
|
"settings": [
|
package/lib/terminal-client.js
CHANGED
|
@@ -191,9 +191,14 @@ function createTerminalClient(o) {
|
|
|
191
191
|
return call('end', id, providedToken);
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
async function paste(id, text) {
|
|
194
|
+
async function paste(id, text, submit = false) {
|
|
195
195
|
if (!isRunning(id)) return false;
|
|
196
|
-
return call('paste', id, text).catch(() => false);
|
|
196
|
+
return call('paste', id, text, submit).catch(() => false);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function type(id, from, text, submit = false) {
|
|
200
|
+
if (!isRunning(id)) return false;
|
|
201
|
+
return call('type', id, from, text, submit).catch(() => false);
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
function restore() {
|
|
@@ -230,7 +235,7 @@ function createTerminalClient(o) {
|
|
|
230
235
|
else spawn();
|
|
231
236
|
|
|
232
237
|
return {
|
|
233
|
-
token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, hasTerminal, sessionOfTerminal, startNew, end, paste, restore,
|
|
238
|
+
token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, hasTerminal, sessionOfTerminal, startNew, end, paste, type, restore,
|
|
234
239
|
setServerUrl, sessions, stats, shutdown, started,
|
|
235
240
|
ids: () => [...ids],
|
|
236
241
|
awaitingTrust: () => [...awaitingTrust],
|
package/lib/terminal-host.js
CHANGED
|
@@ -69,7 +69,8 @@ function init(msg) {
|
|
|
69
69
|
const CALLS = {
|
|
70
70
|
startNew: (msg, extraEnv) => service.startNew(msg, extraEnv),
|
|
71
71
|
end: (id, token) => service.end(id, token),
|
|
72
|
-
paste: (id, text) => service.paste(id, text),
|
|
72
|
+
paste: (id, text, submit) => service.paste(id, text, submit),
|
|
73
|
+
type: (id, from, text, submit) => service.type(id, from, text, submit),
|
|
73
74
|
restore: () => service.restore(),
|
|
74
75
|
sessions: () => service.list(),
|
|
75
76
|
claudePids: () => service.claudePids(),
|
package/lib/terminal.js
CHANGED
|
@@ -49,11 +49,14 @@ const OWNED_FLAGS = new Set([
|
|
|
49
49
|
const MAX_PROMPT = 32 * 1024;
|
|
50
50
|
const PROMPT_QUIET_MS = 400;
|
|
51
51
|
const SUBMIT_POLL_MS = 1000;
|
|
52
|
+
const SUBMIT_AFTER_PASTE_MS = 200;
|
|
52
53
|
const SUBMIT_CHECKS = 5;
|
|
53
54
|
const SUBMIT_RETRIES = 2;
|
|
54
55
|
// The answer lines of Claude Code's folder-trust dialog, current and earlier wording. They tell the
|
|
55
|
-
// board the dialog is up; the looser
|
|
56
|
+
// board the dialog is up; the looser onDialogScreen only holds the prompt back, where a miss is worse.
|
|
56
57
|
const TRUST_DIALOG = /Yes, I trust this folder|Do you trust the files in this folder\?/;
|
|
58
|
+
// The footer of Claude Code's choice dialogs: folder trust, a new .mcp.json server and the rest.
|
|
59
|
+
const DIALOG_SCREEN = /Enter to confirm/;
|
|
57
60
|
// Watermarks from https://xtermjs.org/docs/guides/flowcontrol/ — pause the PTY while a
|
|
58
61
|
// client lags, so a runaway process cannot grow the socket buffer without bound.
|
|
59
62
|
const HIGH_WATER = 128 * 1024;
|
|
@@ -376,6 +379,7 @@ function createTerminalService(o) {
|
|
|
376
379
|
const restoreGapMs = o.restoreGapMs ?? RESTORE_GAP_MS;
|
|
377
380
|
const exitPollMs = o.exitPollMs ?? CLAUDE_EXIT_POLL_MS;
|
|
378
381
|
const submitPollMs = o.submitPollMs ?? SUBMIT_POLL_MS;
|
|
382
|
+
const promptQuietMs = o.promptQuietMs ?? PROMPT_QUIET_MS;
|
|
379
383
|
const pidAlive = o.isPidAlive || isPidAlive;
|
|
380
384
|
const exitTimer = setInterval(checkExits, exitPollMs);
|
|
381
385
|
exitTimer.unref();
|
|
@@ -505,8 +509,8 @@ function createTerminalService(o) {
|
|
|
505
509
|
}
|
|
506
510
|
|
|
507
511
|
// The folder and session names show on claude's main screen, so a "trust" in them is not the dialog.
|
|
508
|
-
function
|
|
509
|
-
|
|
512
|
+
function onDialogScreen(s, text = screenText(s.term)) {
|
|
513
|
+
if (DIALOG_SCREEN.test(text)) return true;
|
|
510
514
|
for (const own of [path.basename(s.cwd), s.name]) if (own) text = text.split(own).join('');
|
|
511
515
|
return /\btrust\b/i.test(text);
|
|
512
516
|
}
|
|
@@ -536,12 +540,13 @@ function createTerminalService(o) {
|
|
|
536
540
|
if (!armed) return;
|
|
537
541
|
timer = setTimeout(() => {
|
|
538
542
|
if (s.exited) return;
|
|
539
|
-
|
|
543
|
+
const shown = submit && screenText(s.term);
|
|
544
|
+
if (submit && onDialogScreen(s, shown)) return setAwaitingTrust(s, TRUST_DIALOG.test(shown));
|
|
540
545
|
setAwaitingTrust(s, false);
|
|
541
546
|
s.onOutput = null;
|
|
542
547
|
s.pty.write(text);
|
|
543
548
|
if (submit) setTimeout(() => submitPrompt(s), 100);
|
|
544
|
-
},
|
|
549
|
+
}, promptQuietMs);
|
|
545
550
|
};
|
|
546
551
|
}
|
|
547
552
|
|
|
@@ -938,16 +943,44 @@ function createTerminalService(o) {
|
|
|
938
943
|
return r.error ? r : { id: r.session.id, cwd: r.session.cwd, name: r.session.name, prompted: r.session.prompted };
|
|
939
944
|
}
|
|
940
945
|
|
|
941
|
-
// Pastes into claude's input box and stops there
|
|
942
|
-
// user had half-typed
|
|
943
|
-
// prompt owns the input box until it is sent.
|
|
944
|
-
function
|
|
946
|
+
// Pastes into claude's input box and stops there unless the caller asks for submit: Enter
|
|
947
|
+
// also submits whatever the user had half-typed. A dialog screen refuses the paste, so the
|
|
948
|
+
// Enter cannot answer one. A pending queued prompt owns the input box until it is sent.
|
|
949
|
+
function inputBox(id) {
|
|
945
950
|
const s = sessions.get(id);
|
|
946
|
-
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell') return
|
|
947
|
-
|
|
951
|
+
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell') return null;
|
|
952
|
+
// Not onDialogScreen: its loose "trust" test is for the first screen, and a conversation says trust.
|
|
953
|
+
const shown = screenText(s.term);
|
|
954
|
+
if (DIALOG_SCREEN.test(shown) || TRUST_DIALOG.test(shown)) return null;
|
|
948
955
|
// Until the user runs the typed command, the input box is the shell's command line.
|
|
949
|
-
if (s.edit && !s.claudePid) return
|
|
956
|
+
if (s.edit && !s.claudePid) return null;
|
|
957
|
+
return s;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function paste(id, text, submit = false) {
|
|
961
|
+
const s = inputBox(id);
|
|
962
|
+
if (!s) return false;
|
|
950
963
|
s.pty.write(bracketed(text));
|
|
964
|
+
// claude takes a paste and an Enter in the same read as a line break in the input box.
|
|
965
|
+
if (submit) setTimeout(() => !s.exited && s.pty.write('\r'), SUBMIT_AFTER_PASTE_MS);
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Live dictation: the caller typed `from` before and now wants `text`. Backspaces remove what
|
|
970
|
+
// differs and keys type the rest, so the input box follows the words as they come.
|
|
971
|
+
// Typed keys, not a paste: claude shows a long paste as one "[Pasted text]" chip that a backspace removes whole.
|
|
972
|
+
function type(id, from, text, submit = false) {
|
|
973
|
+
const s = inputBox(id);
|
|
974
|
+
if (!s) return false;
|
|
975
|
+
// A line break would be Enter.
|
|
976
|
+
const keysOf = (t) => Array.from(t.replace(/\s*[\r\n]+\s*/g, ' '));
|
|
977
|
+
const was = keysOf(from);
|
|
978
|
+
const now = keysOf(text);
|
|
979
|
+
let same = 0;
|
|
980
|
+
while (same < was.length && same < now.length && was[same] === now[same]) same++;
|
|
981
|
+
const keys = '\x7f'.repeat(was.length - same) + now.slice(same).join('');
|
|
982
|
+
if (keys) s.pty.write(keys);
|
|
983
|
+
if (submit) setTimeout(() => !s.exited && s.pty.write('\r'), keys ? SUBMIT_AFTER_PASTE_MS : 0);
|
|
951
984
|
return true;
|
|
952
985
|
}
|
|
953
986
|
|
|
@@ -967,7 +1000,7 @@ function createTerminalService(o) {
|
|
|
967
1000
|
sessions.clear();
|
|
968
1001
|
}
|
|
969
1002
|
|
|
970
|
-
return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
|
|
1003
|
+
return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, type, restore, shutdown, unavailableReason };
|
|
971
1004
|
}
|
|
972
1005
|
|
|
973
|
-
module.exports = { MODELS,createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, claudeCommand, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
|
1006
|
+
module.exports = { MODELS,createTerminalService, DIALOG_SCREEN, screenText, 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.
|
|
3
|
+
"version": "7.24.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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"test": "node --test test/*.test.js",
|
|
14
14
|
"test:swarm": "npm --prefix extensions/swarm/plugin/plugins/swarm test",
|
|
15
15
|
"validate:schemas": "node test/validate-live-schemas.js",
|
|
16
|
+
"test:live-dialogs": "node test/live-startup-dialogs.js",
|
|
16
17
|
"prepare": "husky"
|
|
17
18
|
},
|
|
18
19
|
"repository": {
|
package/public/app.js
CHANGED
|
@@ -4672,7 +4672,7 @@ function renderKanban() {
|
|
|
4672
4672
|
// away mid-typing -- leave the column alone until the input is gone.
|
|
4673
4673
|
if (!addingTask) {
|
|
4674
4674
|
const addTile = canAddTask()
|
|
4675
|
-
? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)" title="You can also ask the agent to add tasks with its TaskCreate tool. Make sure CLAUDE_CODE_ENABLE_TASKS is set in its environment.">${plusIcon}<span>Add task</span></button>`
|
|
4675
|
+
? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this, event)" title="Ctrl+Click opens the dialog with a description. You can also ask the agent to add tasks with its TaskCreate tool. Make sure CLAUDE_CODE_ENABLE_TASKS is set in its environment.">${plusIcon}<span>Add task</span></button>`
|
|
4676
4676
|
: '';
|
|
4677
4677
|
writes.push([
|
|
4678
4678
|
pendingTasks,
|
|
@@ -4732,9 +4732,196 @@ function canAddTask() {
|
|
|
4732
4732
|
return viewMode === 'session' && !!currentSessionId;
|
|
4733
4733
|
}
|
|
4734
4734
|
|
|
4735
|
+
async function postTask(subject, description = '', sessionId = currentSessionId) {
|
|
4736
|
+
const res = await api(apiPath`/api/tasks/${sessionId}`, { method: 'POST', body: { subject, description } });
|
|
4737
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
4738
|
+
// The response carries the finished task, and the watcher will resend it within the
|
|
4739
|
+
// SSE debounce anyway -- so show it now rather than paying a session refetch for it.
|
|
4740
|
+
const { task } = await res.json();
|
|
4741
|
+
if (sessionId === currentSessionId) currentTasks.push({ ...task, sessionId });
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4744
|
+
// task.add from the hub: the subject goes on the board on screen; with none, the Add task dialog opens.
|
|
4745
|
+
async function addTaskFromHub({ subject, description }) {
|
|
4746
|
+
if (!canAddTask()) return showToast('Open a session to add a task', 'info');
|
|
4747
|
+
const text = subject?.trim();
|
|
4748
|
+
if (!text) return openTaskDialog(true);
|
|
4749
|
+
try {
|
|
4750
|
+
await postTask(text, description?.trim());
|
|
4751
|
+
if (!addingTask) renderKanban();
|
|
4752
|
+
} catch (error) {
|
|
4753
|
+
console.error('Failed to create task:', error);
|
|
4754
|
+
showToast('Failed to create task', 'error');
|
|
4755
|
+
}
|
|
4756
|
+
}
|
|
4757
|
+
|
|
4758
|
+
// The Add task dialog: the hub opens it by voice and fills it as the user speaks. The task goes to
|
|
4759
|
+
// the session that was on screen when it opened.
|
|
4760
|
+
let taskDialogSession = null;
|
|
4761
|
+
const taskDialogOpen = () => document.getElementById('task-modal').classList.contains('visible');
|
|
4762
|
+
|
|
4763
|
+
function openTaskDialog(focus = false) {
|
|
4764
|
+
if (terminalPaneFocused()) leaveTerminalPane();
|
|
4765
|
+
taskDialogSession = currentSessionId;
|
|
4766
|
+
const session = sessions.find((s) => s.id === currentSessionId);
|
|
4767
|
+
document.getElementById('task-modal-session').textContent = session ? sessionDisplayName(session) : '';
|
|
4768
|
+
document.getElementById('task-subject').value = '';
|
|
4769
|
+
document.getElementById('task-description').value = '';
|
|
4770
|
+
setFormError('task-error', '');
|
|
4771
|
+
renderTaskDialog();
|
|
4772
|
+
document.getElementById('task-modal').classList.add('visible');
|
|
4773
|
+
if (focus) document.getElementById('task-subject').focus();
|
|
4774
|
+
}
|
|
4775
|
+
|
|
4776
|
+
function closeTaskDialog() {
|
|
4777
|
+
hideModalOverlay('task-modal');
|
|
4778
|
+
taskDialogSession = null;
|
|
4779
|
+
taskDialogVoice = false;
|
|
4780
|
+
setTaskVoiceState('');
|
|
4781
|
+
}
|
|
4782
|
+
|
|
4783
|
+
function renderTaskDialog() {
|
|
4784
|
+
const subject = document.getElementById('task-subject');
|
|
4785
|
+
document.getElementById('task-save').disabled = subject.readOnly || !subject.value.trim();
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
function fillTaskDialog(subject, description) {
|
|
4789
|
+
fillTaskField('task-subject', subject);
|
|
4790
|
+
fillTaskField('task-description', description);
|
|
4791
|
+
renderTaskDialog();
|
|
4792
|
+
}
|
|
4793
|
+
|
|
4794
|
+
// Voice fills a field: it flashes, and a long text shows its newest words.
|
|
4795
|
+
function fillTaskField(id, value) {
|
|
4796
|
+
const field = document.getElementById(id);
|
|
4797
|
+
if (field.value === value) return;
|
|
4798
|
+
field.value = value;
|
|
4799
|
+
field.scrollTop = field.scrollHeight;
|
|
4800
|
+
if (value) flashTaskField(field, 'task-filled');
|
|
4801
|
+
}
|
|
4802
|
+
|
|
4803
|
+
function flashTaskField(field, cls) {
|
|
4804
|
+
field.classList.remove('task-filled', 'task-moved');
|
|
4805
|
+
void field.offsetWidth;
|
|
4806
|
+
field.classList.add(cls);
|
|
4807
|
+
}
|
|
4808
|
+
|
|
4809
|
+
async function saveTaskDialog() {
|
|
4810
|
+
const subject = document.getElementById('task-subject').value.trim();
|
|
4811
|
+
if (!subject || !taskDialogSession) return;
|
|
4812
|
+
const save = document.getElementById('task-save');
|
|
4813
|
+
save.disabled = true;
|
|
4814
|
+
try {
|
|
4815
|
+
await postTask(subject, document.getElementById('task-description').value.trim(), taskDialogSession);
|
|
4816
|
+
closeTaskDialog();
|
|
4817
|
+
if (!addingTask) renderKanban();
|
|
4818
|
+
} catch (error) {
|
|
4819
|
+
console.error('Failed to create task:', error);
|
|
4820
|
+
setFormError('task-error', 'Failed to create task');
|
|
4821
|
+
renderTaskDialog();
|
|
4822
|
+
}
|
|
4823
|
+
}
|
|
4824
|
+
|
|
4825
|
+
// The title can take a model call after the release; a quick answer shows no loading at all.
|
|
4826
|
+
const TASK_TITLING_DELAY_MS = 300;
|
|
4827
|
+
let taskTitlingTimer = null;
|
|
4828
|
+
|
|
4829
|
+
// A hold fills a dialog: it glows, shows the badge, and its fields are read-only, so typing cannot
|
|
4830
|
+
// race the voice. A hold whose last call never comes (the hub page reloaded) runs `onStale` after
|
|
4831
|
+
// VOICE_STALE_MS, longer than a task title's model call.
|
|
4832
|
+
const VOICE_STALE_MS = 20_000;
|
|
4833
|
+
const voiceStale = new Map();
|
|
4834
|
+
|
|
4835
|
+
function setModalVoice(modal, fields, state, onStale) {
|
|
4836
|
+
clearTimeout(voiceStale.get(modal));
|
|
4837
|
+
if (state) voiceStale.set(modal, setTimeout(onStale, VOICE_STALE_MS));
|
|
4838
|
+
if ((modal.dataset.voice ?? '') === state) return;
|
|
4839
|
+
if (state) modal.dataset.voice = state;
|
|
4840
|
+
else delete modal.dataset.voice;
|
|
4841
|
+
for (const field of fields) field.readOnly = !!state;
|
|
4842
|
+
modal.querySelector('.voice-mic').hidden = !state;
|
|
4843
|
+
}
|
|
4844
|
+
|
|
4845
|
+
// 'listening' while the user speaks, 'titling' while the title is on its way, '' when the dialog is
|
|
4846
|
+
// the user's.
|
|
4847
|
+
function setTaskVoiceState(state) {
|
|
4848
|
+
clearTimeout(taskTitlingTimer);
|
|
4849
|
+
if (state === 'titling') {
|
|
4850
|
+
taskTitlingTimer = setTimeout(() => applyTaskVoiceState(state), TASK_TITLING_DELAY_MS);
|
|
4851
|
+
return;
|
|
4852
|
+
}
|
|
4853
|
+
applyTaskVoiceState(state);
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
function applyTaskVoiceState(state) {
|
|
4857
|
+
const subject = document.getElementById('task-subject');
|
|
4858
|
+
const fields = [subject, document.getElementById('task-description')];
|
|
4859
|
+
setModalVoice(document.querySelector('.task-modal'), fields, state, () => {
|
|
4860
|
+
taskDialogVoice = false;
|
|
4861
|
+
setTaskVoiceState('');
|
|
4862
|
+
});
|
|
4863
|
+
subject.classList.toggle('task-titling', state === 'titling');
|
|
4864
|
+
subject.placeholder = state === 'titling' ? 'Writing a title…' : 'What to do';
|
|
4865
|
+
document.getElementById('task-modal-mic-label').textContent = state === 'titling' ? 'Writing a title' : 'Listening';
|
|
4866
|
+
renderTaskDialog();
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
// Set from a hold's first task.live until its last call or a close, so a final call that lands
|
|
4870
|
+
// after the user closed the dialog does not open it again.
|
|
4871
|
+
let taskDialogVoice = false;
|
|
4872
|
+
|
|
4873
|
+
// task.live from the hub: while the user speaks, the words so far as the description; `pending` on
|
|
4874
|
+
// release; then `final` with the title and the description. A short task moves up into the title.
|
|
4875
|
+
function liveTaskFromHub(p) {
|
|
4876
|
+
if (p.cancel === 'true') return taskDialogVoice && closeTaskDialog();
|
|
4877
|
+
const last = p.final === 'true';
|
|
4878
|
+
if (!taskDialogVoice && !last && p.pending !== 'true' && canAddTask()) {
|
|
4879
|
+
if (!taskDialogOpen()) openTaskDialog();
|
|
4880
|
+
taskDialogVoice = true;
|
|
4881
|
+
setTaskVoiceState('listening');
|
|
4882
|
+
}
|
|
4883
|
+
if (!taskDialogVoice) return last && !canAddTask() && showToast('Open a session to add a task', 'info');
|
|
4884
|
+
if (p.pending === 'true') return setTaskVoiceState('titling');
|
|
4885
|
+
if (!last) {
|
|
4886
|
+
setTaskVoiceState('listening');
|
|
4887
|
+
return fillTaskField('task-description', p.description ?? '');
|
|
4888
|
+
}
|
|
4889
|
+
const subject = document.getElementById('task-subject');
|
|
4890
|
+
taskDialogVoice = false;
|
|
4891
|
+
setTaskVoiceState('');
|
|
4892
|
+
if (p.subject !== undefined) {
|
|
4893
|
+
const moved = !p.description && p.subject === document.getElementById('task-description').value.trim();
|
|
4894
|
+
fillTaskDialog(p.subject, p.description ?? '');
|
|
4895
|
+
if (moved) flashTaskField(subject, 'task-moved');
|
|
4896
|
+
}
|
|
4897
|
+
if (p.submit === 'true') return saveTaskDialog();
|
|
4898
|
+
const save = document.getElementById('task-save');
|
|
4899
|
+
(save.disabled ? subject : save).focus();
|
|
4900
|
+
}
|
|
4901
|
+
|
|
4902
|
+
function initTaskDialog() {
|
|
4903
|
+
const subject = document.getElementById('task-subject');
|
|
4904
|
+
const description = document.getElementById('task-description');
|
|
4905
|
+
subject.addEventListener('input', renderTaskDialog);
|
|
4906
|
+
document.querySelector('.task-modal').addEventListener('keydown', (e) => {
|
|
4907
|
+
if (e.key === 'Escape') {
|
|
4908
|
+
e.preventDefault();
|
|
4909
|
+
e.stopPropagation();
|
|
4910
|
+
return closeTaskDialog();
|
|
4911
|
+
}
|
|
4912
|
+
if (e.key !== 'Enter' || e.shiftKey || e.isComposing) return;
|
|
4913
|
+
if (e.target !== subject && e.target !== description) return;
|
|
4914
|
+
e.preventDefault();
|
|
4915
|
+
e.stopPropagation();
|
|
4916
|
+
saveTaskDialog();
|
|
4917
|
+
});
|
|
4918
|
+
}
|
|
4919
|
+
initTaskDialog();
|
|
4920
|
+
|
|
4735
4921
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
4736
|
-
function startAddTask(tile) {
|
|
4922
|
+
function startAddTask(tile, e) {
|
|
4737
4923
|
if (addingTask) return;
|
|
4924
|
+
if (e?.ctrlKey || e?.metaKey) return openTaskDialog(true);
|
|
4738
4925
|
addingTask = true;
|
|
4739
4926
|
|
|
4740
4927
|
const input = document.createElement('input');
|
|
@@ -4761,14 +4948,8 @@ function startAddTask(tile) {
|
|
|
4761
4948
|
const subject = input.value.trim();
|
|
4762
4949
|
if (!subject) return reset();
|
|
4763
4950
|
input.disabled = true;
|
|
4764
|
-
const sessionId = currentSessionId;
|
|
4765
4951
|
try {
|
|
4766
|
-
|
|
4767
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
4768
|
-
// The response carries the finished task, and the watcher will resend it within the
|
|
4769
|
-
// SSE debounce anyway -- so show it now rather than paying a session refetch for it.
|
|
4770
|
-
const { task } = await res.json();
|
|
4771
|
-
currentTasks.push({ ...task, sessionId });
|
|
4952
|
+
await postTask(subject);
|
|
4772
4953
|
addingTask = false;
|
|
4773
4954
|
renderKanban();
|
|
4774
4955
|
} catch (error) {
|
|
@@ -7831,6 +8012,7 @@ const MODAL_CLOSERS = {
|
|
|
7831
8012
|
'project-picker-modal': () => closeProjectPicker(),
|
|
7832
8013
|
'terminal-manager-modal': () => closeTerminalManager(),
|
|
7833
8014
|
'new-session-modal': () => closeNewSession(),
|
|
8015
|
+
'task-modal': () => closeTaskDialog(),
|
|
7834
8016
|
'confirm-modal': () => closeConfirmModal?.(false),
|
|
7835
8017
|
'pane-pop': () => closePanePop(),
|
|
7836
8018
|
};
|
|
@@ -12530,6 +12712,8 @@ let spPeekToken = 0;
|
|
|
12530
12712
|
let spPeekedId = null;
|
|
12531
12713
|
let spFromTerminal = false;
|
|
12532
12714
|
let spRenderedQuery = null;
|
|
12715
|
+
// Rows ranked by the caller for the query the picker opened with; the first edit drops them.
|
|
12716
|
+
let spRanked = null;
|
|
12533
12717
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
12534
12718
|
const spPeekCache = new Map();
|
|
12535
12719
|
// 'board' offers the sidebar's sessions plus a short tail of the newest others; 'all' offers
|
|
@@ -12583,10 +12767,11 @@ const spSearch = sessionSearch(() => {
|
|
|
12583
12767
|
if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
|
|
12584
12768
|
});
|
|
12585
12769
|
|
|
12586
|
-
function openSessionPicker() {
|
|
12770
|
+
function openSessionPicker({ query = '', ranked = null } = {}) {
|
|
12587
12771
|
spFromTerminal = terminalPaneFocused();
|
|
12588
12772
|
const input = document.getElementById('session-picker-input');
|
|
12589
|
-
input.value =
|
|
12773
|
+
input.value = query;
|
|
12774
|
+
spRanked = ranked;
|
|
12590
12775
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
12591
12776
|
spPeekCache.clear();
|
|
12592
12777
|
spRenderedQuery = null;
|
|
@@ -12716,7 +12901,7 @@ function renderSessionPicker() {
|
|
|
12716
12901
|
spRenderedQuery = renderKey;
|
|
12717
12902
|
const all = spScope === 'all';
|
|
12718
12903
|
const board = new Set(spSource.map((s) => s.id));
|
|
12719
|
-
const local = (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
|
|
12904
|
+
const local = spRanked && !all ? spRanked : (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
|
|
12720
12905
|
const found = spSearch.rows(query, local);
|
|
12721
12906
|
const tail = !all && !query ? spRecent.rows.filter((s) => !board.has(s.id)).slice(0, SP_RECENT_TAIL) : [];
|
|
12722
12907
|
const extra = [...found.rows, ...tail];
|
|
@@ -12780,7 +12965,10 @@ async function spOpen(idx) {
|
|
|
12780
12965
|
|
|
12781
12966
|
function initSessionPicker() {
|
|
12782
12967
|
const input = document.getElementById('session-picker-input');
|
|
12783
|
-
input.addEventListener('input',
|
|
12968
|
+
input.addEventListener('input', () => {
|
|
12969
|
+
spRanked = null;
|
|
12970
|
+
renderSessionPicker();
|
|
12971
|
+
});
|
|
12784
12972
|
// Bound on the input: the global handler returns early on INPUT targets.
|
|
12785
12973
|
input.addEventListener('keydown', (e) => {
|
|
12786
12974
|
if (e.key === 'Escape') closeSessionPicker();
|
|
@@ -15264,6 +15452,7 @@ panePop.addEventListener('keydown', (e) => {
|
|
|
15264
15452
|
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
15265
15453
|
const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
15266
15454
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
15455
|
+
const TERMINAL_SEEN_KEY = 'terminal-seen-sessions';
|
|
15267
15456
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
15268
15457
|
const TERMINAL_FONT_MIN = 8;
|
|
15269
15458
|
const TERMINAL_FONT_MAX = 32;
|
|
@@ -15355,6 +15544,16 @@ function setTerminalMode(sessionId, on) {
|
|
|
15355
15544
|
store.writeJson(TERMINAL_MODES_KEY, [...modes].slice(-50));
|
|
15356
15545
|
}
|
|
15357
15546
|
|
|
15547
|
+
// A session started elsewhere (dispatch, another board) runs in a terminal this board never turned on,
|
|
15548
|
+
// so its first open shows the terminal. Later opens keep what the user chose.
|
|
15549
|
+
function defaultToTerminalOnFirstOpen(sessionId) {
|
|
15550
|
+
if (!runningTerminals.has(sessionId)) return;
|
|
15551
|
+
const seen = readStoredList(TERMINAL_SEEN_KEY);
|
|
15552
|
+
if (seen.includes(sessionId)) return;
|
|
15553
|
+
store.writeJson(TERMINAL_SEEN_KEY, [...seen, sessionId].slice(-200));
|
|
15554
|
+
setTerminalMode(sessionId, true);
|
|
15555
|
+
}
|
|
15556
|
+
|
|
15358
15557
|
function wantsTerminal() {
|
|
15359
15558
|
return viewMode === 'session' && wantsTerminalFor(currentSessionId);
|
|
15360
15559
|
}
|
|
@@ -15543,6 +15742,7 @@ async function openSession(sessionId) {
|
|
|
15543
15742
|
setSessionDismissed(sessionId, false);
|
|
15544
15743
|
// Awaited only when missing, so opening a loaded session stays synchronous up to fetchTasks.
|
|
15545
15744
|
if (!sessions.some((s) => s.id === sessionId)) await ensureSessionLoaded(sessionId);
|
|
15745
|
+
defaultToTerminalOnFirstOpen(sessionId);
|
|
15546
15746
|
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
15547
15747
|
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
15548
15748
|
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
@@ -16121,6 +16321,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
|
|
|
16121
16321
|
effort: spec.effort,
|
|
16122
16322
|
prompt: spec.prompt,
|
|
16123
16323
|
edit: spec.edit,
|
|
16324
|
+
extraArgs: spec.extraArgs,
|
|
16124
16325
|
forkOf: spec.forkOf,
|
|
16125
16326
|
}),
|
|
16126
16327
|
},
|
|
@@ -17574,8 +17775,10 @@ function dockOnScreen(active = dockActivePane()) {
|
|
|
17574
17775
|
function setDockAddon(id) {
|
|
17575
17776
|
const tid = showState.terminalId;
|
|
17576
17777
|
if (!tid || (dockAddons[tid] ?? null) === id) return;
|
|
17577
|
-
if (id)
|
|
17578
|
-
|
|
17778
|
+
if (id) {
|
|
17779
|
+
dockAddons[tid] = id;
|
|
17780
|
+
showState.closed = true;
|
|
17781
|
+
} else delete dockAddons[tid];
|
|
17579
17782
|
store.writeJson(DOCK_ADDON_KEY, dockAddons);
|
|
17580
17783
|
}
|
|
17581
17784
|
|
|
@@ -18083,7 +18286,17 @@ const newSpecs = new Map();
|
|
|
18083
18286
|
// Sessions another session started (`dispatch start`), shown where they will stay until
|
|
18084
18287
|
// their transcript appears. Kept apart from newSpecs: their prompt is already sent.
|
|
18085
18288
|
const dispatchSpecs = new Map();
|
|
18086
|
-
const ns = {
|
|
18289
|
+
const ns = {
|
|
18290
|
+
projects: [],
|
|
18291
|
+
picked: [],
|
|
18292
|
+
matches: [],
|
|
18293
|
+
idx: -1,
|
|
18294
|
+
folder: '',
|
|
18295
|
+
busy: false,
|
|
18296
|
+
resume: false,
|
|
18297
|
+
images: [],
|
|
18298
|
+
extraArgs: [],
|
|
18299
|
+
};
|
|
18087
18300
|
// Must match ATTACHMENT_TYPES and the upload limit in server.js.
|
|
18088
18301
|
const NS_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
18089
18302
|
const NS_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -18291,6 +18504,7 @@ function openNewSession(folder, resume = false) {
|
|
|
18291
18504
|
if (!terminalAvailable() || document.getElementById('new-session-modal').classList.contains('visible')) return;
|
|
18292
18505
|
if (terminalPaneFocused()) leaveTerminalPane();
|
|
18293
18506
|
ns.resume = resume;
|
|
18507
|
+
ns.extraArgs = [];
|
|
18294
18508
|
document.querySelector('.new-session-modal').classList.toggle('ns-resume', resume);
|
|
18295
18509
|
document.getElementById('ns-title').textContent = resume ? 'Resume session' : 'New session';
|
|
18296
18510
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
|
|
@@ -18308,7 +18522,7 @@ function openNewSession(folder, resume = false) {
|
|
|
18308
18522
|
const input = document.getElementById('ns-folder');
|
|
18309
18523
|
input.focus();
|
|
18310
18524
|
input.select();
|
|
18311
|
-
loadProjects().then((list) => {
|
|
18525
|
+
return loadProjects().then((list) => {
|
|
18312
18526
|
ns.projects = list
|
|
18313
18527
|
.filter((p) => !p.temp)
|
|
18314
18528
|
.sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''))
|
|
@@ -18320,10 +18534,16 @@ function openNewSession(folder, resume = false) {
|
|
|
18320
18534
|
|
|
18321
18535
|
function closeNewSession() {
|
|
18322
18536
|
hideModalOverlay('new-session-modal');
|
|
18537
|
+
setNewSessionVoice(false);
|
|
18323
18538
|
closeFolderList();
|
|
18324
18539
|
closeImagePreview();
|
|
18325
18540
|
clearNewSessionImages();
|
|
18326
18541
|
promptVoice.rec?.abort();
|
|
18542
|
+
if (promptVoice.hub) {
|
|
18543
|
+
promptVoice.hub = null;
|
|
18544
|
+
document.getElementById('ns-mic').setAttribute('aria-pressed', 'false');
|
|
18545
|
+
hub.invoke(HUB_DICTATE, { op: 'cancel' });
|
|
18546
|
+
}
|
|
18327
18547
|
}
|
|
18328
18548
|
|
|
18329
18549
|
function addNewSessionImages(files) {
|
|
@@ -18478,6 +18698,7 @@ function newSessionValues() {
|
|
|
18478
18698
|
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
18479
18699
|
edit,
|
|
18480
18700
|
stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
|
|
18701
|
+
extraArgs: ns.resume ? [] : ns.extraArgs,
|
|
18481
18702
|
};
|
|
18482
18703
|
}
|
|
18483
18704
|
|
|
@@ -18490,7 +18711,11 @@ function newSessionProblem(v) {
|
|
|
18490
18711
|
}
|
|
18491
18712
|
|
|
18492
18713
|
function setNewSessionError(text) {
|
|
18493
|
-
|
|
18714
|
+
setFormError('ns-error', text);
|
|
18715
|
+
}
|
|
18716
|
+
|
|
18717
|
+
function setFormError(id, text) {
|
|
18718
|
+
const el = document.getElementById(id);
|
|
18494
18719
|
el.textContent = text;
|
|
18495
18720
|
el.hidden = !text;
|
|
18496
18721
|
}
|
|
@@ -18504,6 +18729,8 @@ function renderNewSessionForm() {
|
|
|
18504
18729
|
hint.hidden = !wtOn;
|
|
18505
18730
|
hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
|
|
18506
18731
|
document.getElementById('ns-stay').disabled = v.edit;
|
|
18732
|
+
document.getElementById('ns-args-row').hidden = !v.extraArgs.length;
|
|
18733
|
+
document.getElementById('ns-args').textContent = v.extraArgs.join(' ');
|
|
18507
18734
|
const problem = newSessionProblem(v);
|
|
18508
18735
|
setNewSessionError(problem);
|
|
18509
18736
|
const start = document.getElementById('ns-start');
|
|
@@ -18591,7 +18818,7 @@ function forkSession(parentId) {
|
|
|
18591
18818
|
async function startNewSessionInBackground(id, v) {
|
|
18592
18819
|
refreshPlaceholders();
|
|
18593
18820
|
setNewSessionBusy(true);
|
|
18594
|
-
const r = await newSessionPost('/api/terminal/start', { id, ...v }, 'Start failed');
|
|
18821
|
+
const r = await newSessionPost('/api/terminal/start', { id, ...v, claudeArgs: v.extraArgs }, 'Start failed');
|
|
18595
18822
|
setNewSessionBusy(false);
|
|
18596
18823
|
if (r.error) {
|
|
18597
18824
|
dropPlaceholder(id);
|
|
@@ -18609,26 +18836,34 @@ async function startNewSessionInBackground(id, v) {
|
|
|
18609
18836
|
}
|
|
18610
18837
|
|
|
18611
18838
|
const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
18612
|
-
const promptVoice = { rec: null };
|
|
18839
|
+
const promptVoice = { rec: null, hub: null };
|
|
18840
|
+
const HUB_DICTATE = 'voice.dictate';
|
|
18613
18841
|
|
|
18614
18842
|
// Replaces the selection the field had when dictation began, so interim results rewrite
|
|
18615
18843
|
// themselves in place instead of piling up.
|
|
18616
|
-
function
|
|
18617
|
-
if (promptVoice.rec) return promptVoice.rec.stop();
|
|
18844
|
+
function promptInserter() {
|
|
18618
18845
|
const field = document.getElementById('ns-prompt');
|
|
18619
|
-
const btn = document.getElementById('ns-mic');
|
|
18620
18846
|
const before = field.value.slice(0, field.selectionStart);
|
|
18621
18847
|
const after = field.value.slice(field.selectionEnd);
|
|
18622
18848
|
const lead = before && !/\s$/.test(before) ? ' ' : '';
|
|
18623
|
-
|
|
18624
|
-
rec.lang = navigator.language;
|
|
18625
|
-
rec.interimResults = true;
|
|
18626
|
-
rec.onresult = (e) => {
|
|
18627
|
-
const text = Array.from(e.results, (r) => r[0].transcript).join('');
|
|
18849
|
+
return (text) => {
|
|
18628
18850
|
field.value = before + lead + text + after;
|
|
18629
18851
|
const caret = before.length + lead.length + text.length;
|
|
18630
18852
|
field.setSelectionRange(caret, caret);
|
|
18853
|
+
renderNewSessionForm();
|
|
18631
18854
|
};
|
|
18855
|
+
}
|
|
18856
|
+
|
|
18857
|
+
function togglePromptVoice() {
|
|
18858
|
+
if (hub.can(HUB_DICTATE)) return toggleHubDictation();
|
|
18859
|
+
if (promptVoice.rec) return promptVoice.rec.stop();
|
|
18860
|
+
const field = document.getElementById('ns-prompt');
|
|
18861
|
+
const btn = document.getElementById('ns-mic');
|
|
18862
|
+
const insert = promptInserter();
|
|
18863
|
+
const rec = new SpeechRecognitionApi();
|
|
18864
|
+
rec.lang = navigator.language;
|
|
18865
|
+
rec.interimResults = true;
|
|
18866
|
+
rec.onresult = (e) => insert(Array.from(e.results, (r) => r[0].transcript).join(''));
|
|
18632
18867
|
rec.onerror = (e) => {
|
|
18633
18868
|
if (e.error === 'not-allowed' || e.error === 'service-not-allowed')
|
|
18634
18869
|
setNewSessionError('Microphone access is blocked for this page.');
|
|
@@ -18644,6 +18879,23 @@ function togglePromptVoice() {
|
|
|
18644
18879
|
rec.start();
|
|
18645
18880
|
}
|
|
18646
18881
|
|
|
18882
|
+
// Under a hub with voice on, the mic uses the hub's speech provider: the text arrives once, on the second click.
|
|
18883
|
+
async function toggleHubDictation() {
|
|
18884
|
+
const btn = document.getElementById('ns-mic');
|
|
18885
|
+
const op = promptVoice.hub ? 'stop' : 'start';
|
|
18886
|
+
const insert = promptVoice.hub ?? promptInserter();
|
|
18887
|
+
promptVoice.hub = op === 'start' ? insert : null;
|
|
18888
|
+
btn.setAttribute('aria-pressed', String(op === 'start'));
|
|
18889
|
+
document.getElementById('ns-prompt').focus();
|
|
18890
|
+
const r = await hub.invoke(HUB_DICTATE, { op });
|
|
18891
|
+
if (!r.ok) {
|
|
18892
|
+
promptVoice.hub = null;
|
|
18893
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
18894
|
+
return setNewSessionError(`Dictation failed: ${r.reason}`);
|
|
18895
|
+
}
|
|
18896
|
+
if (op === 'stop' && r.text) insert(r.text);
|
|
18897
|
+
}
|
|
18898
|
+
|
|
18647
18899
|
function pickFolderOption(i) {
|
|
18648
18900
|
const path = ns.matches[i];
|
|
18649
18901
|
if (!path) return;
|
|
@@ -19135,6 +19387,179 @@ hub.onActive((active) => {
|
|
|
19135
19387
|
hub.onStatus(pushTerminalClaims);
|
|
19136
19388
|
hub.onSettings(applyHubSettings);
|
|
19137
19389
|
hub.handle('session.open', (p) => p.session && openSessionFromOutside(p.session));
|
|
19390
|
+
hub.handle('session.find', (p) => findSessionFromHub(p));
|
|
19391
|
+
hub.handle('session.paste', (p) => pasteFromHub(p));
|
|
19392
|
+
hub.handle('task.add', (p) => addTaskFromHub(p));
|
|
19393
|
+
hub.handle('task.live', (p) => liveTaskFromHub(p));
|
|
19394
|
+
// After pending session.live calls, so the hub's clear of words typed into the terminal lands there, not in the dialog.
|
|
19395
|
+
hub.handle('session.new', (p) => {
|
|
19396
|
+
hubNewSession = liveChain.then(() => newSessionFromHub(p)).catch(() => {});
|
|
19397
|
+
});
|
|
19398
|
+
hub.handle('session.live', (p) => {
|
|
19399
|
+
liveChain = liveChain.then(() => liveFromHub(p)).catch(() => {});
|
|
19400
|
+
});
|
|
19401
|
+
|
|
19402
|
+
// Action params are strings, so flags come as "true" and `args` as claude args split on spaces
|
|
19403
|
+
// (the terminal refuses quotes in them anyway).
|
|
19404
|
+
function hubNewSessionValues(p) {
|
|
19405
|
+
const onScreen = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
|
|
19406
|
+
const filtered = filterProject && filterProject !== '__recent__' ? filterProject : null;
|
|
19407
|
+
const wt = p.worktree?.trim();
|
|
19408
|
+
const edit = p.edit === 'true';
|
|
19409
|
+
return {
|
|
19410
|
+
cwd: p.cwd || onScreen || filtered || '',
|
|
19411
|
+
name: p.name?.trim() || null,
|
|
19412
|
+
worktree: wt === 'true' ? true : wt && wt !== 'false' ? wt : false,
|
|
19413
|
+
model: p.model || null,
|
|
19414
|
+
effort: p.effort || null,
|
|
19415
|
+
prompt: p.prompt?.trim() || null,
|
|
19416
|
+
edit,
|
|
19417
|
+
stay: !edit && p.stay === 'true',
|
|
19418
|
+
extraArgs: p.args?.trim() ? p.args.trim().split(/\s+/) : [],
|
|
19419
|
+
};
|
|
19420
|
+
}
|
|
19421
|
+
|
|
19422
|
+
function newSessionFromHub(p) {
|
|
19423
|
+
if (!terminalAvailable()) return showToast('The embedded terminal is off', 'error');
|
|
19424
|
+
const v = hubNewSessionValues(p);
|
|
19425
|
+
const problem = newSessionProblem(v);
|
|
19426
|
+
if (problem) return showToast(problem, 'error');
|
|
19427
|
+
return newSessionDialogFromHub(v);
|
|
19428
|
+
}
|
|
19429
|
+
|
|
19430
|
+
// The dialog with the hub's values filled in, a folder picked (else the most recent project) and Start focused,
|
|
19431
|
+
// so the user sees what starts and one Enter starts it.
|
|
19432
|
+
async function newSessionDialogFromHub(v) {
|
|
19433
|
+
const opened = openNewSession(v.cwd);
|
|
19434
|
+
if (!opened) return;
|
|
19435
|
+
const set = (id, value) => {
|
|
19436
|
+
document.getElementById(id).value = value ?? '';
|
|
19437
|
+
};
|
|
19438
|
+
set('ns-name', v.name);
|
|
19439
|
+
set('ns-prompt', v.prompt);
|
|
19440
|
+
set('ns-model', v.model);
|
|
19441
|
+
set('ns-effort', v.effort);
|
|
19442
|
+
set('ns-wt-name', typeof v.worktree === 'string' ? v.worktree : '');
|
|
19443
|
+
document.getElementById('ns-wt').checked = v.worktree !== false;
|
|
19444
|
+
if (v.edit) document.getElementById('ns-edit').checked = true;
|
|
19445
|
+
if (v.stay) document.getElementById('ns-stay').checked = true;
|
|
19446
|
+
ns.extraArgs = v.extraArgs;
|
|
19447
|
+
renderNewSessionForm();
|
|
19448
|
+
await opened;
|
|
19449
|
+
if (!ns.folder && ns.projects[0]) setNewSessionFolder(ns.projects[0]);
|
|
19450
|
+
if (ns.folder) document.getElementById('ns-start').focus();
|
|
19451
|
+
}
|
|
19452
|
+
|
|
19453
|
+
// session.find from the hub: spoken words name a session in the list. Each session scores the share
|
|
19454
|
+
// of the words found in its name, project and branch; a clear best opens at once, else a toast
|
|
19455
|
+
// offers the closest.
|
|
19456
|
+
const FIND_MIN_SCORE = 0.6;
|
|
19457
|
+
const FIND_MIN_LEAD = 0.25;
|
|
19458
|
+
const FIND_SKIP = new Set(['the', 'an', 'my', 'session', 'on', 'in', 'of', 'for', 'to', 'and', 'with', 'about']);
|
|
19459
|
+
const findWords = (s) =>
|
|
19460
|
+
String(s ?? '')
|
|
19461
|
+
.toLowerCase()
|
|
19462
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
19463
|
+
.filter((w) => w.length > 1 && !FIND_SKIP.has(w));
|
|
19464
|
+
// Speech drops or adds endings ("test" for "tests"), so words of 4+ letters match on a shared start.
|
|
19465
|
+
const wordFound = (w, words) =>
|
|
19466
|
+
words.some((x) => x === w || (Math.min(x.length, w.length) >= 4 && (x.startsWith(w) || w.startsWith(x))));
|
|
19467
|
+
|
|
19468
|
+
function rankSessions(name) {
|
|
19469
|
+
const said = findWords(name);
|
|
19470
|
+
if (!said.length) return [];
|
|
19471
|
+
return sessions
|
|
19472
|
+
.filter((s) => !s.placeholder)
|
|
19473
|
+
.map((s) => {
|
|
19474
|
+
const words = findWords(
|
|
19475
|
+
`${sessionDisplayName(s)} ${s.project ? pathBasename(s.project) : ''} ${s.gitBranch ?? ''}`,
|
|
19476
|
+
);
|
|
19477
|
+
return { s, score: said.filter((w) => wordFound(w, words)).length / said.length };
|
|
19478
|
+
})
|
|
19479
|
+
.filter((r) => r.score > 0)
|
|
19480
|
+
.sort((a, b) => b.score - a.score || Date.parse(b.s.modifiedAt) - Date.parse(a.s.modifiedAt));
|
|
19481
|
+
}
|
|
19482
|
+
|
|
19483
|
+
function findSessionFromHub({ name }) {
|
|
19484
|
+
const ranked = rankSessions(name ?? '');
|
|
19485
|
+
const [best, next] = ranked;
|
|
19486
|
+
if (best && best.score >= FIND_MIN_SCORE && (!next || best.score - next.score >= FIND_MIN_LEAD)) {
|
|
19487
|
+
return revealSession(best.s.id);
|
|
19488
|
+
}
|
|
19489
|
+
if (isSessionPickerOpen()) closeSessionPicker({ refocus: false });
|
|
19490
|
+
openSessionPicker({ query: name?.trim() ?? '', ranked: ranked.length ? ranked.map((r) => r.s) : null });
|
|
19491
|
+
}
|
|
19492
|
+
|
|
19493
|
+
// Voice while the New session dialog is open is dictation into its prompt; Enter on Start sends it.
|
|
19494
|
+
function voiceIntoNewSession(text) {
|
|
19495
|
+
promptInserter()(text.trim());
|
|
19496
|
+
if (ns.folder) document.getElementById('ns-start').focus();
|
|
19497
|
+
}
|
|
19498
|
+
|
|
19499
|
+
// The hub's voice control sends dictated text here; with no session named, it goes to the one on screen.
|
|
19500
|
+
const newSessionOpen = () => document.getElementById('new-session-modal').classList.contains('visible');
|
|
19501
|
+
const hubTargetSession = (session) => session || (viewMode === 'session' && termState.shown && termState.sessionId);
|
|
19502
|
+
const noSessionToast = (text) =>
|
|
19503
|
+
showToast('No session on screen', 'info', { label: 'Copy', onClick: () => copyText(text, 'text') }, text);
|
|
19504
|
+
|
|
19505
|
+
async function pasteFromHub({ session, text, submit }) {
|
|
19506
|
+
if (!text) return;
|
|
19507
|
+
if (!session && newSessionOpen()) return voiceIntoNewSession(text);
|
|
19508
|
+
const id = hubTargetSession(session);
|
|
19509
|
+
if (!id) return noSessionToast(text);
|
|
19510
|
+
const res = await terminalFetch(`/api/terminal/paste/${encodeURIComponent(id)}`, 'POST', {
|
|
19511
|
+
text,
|
|
19512
|
+
submit: submit === 'true',
|
|
19513
|
+
});
|
|
19514
|
+
if (!res.ok) showToast((await res.json().catch(() => ({}))).error || `Paste failed (${res.status})`, 'error');
|
|
19515
|
+
}
|
|
19516
|
+
|
|
19517
|
+
// session.live: one key hold sends the words so far many times, then once with final. Calls run in
|
|
19518
|
+
// order, after a session.new sent just before them has opened its dialog.
|
|
19519
|
+
let hubNewSession = Promise.resolve();
|
|
19520
|
+
let liveChain = Promise.resolve();
|
|
19521
|
+
let liveText = { insert: null, typed: '', at: 0 };
|
|
19522
|
+
// A hold sends text about once a second, so a longer gap means the last hold ended without its
|
|
19523
|
+
// final call (the hub page reloaded) and its typed words are not this hold's to erase.
|
|
19524
|
+
const LIVE_GAP_MS = 10_000;
|
|
19525
|
+
|
|
19526
|
+
function setNewSessionVoice(on) {
|
|
19527
|
+
const modal = document.querySelector('.new-session-modal');
|
|
19528
|
+
setModalVoice(modal, [document.getElementById('ns-prompt')], on ? 'listening' : '', () => setNewSessionVoice(false));
|
|
19529
|
+
}
|
|
19530
|
+
|
|
19531
|
+
// `cancel` comes with an empty final text, which removes what the hold typed; "close" also closes
|
|
19532
|
+
// the New session dialog that the hold opened.
|
|
19533
|
+
async function liveFromHub({ session, text, final, submit, cancel }) {
|
|
19534
|
+
await hubNewSession;
|
|
19535
|
+
const done = final === 'true';
|
|
19536
|
+
if (Date.now() - liveText.at > LIVE_GAP_MS) liveText = { insert: null, typed: '', at: 0 };
|
|
19537
|
+
liveText.at = Date.now();
|
|
19538
|
+
const hold = liveText;
|
|
19539
|
+
if (done) liveText = { insert: null, typed: '', at: 0 };
|
|
19540
|
+
if (!session && newSessionOpen()) {
|
|
19541
|
+
hold.insert ??= promptInserter();
|
|
19542
|
+
hold.insert(text.trim());
|
|
19543
|
+
const prompt = document.getElementById('ns-prompt');
|
|
19544
|
+
prompt.scrollTop = prompt.scrollHeight;
|
|
19545
|
+
setNewSessionVoice(!done);
|
|
19546
|
+
if (cancel === 'close') closeNewSession();
|
|
19547
|
+
else if (done && !cancel && ns.folder) document.getElementById('ns-start').focus();
|
|
19548
|
+
return;
|
|
19549
|
+
}
|
|
19550
|
+
const id = hubTargetSession(session);
|
|
19551
|
+
if (!id) {
|
|
19552
|
+
if (done && text) noSessionToast(text);
|
|
19553
|
+
return;
|
|
19554
|
+
}
|
|
19555
|
+
const res = await terminalFetch(`/api/terminal/type/${encodeURIComponent(id)}`, 'POST', {
|
|
19556
|
+
from: hold.typed,
|
|
19557
|
+
text,
|
|
19558
|
+
submit: done && submit === 'true',
|
|
19559
|
+
});
|
|
19560
|
+
if (res.ok) hold.typed = text;
|
|
19561
|
+
else if (done) showToast((await res.json().catch(() => ({}))).error || `Typing failed (${res.status})`, 'error');
|
|
19562
|
+
}
|
|
19138
19563
|
|
|
19139
19564
|
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
19140
19565
|
let publishTimer = 0;
|
package/public/index.html
CHANGED
|
@@ -814,6 +814,10 @@
|
|
|
814
814
|
<div class="modal new-session-modal" onclick="event.stopPropagation()" role="dialog" aria-labelledby="ns-title">
|
|
815
815
|
<div class="modal-header">
|
|
816
816
|
<h3 class="modal-title" id="ns-title">New session</h3>
|
|
817
|
+
<span id="ns-voice" class="voice-mic" role="status" hidden>
|
|
818
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>
|
|
819
|
+
<span>Listening</span>
|
|
820
|
+
</span>
|
|
817
821
|
<button class="modal-close" aria-label="Close dialog" onclick="closeNewSession()">
|
|
818
822
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
819
823
|
</button>
|
|
@@ -883,6 +887,10 @@
|
|
|
883
887
|
</div>
|
|
884
888
|
</div>
|
|
885
889
|
</div>
|
|
890
|
+
<div id="ns-args-row" class="ns-row" hidden>
|
|
891
|
+
<span class="ns-label">Args</span>
|
|
892
|
+
<div class="ns-field"><code id="ns-args" class="ns-hint" title="Claude arguments this session starts with"></code></div>
|
|
893
|
+
</div>
|
|
886
894
|
<div id="ns-error" class="ns-error" hidden></div>
|
|
887
895
|
</div>
|
|
888
896
|
<div class="modal-footer ns-footer">
|
|
@@ -895,6 +903,41 @@
|
|
|
895
903
|
</div>
|
|
896
904
|
</div>
|
|
897
905
|
|
|
906
|
+
<div id="task-modal" class="modal-overlay top-aligned" onclick="closeTaskDialog()">
|
|
907
|
+
<div class="modal task-modal" onclick="event.stopPropagation()" role="dialog" aria-labelledby="task-modal-title">
|
|
908
|
+
<div class="modal-header">
|
|
909
|
+
<h3 class="modal-title" id="task-modal-title">Add task <span id="task-modal-session" class="task-modal-session"></span></h3>
|
|
910
|
+
<span id="task-modal-mic" class="voice-mic" role="status" hidden>
|
|
911
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>
|
|
912
|
+
<span id="task-modal-mic-label">Listening</span>
|
|
913
|
+
</span>
|
|
914
|
+
<button class="modal-close" aria-label="Close dialog" onclick="closeTaskDialog()">
|
|
915
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
916
|
+
</button>
|
|
917
|
+
</div>
|
|
918
|
+
<div class="modal-body ns-body">
|
|
919
|
+
<div class="ns-row">
|
|
920
|
+
<label class="ns-label" for="task-subject">Title</label>
|
|
921
|
+
<div class="ns-field">
|
|
922
|
+
<input type="text" id="task-subject" class="form-input" placeholder="What to do" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true">
|
|
923
|
+
</div>
|
|
924
|
+
</div>
|
|
925
|
+
<div class="ns-row">
|
|
926
|
+
<label class="ns-label" for="task-description">Description</label>
|
|
927
|
+
<div class="ns-field">
|
|
928
|
+
<textarea id="task-description" class="form-input" rows="4" placeholder="Optional"></textarea>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
<div id="task-error" class="ns-error" hidden></div>
|
|
932
|
+
</div>
|
|
933
|
+
<div class="modal-footer ns-footer">
|
|
934
|
+
<span class="task-modal-hint"><kbd>Enter</kbd> adds · <kbd>Shift</kbd>+<kbd>Enter</kbd> new line</span>
|
|
935
|
+
<button type="button" class="btn btn-secondary" onclick="closeTaskDialog()">Cancel</button>
|
|
936
|
+
<button type="button" id="task-save" class="btn btn-primary" onclick="saveTaskDialog()" disabled>Add</button>
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
|
|
898
941
|
<div id="pane-pop" class="modal-overlay" onclick="closePanePop()">
|
|
899
942
|
<div class="modal pane-pop-dialog" role="dialog" aria-labelledby="pane-pop-title" onclick="event.stopPropagation()">
|
|
900
943
|
<div class="modal-header">
|
package/public/style.css
CHANGED
|
@@ -8675,7 +8675,8 @@ body.light .msg-assistant .msg-text {
|
|
|
8675
8675
|
color: var(--text-muted);
|
|
8676
8676
|
}
|
|
8677
8677
|
|
|
8678
|
-
.confirm-hint kbd
|
|
8678
|
+
.confirm-hint kbd,
|
|
8679
|
+
.task-modal-hint kbd {
|
|
8679
8680
|
font-family: var(--mono);
|
|
8680
8681
|
font-size: 10px;
|
|
8681
8682
|
border: 1px solid var(--border);
|
|
@@ -9623,6 +9624,10 @@ select.form-input option:checked {
|
|
|
9623
9624
|
gap: 12px;
|
|
9624
9625
|
}
|
|
9625
9626
|
|
|
9627
|
+
.ns-row[hidden] {
|
|
9628
|
+
display: none;
|
|
9629
|
+
}
|
|
9630
|
+
|
|
9626
9631
|
.new-session-modal.ns-resume .ns-extra {
|
|
9627
9632
|
display: none;
|
|
9628
9633
|
}
|
|
@@ -9797,7 +9802,9 @@ select.form-input option:checked {
|
|
|
9797
9802
|
}
|
|
9798
9803
|
|
|
9799
9804
|
.ns-body textarea.form-input {
|
|
9800
|
-
|
|
9805
|
+
field-sizing: content;
|
|
9806
|
+
min-height: 86px;
|
|
9807
|
+
max-height: 50vh;
|
|
9801
9808
|
}
|
|
9802
9809
|
|
|
9803
9810
|
.ns-prompt-field {
|
|
@@ -9977,6 +9984,132 @@ select.form-input option:checked {
|
|
|
9977
9984
|
}
|
|
9978
9985
|
/* #endregion */
|
|
9979
9986
|
|
|
9987
|
+
/* #region TASK_DIALOG */
|
|
9988
|
+
.modal.task-modal {
|
|
9989
|
+
width: 560px;
|
|
9990
|
+
max-width: calc(100vw - 32px);
|
|
9991
|
+
}
|
|
9992
|
+
|
|
9993
|
+
.task-modal-session {
|
|
9994
|
+
margin-left: 6px;
|
|
9995
|
+
font-size: 12px;
|
|
9996
|
+
font-weight: 400;
|
|
9997
|
+
color: var(--text-tertiary);
|
|
9998
|
+
}
|
|
9999
|
+
|
|
10000
|
+
.task-modal .ns-label {
|
|
10001
|
+
white-space: nowrap;
|
|
10002
|
+
}
|
|
10003
|
+
|
|
10004
|
+
.modal[data-voice] {
|
|
10005
|
+
border-color: color-mix(in srgb, var(--accent) 60%, var(--border));
|
|
10006
|
+
animation: voice-glow 1.6s ease-in-out infinite;
|
|
10007
|
+
}
|
|
10008
|
+
|
|
10009
|
+
@keyframes voice-glow {
|
|
10010
|
+
0%,
|
|
10011
|
+
100% {
|
|
10012
|
+
box-shadow:
|
|
10013
|
+
0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent),
|
|
10014
|
+
0 0 18px color-mix(in srgb, var(--accent) 18%, transparent);
|
|
10015
|
+
}
|
|
10016
|
+
50% {
|
|
10017
|
+
box-shadow:
|
|
10018
|
+
0 0 0 1px color-mix(in srgb, var(--accent) 60%, transparent),
|
|
10019
|
+
0 0 34px color-mix(in srgb, var(--accent) 35%, transparent);
|
|
10020
|
+
}
|
|
10021
|
+
}
|
|
10022
|
+
|
|
10023
|
+
.voice-mic {
|
|
10024
|
+
display: inline-flex;
|
|
10025
|
+
align-items: center;
|
|
10026
|
+
gap: 6px;
|
|
10027
|
+
margin-left: auto;
|
|
10028
|
+
margin-right: 8px;
|
|
10029
|
+
padding: 3px 10px 3px 8px;
|
|
10030
|
+
border-radius: 999px;
|
|
10031
|
+
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
10032
|
+
color: var(--accent);
|
|
10033
|
+
font-size: 12px;
|
|
10034
|
+
font-weight: 500;
|
|
10035
|
+
}
|
|
10036
|
+
|
|
10037
|
+
.voice-mic[hidden] {
|
|
10038
|
+
display: none;
|
|
10039
|
+
}
|
|
10040
|
+
|
|
10041
|
+
.voice-mic svg {
|
|
10042
|
+
width: 14px;
|
|
10043
|
+
height: 14px;
|
|
10044
|
+
animation: voice-mic 1.2s ease-in-out infinite;
|
|
10045
|
+
}
|
|
10046
|
+
|
|
10047
|
+
@keyframes voice-mic {
|
|
10048
|
+
50% {
|
|
10049
|
+
opacity: 0.4;
|
|
10050
|
+
}
|
|
10051
|
+
}
|
|
10052
|
+
|
|
10053
|
+
.modal[data-voice] .form-input[readonly] {
|
|
10054
|
+
cursor: default;
|
|
10055
|
+
}
|
|
10056
|
+
|
|
10057
|
+
.task-modal-hint {
|
|
10058
|
+
margin-right: auto;
|
|
10059
|
+
font-size: 11px;
|
|
10060
|
+
color: var(--text-muted);
|
|
10061
|
+
}
|
|
10062
|
+
|
|
10063
|
+
.task-modal .form-input.task-filled {
|
|
10064
|
+
animation: task-filled 0.6s ease-out;
|
|
10065
|
+
}
|
|
10066
|
+
|
|
10067
|
+
@keyframes task-filled {
|
|
10068
|
+
from {
|
|
10069
|
+
border-color: var(--accent);
|
|
10070
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
|
|
10071
|
+
}
|
|
10072
|
+
}
|
|
10073
|
+
|
|
10074
|
+
.task-modal .form-input.task-moved {
|
|
10075
|
+
animation: task-moved 0.35s ease-out;
|
|
10076
|
+
}
|
|
10077
|
+
|
|
10078
|
+
@keyframes task-moved {
|
|
10079
|
+
from {
|
|
10080
|
+
opacity: 0.2;
|
|
10081
|
+
transform: translateY(48px);
|
|
10082
|
+
}
|
|
10083
|
+
}
|
|
10084
|
+
|
|
10085
|
+
.task-modal .form-input.task-titling {
|
|
10086
|
+
background:
|
|
10087
|
+
linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--accent) 14%, transparent) 50%, transparent 100%) 0 0
|
|
10088
|
+
/ 200% 100% var(--bg-elevated);
|
|
10089
|
+
animation: task-titling 1.2s linear infinite;
|
|
10090
|
+
cursor: progress;
|
|
10091
|
+
}
|
|
10092
|
+
|
|
10093
|
+
@keyframes task-titling {
|
|
10094
|
+
from {
|
|
10095
|
+
background-position: 100% 0;
|
|
10096
|
+
}
|
|
10097
|
+
to {
|
|
10098
|
+
background-position: -100% 0;
|
|
10099
|
+
}
|
|
10100
|
+
}
|
|
10101
|
+
|
|
10102
|
+
@media (prefers-reduced-motion: reduce) {
|
|
10103
|
+
.task-modal .form-input.task-filled,
|
|
10104
|
+
.task-modal .form-input.task-moved,
|
|
10105
|
+
.task-modal .form-input.task-titling,
|
|
10106
|
+
.modal[data-voice],
|
|
10107
|
+
.voice-mic svg {
|
|
10108
|
+
animation: none;
|
|
10109
|
+
}
|
|
10110
|
+
}
|
|
10111
|
+
/* #endregion */
|
|
10112
|
+
|
|
9980
10113
|
/* #region TOOL_STATS */
|
|
9981
10114
|
.modal.tool-stats-modal {
|
|
9982
10115
|
width: 90%;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// claude-hub-sdk 1.
|
|
1
|
+
// claude-hub-sdk 1.9.0 (sha256 537745aa3415). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
|
|
2
2
|
// Claude Code Hub SDK: the one rule that names a key press, shared by the hub page, client.js and
|
|
3
3
|
// stub.js. The hub serves it to its page as /sdk/keys.js; mount() and sdk:sync put it in front of
|
|
4
4
|
// client.js and stub.js, so an app still loads one file.
|
|
@@ -18,7 +18,7 @@ var ClaudeHubKeys = (() => {
|
|
|
18
18
|
function comboOf(e) {
|
|
19
19
|
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
20
20
|
const m = !e.getModifierState?.('AltGraph') && /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
21
|
-
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
21
|
+
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key === ' ' ? 'Space' : e.key;
|
|
22
22
|
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
23
23
|
return [...mods, key].filter(Boolean).join('+');
|
|
24
24
|
}
|
package/server.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
4
|
+
const { runInstall, runUninstall } = require("./install");
|
|
5
|
+
const yes = process.argv.includes("--yes");
|
|
6
|
+
(process.argv.includes("--install") ? runInstall({ yes }) : runUninstall())
|
|
7
|
+
.then(() => process.exit(0))
|
|
8
|
+
.catch(e => { console.error(e.message); process.exit(1); });
|
|
9
|
+
} else if (!require("./cli").runCli(process.argv)) {
|
|
10
|
+
startServer();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Not indented: the formatter is off for this file, and a reindent would rewrite every line.
|
|
14
|
+
// The requires sit inside, so a CLI call does not load the server's modules.
|
|
15
|
+
function startServer() {
|
|
16
|
+
|
|
3
17
|
// #region SETUP
|
|
4
18
|
const express = require('express');
|
|
5
19
|
const path = require('node:path');
|
|
@@ -79,19 +93,6 @@ const { foldAgentLines } = require('./lib/agent-log');
|
|
|
79
93
|
const { projectMatcher, normalizeProjectPath, sessionProjectKey } = require('./public/project-match');
|
|
80
94
|
const { getParentVerdict, setParentVerdict, peekParentVerdict } = require('./lib/parent-cache');
|
|
81
95
|
|
|
82
|
-
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
83
|
-
const { runInstall, runUninstall } = require("./install");
|
|
84
|
-
const yes = process.argv.includes("--yes");
|
|
85
|
-
(process.argv.includes("--install") ? runInstall({ yes }) : runUninstall())
|
|
86
|
-
.then(() => process.exit(0))
|
|
87
|
-
.catch(e => { console.error(e.message); process.exit(1); });
|
|
88
|
-
} else if (!require("./cli").runCli(process.argv)) {
|
|
89
|
-
startServer();
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Not indented: the formatter is off for this file, and a reindent would rewrite every line.
|
|
93
|
-
function startServer() {
|
|
94
|
-
|
|
95
96
|
const app = express();
|
|
96
97
|
const PORT = parseInt(getArgValue('port') || process.env.PORT || '3541', 10);
|
|
97
98
|
|
|
@@ -3392,6 +3393,35 @@ app.post('/api/terminal/launched/:id', (req, res) => {
|
|
|
3392
3393
|
res.status(204).end();
|
|
3393
3394
|
});
|
|
3394
3395
|
|
|
3396
|
+
// The hub's session.paste action: text into a session's input box, with Enter when `submit`.
|
|
3397
|
+
// claude queues a prompt sent while it works. Refused (409) only on a dialog, where the keys would answer it:
|
|
3398
|
+
// the screen check misses a permission prompt with no "Enter to confirm", so the waiting marker is checked too.
|
|
3399
|
+
app.post('/api/terminal/paste/:id', terminalRoute(async (req, res) => {
|
|
3400
|
+
if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
3401
|
+
const { text, submit } = req.body || {};
|
|
3402
|
+
if (typeof text !== 'string' || !text.trim()) return res.status(400).json({ error: 'text is required' });
|
|
3403
|
+
const id = req.params.id;
|
|
3404
|
+
if (!terminal.isRunning(id)) return res.status(404).json({ error: 'no running terminal for this session' });
|
|
3405
|
+
if (isWaitingOnUser(id) || !(await terminal.paste(id, text, submit === true))) {
|
|
3406
|
+
return res.status(409).json({ error: 'the session shows a dialog' });
|
|
3407
|
+
}
|
|
3408
|
+
res.status(204).end();
|
|
3409
|
+
}));
|
|
3410
|
+
|
|
3411
|
+
// The hub's session.live action: dictated words typed into the input box as they come.
|
|
3412
|
+
// `from` is what the last call typed; the box loses what differs and gets the rest.
|
|
3413
|
+
app.post('/api/terminal/type/:id', terminalRoute(async (req, res) => {
|
|
3414
|
+
if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
3415
|
+
const { from = '', text, submit } = req.body || {};
|
|
3416
|
+
if (typeof text !== 'string' || typeof from !== 'string') return res.status(400).json({ error: 'text is required' });
|
|
3417
|
+
const id = req.params.id;
|
|
3418
|
+
if (!terminal.isRunning(id)) return res.status(404).json({ error: 'no running terminal for this session' });
|
|
3419
|
+
if (isWaitingOnUser(id) || !(await terminal.type(id, from, text, submit === true))) {
|
|
3420
|
+
return res.status(409).json({ error: 'the session shows a dialog' });
|
|
3421
|
+
}
|
|
3422
|
+
res.status(204).end();
|
|
3423
|
+
}));
|
|
3424
|
+
|
|
3395
3425
|
// The New session dialog's "Stay here": the PTY starts headless and the board keeps its view.
|
|
3396
3426
|
// `show` is `claude-code-kanban new` from a shell outside the board: a board tab adds its card
|
|
3397
3427
|
// and attaches, and with no tab to claim it the PTY is ended, because no one would see it.
|