@bakapiano/ccsm 0.22.0 → 0.22.2
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/CLAUDE.md +2 -0
- package/lib/persistedSessions.js +5 -1
- package/package.json +1 -1
- package/public/css/terminals.css +38 -1
- package/public/js/api.js +14 -0
- package/public/js/components/Sidebar.js +3 -4
- package/public/js/components/TerminalInstance.js +266 -0
- package/public/js/components/TerminalView.js +22 -524
- package/public/js/components/XtermTerminal.js +198 -0
- package/public/js/icons.js +8 -0
- package/public/js/pages/SessionsPage.js +106 -8
- package/server.js +62 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// VS Code-style xterm wrapper. Owns the raw xterm.js terminal, renderer
|
|
2
|
+
// addons, theme application, and fit/refresh behavior. It intentionally does
|
|
3
|
+
// not know about ccsm sessions or WebSockets.
|
|
4
|
+
|
|
5
|
+
import { Terminal } from '@xterm/xterm';
|
|
6
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
7
|
+
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
8
|
+
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
|
9
|
+
import { WebglAddon } from '@xterm/addon-webgl';
|
|
10
|
+
import { isDarkTheme } from '../state.js';
|
|
11
|
+
|
|
12
|
+
// Dark xterm theme - VSCode's Dark+ terminal palette, verbatim (see
|
|
13
|
+
// microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts).
|
|
14
|
+
const THEME_DARK = {
|
|
15
|
+
background: '#1e1e1e',
|
|
16
|
+
foreground: '#cccccc',
|
|
17
|
+
cursor: '#aeafad',
|
|
18
|
+
cursorAccent: '#1e1e1e',
|
|
19
|
+
selectionBackground: '#264f78',
|
|
20
|
+
black: '#000000', brightBlack: '#666666',
|
|
21
|
+
red: '#cd3131', brightRed: '#f14c4c',
|
|
22
|
+
green: '#0dbc79', brightGreen: '#23d18b',
|
|
23
|
+
yellow: '#e5e510', brightYellow: '#f5f543',
|
|
24
|
+
blue: '#2472c8', brightBlue: '#3b8eea',
|
|
25
|
+
magenta: '#bc3fbc', brightMagenta: '#d670d6',
|
|
26
|
+
cyan: '#11a8cd', brightCyan: '#29b8db',
|
|
27
|
+
white: '#e5e5e5', brightWhite: '#e5e5e5',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Light xterm theme - VSCode's Light+ terminal palette, verbatim (see
|
|
31
|
+
// microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts).
|
|
32
|
+
const THEME_LIGHT = {
|
|
33
|
+
background: '#ffffff',
|
|
34
|
+
foreground: '#333333',
|
|
35
|
+
cursor: '#000000',
|
|
36
|
+
cursorAccent: '#ffffff',
|
|
37
|
+
selectionBackground: '#add6ff',
|
|
38
|
+
black: '#000000', brightBlack: '#666666',
|
|
39
|
+
red: '#cd3131', brightRed: '#cd3131',
|
|
40
|
+
green: '#107c10', brightGreen: '#14ce14',
|
|
41
|
+
yellow: '#949800', brightYellow: '#b5ba00',
|
|
42
|
+
blue: '#0451a5', brightBlue: '#0451a5',
|
|
43
|
+
magenta: '#bc05bc', brightMagenta: '#bc05bc',
|
|
44
|
+
cyan: '#0598bc', brightCyan: '#0598bc',
|
|
45
|
+
white: '#555555', brightWhite: '#a5a5a5',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const themeFor = (dark) => (dark ? THEME_DARK : THEME_LIGHT);
|
|
49
|
+
|
|
50
|
+
export class XtermTerminal {
|
|
51
|
+
constructor() {
|
|
52
|
+
this.isMobile = window.matchMedia('(max-width: 640px)').matches;
|
|
53
|
+
this.currentTheme = themeFor(isDarkTheme());
|
|
54
|
+
this.fitAddon = new FitAddon();
|
|
55
|
+
this.webglAddon = null;
|
|
56
|
+
this.host = null;
|
|
57
|
+
|
|
58
|
+
this.raw = new Terminal({
|
|
59
|
+
fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
|
|
60
|
+
fontSize: this.isMobile ? 11 : 13,
|
|
61
|
+
lineHeight: 1.2,
|
|
62
|
+
cursorBlink: true,
|
|
63
|
+
cursorStyle: 'bar',
|
|
64
|
+
scrollback: 5000,
|
|
65
|
+
allowProposedApi: true,
|
|
66
|
+
theme: this.currentTheme,
|
|
67
|
+
// Same modern keyboard protocols VS Code enables when configured.
|
|
68
|
+
vtExtensions: {
|
|
69
|
+
kittyKeyboard: true,
|
|
70
|
+
win32InputMode: true,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
this.raw.loadAddon(this.fitAddon);
|
|
75
|
+
this.raw.loadAddon(new WebLinksAddon());
|
|
76
|
+
this.raw.loadAddon(new ClipboardAddon());
|
|
77
|
+
this._loadRendererAddon();
|
|
78
|
+
this._installSelectionCopyGuard();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get cols() { return this.raw.cols; }
|
|
82
|
+
get rows() { return this.raw.rows; }
|
|
83
|
+
get theme() { return this.currentTheme; }
|
|
84
|
+
get parser() { return this.raw.parser; }
|
|
85
|
+
get helperTextarea() {
|
|
86
|
+
return this.host?.querySelector('.xterm-helper-textarea') || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
attachToElement(host) {
|
|
90
|
+
this.host = host;
|
|
91
|
+
this.raw.open(host);
|
|
92
|
+
this.scheduleFit();
|
|
93
|
+
try {
|
|
94
|
+
document.fonts?.ready?.then(() => {
|
|
95
|
+
if (this.host === host) this.scheduleFit();
|
|
96
|
+
});
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
applyResolvedTheme() {
|
|
101
|
+
const theme = themeFor(isDarkTheme());
|
|
102
|
+
this.currentTheme = theme;
|
|
103
|
+
try { this.raw.options.theme = theme; } catch {}
|
|
104
|
+
return theme;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setCursorVisible(visible) {
|
|
108
|
+
if (visible) {
|
|
109
|
+
try { this.raw.options.theme = this.currentTheme; } catch {}
|
|
110
|
+
try { this.raw.write('\x1b[?25h'); } catch {}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
this.raw.options.theme = {
|
|
115
|
+
...this.currentTheme,
|
|
116
|
+
cursor: 'transparent',
|
|
117
|
+
cursorAccent: 'transparent',
|
|
118
|
+
};
|
|
119
|
+
} catch {}
|
|
120
|
+
try { this.raw.write('\x1b[?25l'); } catch {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
scheduleFit() {
|
|
124
|
+
this.fit();
|
|
125
|
+
requestAnimationFrame(() => {
|
|
126
|
+
this.fit();
|
|
127
|
+
setTimeout(() => this.fit(), 60);
|
|
128
|
+
setTimeout(() => this.fit(), 200);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fit() {
|
|
133
|
+
try { this.fitAddon.fit(); } catch {}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
refresh() {
|
|
137
|
+
try { this.raw.refresh(0, this.raw.rows - 1); } catch {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
clearTextureAtlas() {
|
|
141
|
+
try { this.raw.clearTextureAtlas?.(); } catch {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
write(data, callback) {
|
|
145
|
+
try { this.raw.write(data, callback); } catch {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
reset() {
|
|
149
|
+
try { this.raw.reset(); } catch {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
focus() {
|
|
153
|
+
try { this.raw.focus(); } catch {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
onData(listener) {
|
|
157
|
+
return this.raw.onData(listener);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
onResize(listener) {
|
|
161
|
+
return this.raw.onResize(listener);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
hasSelection() {
|
|
165
|
+
return this.raw.hasSelection();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
dispose() {
|
|
169
|
+
this.host = null;
|
|
170
|
+
try { this.raw.dispose(); } catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_loadRendererAddon() {
|
|
174
|
+
// Keep the current mobile guard: @xterm/addon-webgl@0.18 can mis-measure
|
|
175
|
+
// glyph atlases on fractional mobile DPRs.
|
|
176
|
+
if (this.isMobile) return;
|
|
177
|
+
try {
|
|
178
|
+
const webgl = new WebglAddon();
|
|
179
|
+
this.webglAddon = webgl;
|
|
180
|
+
webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
|
|
181
|
+
this.raw.loadAddon(webgl);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_installSelectionCopyGuard() {
|
|
188
|
+
this.raw.attachCustomKeyEventHandler((ev) => {
|
|
189
|
+
if (ev.type === 'keydown'
|
|
190
|
+
&& ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey
|
|
191
|
+
&& ev.key.toLowerCase() === 'c'
|
|
192
|
+
&& this.raw.hasSelection()) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
package/public/js/icons.js
CHANGED
|
@@ -141,6 +141,14 @@ export const IconMoreVert = ic('0 0 24 24', html`
|
|
|
141
141
|
<circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/>
|
|
142
142
|
`, 16);
|
|
143
143
|
|
|
144
|
+
export const IconPlay = ic('0 0 24 24', html`
|
|
145
|
+
<polygon points="8 5 19 12 8 19 8 5"/>
|
|
146
|
+
`, 14);
|
|
147
|
+
|
|
148
|
+
export const IconStop = ic('0 0 24 24', html`
|
|
149
|
+
<rect x="7" y="7" width="10" height="10" rx="1.5"/>
|
|
150
|
+
`, 14);
|
|
151
|
+
|
|
144
152
|
// Broadcast / remote — radiating arcs over a centre dot. Used on the
|
|
145
153
|
// Remote nav tab; reads as "this machine is broadcasting" / "remote
|
|
146
154
|
// access available".
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import { html } from '../html.js';
|
|
8
8
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
9
9
|
import { activeSessionId, sessions, config, selectTab, selectSession, clockTick } from '../state.js';
|
|
10
|
-
import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle, openSessionInEditor } from '../api.js';
|
|
10
|
+
import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle, switchSessionCli, stopSession, openSessionInEditor } from '../api.js';
|
|
11
11
|
import { setToast } from '../toast.js';
|
|
12
12
|
import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
|
|
13
13
|
import { TerminalView } from '../components/TerminalView.js';
|
|
14
14
|
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
15
15
|
import { Popover } from '../components/Popover.js';
|
|
16
|
-
import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal, IconExternal } from '../icons.js';
|
|
16
|
+
import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal, IconExternal, IconPlay, IconStop } from '../icons.js';
|
|
17
17
|
import { fmtAgo } from '../util.js';
|
|
18
18
|
|
|
19
19
|
function SessionTabs({ activeId, onActivate, onNew, kebab }) {
|
|
@@ -51,7 +51,7 @@ function SessionTabs({ activeId, onActivate, onNew, kebab }) {
|
|
|
51
51
|
</div>`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
|
|
54
|
+
function SessionMenu({ session, switchableClis, onRename, onDelete, onOpenEditor, onSwitchCli }) {
|
|
55
55
|
const [open, setOpen] = useState(false);
|
|
56
56
|
const anchor = useRef(null);
|
|
57
57
|
return html`
|
|
@@ -67,6 +67,18 @@ function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
|
|
|
67
67
|
<button class="session-menu-item" onClick=${() => { setOpen(false); onOpenEditor(); }}>
|
|
68
68
|
<${IconExternal} /> Open in editor
|
|
69
69
|
</button>
|
|
70
|
+
${switchableClis.length ? html`
|
|
71
|
+
<div class="session-menu-separator"></div>
|
|
72
|
+
<div class="session-menu-label">Switch CLI</div>
|
|
73
|
+
${switchableClis.map((target) => {
|
|
74
|
+
const TargetIcon = IconForCliType(target.type) || IconTerminal;
|
|
75
|
+
return html`
|
|
76
|
+
<button class="session-menu-item" key=${target.id}
|
|
77
|
+
onClick=${() => { setOpen(false); onSwitchCli(target); }}>
|
|
78
|
+
<${TargetIcon} /> Switch to ${target.name}
|
|
79
|
+
</button>`;
|
|
80
|
+
})}
|
|
81
|
+
` : null}
|
|
70
82
|
<button class="session-menu-item" onClick=${() => { setOpen(false); onRename(); }}>
|
|
71
83
|
<${IconPencil} /> Rename
|
|
72
84
|
</button>
|
|
@@ -77,12 +89,35 @@ function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
|
|
|
77
89
|
</${Popover}>` : null}`;
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
function SessionControls({ running, busy, onStop, onResume }) {
|
|
93
|
+
return html`
|
|
94
|
+
<div class="session-controls">
|
|
95
|
+
${running ? html`
|
|
96
|
+
<button class="session-menu-btn session-control-btn danger" type="button"
|
|
97
|
+
title="Stop session" aria-label="Stop session"
|
|
98
|
+
disabled=${busy}
|
|
99
|
+
onClick=${onStop}>
|
|
100
|
+
<${IconStop} />
|
|
101
|
+
</button>
|
|
102
|
+
` : html`
|
|
103
|
+
<button class="session-menu-btn session-control-btn" type="button"
|
|
104
|
+
title=${busy ? 'Resuming session' : 'Resume session'}
|
|
105
|
+
aria-label=${busy ? 'Resuming session' : 'Resume session'}
|
|
106
|
+
disabled=${busy}
|
|
107
|
+
onClick=${onResume}>
|
|
108
|
+
<${IconPlay} />
|
|
109
|
+
</button>
|
|
110
|
+
`}
|
|
111
|
+
</div>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
80
114
|
export function SessionsPage() {
|
|
81
115
|
clockTick.value; // resubscribe fmtAgo
|
|
82
116
|
const id = activeSessionId.value;
|
|
83
117
|
const list = sessions.value;
|
|
84
118
|
const session = id ? list.find((s) => s.id === id) : null;
|
|
85
119
|
const [resumeError, setResumeError] = useState(null);
|
|
120
|
+
const [actionBusy, setActionBusy] = useState(false);
|
|
86
121
|
// Bumps to force the auto-resume effect to re-run on Retry without
|
|
87
122
|
// mutating any signal. Primitive in the dep array → identity changes.
|
|
88
123
|
const [retryNonce, setRetryNonce] = useState(0);
|
|
@@ -100,22 +135,50 @@ export function SessionsPage() {
|
|
|
100
135
|
useEffect(() => {
|
|
101
136
|
if (!session) return;
|
|
102
137
|
if (session.status === 'running') { setResumeError(null); return; }
|
|
138
|
+
if (session.manualStopped) { setResumeError(null); return; }
|
|
103
139
|
setResumeError(null);
|
|
104
140
|
resumeSession(session.id)
|
|
105
141
|
.then((launched) => { if (launched?.id) selectSession(launched.id); })
|
|
106
142
|
.catch((e) => { setResumeError(e.message); setToast(e.message, 'error'); });
|
|
107
|
-
}, [session?.id, session?.status, retryNonce]);
|
|
143
|
+
}, [session?.id, session?.status, session?.cliId, session?.manualStopped, retryNonce]);
|
|
108
144
|
|
|
109
145
|
if (!session) return null;
|
|
110
146
|
|
|
111
147
|
const cli = (config.value?.clis || []).find((c) => c.id === session.cliId);
|
|
148
|
+
const switchableClis = cli
|
|
149
|
+
? (config.value?.clis || []).filter((c) => c.id !== cli.id && c.type === cli.type)
|
|
150
|
+
: [];
|
|
112
151
|
const running = session.status === 'running';
|
|
113
152
|
const title = session.title || session.workspace || session.id.slice(0, 12);
|
|
114
153
|
|
|
115
|
-
const
|
|
154
|
+
const onResume = async () => {
|
|
116
155
|
clearResumeFailure(session.id);
|
|
117
156
|
setResumeError(null);
|
|
118
|
-
|
|
157
|
+
setActionBusy(true);
|
|
158
|
+
try {
|
|
159
|
+
const launched = await resumeSession(session.id);
|
|
160
|
+
if (launched?.id) selectSession(launched.id);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
setResumeError(e.message);
|
|
163
|
+
setToast(e.message, 'error');
|
|
164
|
+
} finally {
|
|
165
|
+
setActionBusy(false);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const onRetry = () => {
|
|
169
|
+
onResume();
|
|
170
|
+
};
|
|
171
|
+
const onStop = async () => {
|
|
172
|
+
setActionBusy(true);
|
|
173
|
+
try {
|
|
174
|
+
await stopSession(session.id);
|
|
175
|
+
setResumeError(null);
|
|
176
|
+
setToast('Session stopped');
|
|
177
|
+
} catch (e) {
|
|
178
|
+
setToast(e.message, 'error');
|
|
179
|
+
} finally {
|
|
180
|
+
setActionBusy(false);
|
|
181
|
+
}
|
|
119
182
|
};
|
|
120
183
|
const onRename = async () => {
|
|
121
184
|
const next = await ccsmPrompt('Rename session', title, { okLabel: 'Save' });
|
|
@@ -138,6 +201,26 @@ export function SessionsPage() {
|
|
|
138
201
|
setToast(`Opening in ${r?.editor || 'editor'}…`);
|
|
139
202
|
} catch (e) { setToast(e.message, 'error'); }
|
|
140
203
|
};
|
|
204
|
+
const onSwitchCli = async (target) => {
|
|
205
|
+
const fromName = cli?.name || session.cliId;
|
|
206
|
+
if (running) {
|
|
207
|
+
const ok = await ccsmConfirm(
|
|
208
|
+
`Switch ${title} from ${fromName} to ${target.name}? The running terminal keeps its current process; ${target.name} is used next time this session resumes.`,
|
|
209
|
+
{ title: 'Switch CLI', okLabel: 'Switch' },
|
|
210
|
+
);
|
|
211
|
+
if (!ok) return;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const r = await switchSessionCli(session.id, target.id);
|
|
215
|
+
setToast(r.running
|
|
216
|
+
? `CLI switched to ${target.name} for next resume`
|
|
217
|
+
: `CLI switched to ${target.name}`);
|
|
218
|
+
if (!running && !session.manualStopped) {
|
|
219
|
+
clearResumeFailure(session.id);
|
|
220
|
+
setRetryNonce((n) => n + 1);
|
|
221
|
+
}
|
|
222
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
223
|
+
};
|
|
141
224
|
|
|
142
225
|
return html`
|
|
143
226
|
<${PageTitleBar} title=${html`
|
|
@@ -148,14 +231,24 @@ export function SessionsPage() {
|
|
|
148
231
|
<span>${cli ? cli.name : session.cliId}</span>
|
|
149
232
|
${session.repos.length ? html`<span>·</span><span>${session.repos.join(', ')}</span>` : null}
|
|
150
233
|
<span>·</span>
|
|
151
|
-
<span>${running ? 'running' : (resumeError ? 'resume failed' : 'resuming…')}</span>
|
|
234
|
+
<span>${running ? 'running' : (resumeError ? 'resume failed' : (session.manualStopped ? 'stopped' : 'resuming…'))}</span>
|
|
152
235
|
</span>
|
|
153
236
|
`} />
|
|
154
237
|
<${SessionTabs}
|
|
155
238
|
activeId=${session.id}
|
|
156
239
|
onActivate=${(sid) => selectSession(sid)}
|
|
157
240
|
onNew=${() => selectTab('launch')}
|
|
158
|
-
kebab=${html
|
|
241
|
+
kebab=${html`
|
|
242
|
+
<${SessionControls} running=${running}
|
|
243
|
+
busy=${actionBusy}
|
|
244
|
+
onStop=${onStop}
|
|
245
|
+
onResume=${onResume} />
|
|
246
|
+
<${SessionMenu} session=${session}
|
|
247
|
+
switchableClis=${switchableClis}
|
|
248
|
+
onRename=${onRename}
|
|
249
|
+
onDelete=${onDelete}
|
|
250
|
+
onOpenEditor=${onOpenEditor}
|
|
251
|
+
onSwitchCli=${onSwitchCli} />`} />
|
|
159
252
|
<div class="session-pane">
|
|
160
253
|
<div class="session-pane-body">
|
|
161
254
|
${running
|
|
@@ -165,6 +258,11 @@ export function SessionsPage() {
|
|
|
165
258
|
${resumeError ? html`
|
|
166
259
|
<div>Failed to resume: <span class="mono">${resumeError}</span></div>
|
|
167
260
|
<button class="action primary" onClick=${onRetry}>Retry</button>
|
|
261
|
+
` : session.manualStopped ? html`
|
|
262
|
+
<div>Session stopped</div>
|
|
263
|
+
<button class="action primary" onClick=${onResume} disabled=${actionBusy}>
|
|
264
|
+
${actionBusy ? 'Resuming…' : 'Resume'}
|
|
265
|
+
</button>
|
|
168
266
|
` : html`
|
|
169
267
|
<div>Resuming session…</div>
|
|
170
268
|
`}
|
package/server.js
CHANGED
|
@@ -217,6 +217,10 @@ function pickCli(cfg, requestedId) {
|
|
|
217
217
|
return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
function findCliById(cfg, id) {
|
|
221
|
+
return (cfg.clis || []).find((c) => c.id === id) || null;
|
|
222
|
+
}
|
|
223
|
+
|
|
220
224
|
// Resolve how to spawn a CLI command. Windows quirks:
|
|
221
225
|
// v1.1 — spawn strategy is now caller-controlled via cli.shell:
|
|
222
226
|
// 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
|
|
@@ -639,6 +643,64 @@ app.put('/api/sessions/:id', asyncH(async (req, res) => {
|
|
|
639
643
|
res.json({ session: updated });
|
|
640
644
|
}));
|
|
641
645
|
|
|
646
|
+
// Switch the CLI config used to resume an existing session. This is
|
|
647
|
+
// intentionally narrower than the generic PUT route: a session can only
|
|
648
|
+
// move between configured CLIs of the same type (e.g. one claude wrapper
|
|
649
|
+
// to another) so its captured upstream cliSessionId stays meaningful.
|
|
650
|
+
app.post('/api/sessions/:id/switch-cli', asyncH(async (req, res) => {
|
|
651
|
+
const targetCliId = typeof req.body?.cliId === 'string' ? req.body.cliId.trim() : '';
|
|
652
|
+
if (!targetCliId) return res.status(400).json({ error: 'cliId required' });
|
|
653
|
+
|
|
654
|
+
const record = await persistedSessions.get(req.params.id);
|
|
655
|
+
if (!record) return res.status(404).json({ error: 'session not found' });
|
|
656
|
+
|
|
657
|
+
const cfg = await loadConfig();
|
|
658
|
+
const currentCli = findCliById(cfg, record.cliId);
|
|
659
|
+
const targetCli = findCliById(cfg, targetCliId);
|
|
660
|
+
if (!currentCli) return res.status(400).json({ error: `current CLI ${record.cliId} no longer configured` });
|
|
661
|
+
if (!targetCli) return res.status(400).json({ error: `target CLI ${targetCliId} not configured` });
|
|
662
|
+
if (currentCli.type !== targetCli.type) {
|
|
663
|
+
return res.status(400).json({
|
|
664
|
+
error: `cannot switch ${currentCli.type} session to ${targetCli.type} CLI`,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (record.cliId === targetCli.id) {
|
|
669
|
+
const live = webTerminal.get(record.id);
|
|
670
|
+
return res.json({ session: record, changed: false, running: !!(live && !live.exitedAt) });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const updated = await persistedSessions.update(record.id, { cliId: targetCli.id });
|
|
674
|
+
const live = webTerminal.get(record.id);
|
|
675
|
+
res.json({
|
|
676
|
+
session: updated,
|
|
677
|
+
changed: true,
|
|
678
|
+
running: !!(live && !live.exitedAt),
|
|
679
|
+
fromCliId: currentCli.id,
|
|
680
|
+
toCliId: targetCli.id,
|
|
681
|
+
cliType: targetCli.type,
|
|
682
|
+
});
|
|
683
|
+
}));
|
|
684
|
+
|
|
685
|
+
// Stop the live PTY for a session without deleting its persisted record.
|
|
686
|
+
// Unlike a natural CLI exit, this is a user intent signal: the frontend
|
|
687
|
+
// should not auto-resume it again until the user explicitly presses Resume.
|
|
688
|
+
app.post('/api/sessions/:id/stop', asyncH(async (req, res) => {
|
|
689
|
+
const record = await persistedSessions.get(req.params.id);
|
|
690
|
+
if (!record) return res.status(404).json({ error: 'session not found' });
|
|
691
|
+
const stopped = webTerminal.kill(record.id);
|
|
692
|
+
const updated = await persistedSessions.update(record.id, {
|
|
693
|
+
status: 'exited',
|
|
694
|
+
pid: null,
|
|
695
|
+
exitCode: null,
|
|
696
|
+
exitedAt: Date.now(),
|
|
697
|
+
manualStopped: true,
|
|
698
|
+
lastActiveAt: Date.now(),
|
|
699
|
+
});
|
|
700
|
+
try { require('./lib/cliActivity').releaseSession(record.id); } catch {}
|
|
701
|
+
res.json({ stopped, session: updated });
|
|
702
|
+
}));
|
|
703
|
+
|
|
642
704
|
app.delete('/api/sessions/:id', asyncH(async (req, res) => {
|
|
643
705
|
// Kill PTY first if it's still alive, then drop the record.
|
|
644
706
|
try { webTerminal.kill(req.params.id); } catch {}
|