@bakapiano/ccsm 0.20.2 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/codexSeed.js +23 -8
- package/lib/config.js +3 -3
- package/lib/localCliSessions.js +102 -72
- package/lib/webTerminal.js +7 -1
- package/lib/winPath.js +67 -0
- package/package.json +1 -1
- package/public/css/responsive.css +16 -0
- package/public/css/terminals.css +26 -38
- package/public/css/widgets.css +114 -86
- package/public/js/components/AdoptModal.js +168 -250
- package/public/js/components/TerminalView.js +42 -16
- package/public/js/pages/ConfigurePage.js +1 -1
- package/public/js/pages/LaunchPage.js +1 -1
- package/server.js +20 -3
|
@@ -1,20 +1,13 @@
|
|
|
1
|
-
// "Import existing session" modal. Browses sessions discovered on disk
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// cli.resumeIdArgs).
|
|
1
|
+
// "Import existing session" modal. Browses sessions discovered on disk for
|
|
2
|
+
// claude / codex / copilot, lets the user pick one, choose which configured
|
|
3
|
+
// CLI it should be tied to, and adopts it — a ccsm persistedSessions record
|
|
4
|
+
// is created with the upstream session id pre-filled so clicking it later
|
|
5
|
+
// runs `<cli> --resume <id>` (via cli.resumeIdArgs).
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// Tabs across the top switch the upstream type. Below the tabs, an
|
|
14
|
-
// "Adopt as <CLI ▾>" chip filters the configured CLIs by matching
|
|
15
|
-
// `type` and reuses the global PickerPanel popover. A search box
|
|
16
|
-
// filters rows by title + cwd. Each row is a card with the prompt
|
|
17
|
-
// summary, cwd, age, and an Import button.
|
|
7
|
+
// Layout (matches the app's modal pattern): pinned head (tabs + "import as"
|
|
8
|
+
// CLI pill + search), a scrolling list of session cards, and a pinned footer
|
|
9
|
+
// with ‹ Prev · X–Y of Z · Next › pagination. Each tab paginates server-side
|
|
10
|
+
// (PAGE_SIZE per page); typing a query loads the whole tab and filters it.
|
|
18
11
|
|
|
19
12
|
import { html } from '../html.js';
|
|
20
13
|
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
|
@@ -26,7 +19,7 @@ import { listLocalCliSessions, adoptSession } from '../api.js';
|
|
|
26
19
|
import { setToast } from '../toast.js';
|
|
27
20
|
import {
|
|
28
21
|
IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor,
|
|
29
|
-
IconSearch, IconClose, IconChevronDown,
|
|
22
|
+
IconSearch, IconClose, IconChevronDown, IconChevronLeft, IconChevronRight, IconRefresh,
|
|
30
23
|
} from '../icons.js';
|
|
31
24
|
|
|
32
25
|
const TABS = [
|
|
@@ -35,157 +28,90 @@ const TABS = [
|
|
|
35
28
|
{ type: 'copilot', label: 'Copilot', Icon: IconCopilotColor },
|
|
36
29
|
];
|
|
37
30
|
|
|
38
|
-
const PAGE_SIZE =
|
|
31
|
+
const PAGE_SIZE = 20;
|
|
32
|
+
const SEARCH_LIMIT = 1000; // typing a query loads the whole tab to filter
|
|
39
33
|
|
|
40
34
|
export function AdoptModal({ onClose, onAdopted }) {
|
|
41
35
|
const [tab, setTab] = useState('claude');
|
|
42
|
-
|
|
43
|
-
// hasMore, totalActive, totalNonActive }
|
|
44
|
-
const [cache, setCache] = useState({});
|
|
45
|
-
const [adopting, setAdopting] = useState(null);
|
|
36
|
+
const [page, setPage] = useState(0);
|
|
46
37
|
const [query, setQuery] = useState('');
|
|
38
|
+
const [view, setView] = useState({ loading: true, error: null, sessions: [], total: 0 });
|
|
39
|
+
const [totals, setTotals] = useState({}); // per-tab total → tab badges
|
|
40
|
+
const [adopting, setAdopting] = useState(null);
|
|
47
41
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
48
42
|
const [cliOverride, setCliOverride] = useState({});
|
|
43
|
+
const [reloadTick, setReloadTick] = useState(0); // Rescan bumps this
|
|
49
44
|
const cliAnchorRef = useRef(null);
|
|
50
45
|
|
|
51
|
-
const
|
|
52
|
-
const existing = cache[type];
|
|
53
|
-
if (!force && existing && !existing.error && existing.items.length) return;
|
|
54
|
-
setCache((c) => ({
|
|
55
|
-
...c,
|
|
56
|
-
[type]: { loading: true, loadingMore: false, items: [], error: null,
|
|
57
|
-
offset: 0, hasMore: false, totalActive: 0, totalNonActive: 0 },
|
|
58
|
-
}));
|
|
59
|
-
try {
|
|
60
|
-
const r = await listLocalCliSessions(type, { offset: 0, limit: PAGE_SIZE });
|
|
61
|
-
setCache((c) => ({
|
|
62
|
-
...c,
|
|
63
|
-
[type]: {
|
|
64
|
-
loading: false, loadingMore: false, error: null,
|
|
65
|
-
items: r.sessions,
|
|
66
|
-
offset: r.offset + r.sessions.filter((s) => !s.active).length, // advance past hydrated non-active
|
|
67
|
-
hasMore: r.hasMore,
|
|
68
|
-
totalActive: r.totalActive,
|
|
69
|
-
totalNonActive: r.totalNonActive,
|
|
70
|
-
},
|
|
71
|
-
}));
|
|
72
|
-
} catch (e) {
|
|
73
|
-
setCache((c) => ({
|
|
74
|
-
...c,
|
|
75
|
-
[type]: { loading: false, loadingMore: false, items: [], error: e.message,
|
|
76
|
-
offset: 0, hasMore: false, totalActive: 0, totalNonActive: 0 },
|
|
77
|
-
}));
|
|
78
|
-
}
|
|
79
|
-
};
|
|
46
|
+
const searching = !!query.trim();
|
|
80
47
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
return
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
});
|
|
102
|
-
} catch (e) {
|
|
103
|
-
setCache((c) => ({ ...c, [tab]: { ...c[tab], loadingMore: false, error: e.message } }));
|
|
104
|
-
}
|
|
105
|
-
};
|
|
48
|
+
// Fetch the active view (tab × page × query). When searching we pull the
|
|
49
|
+
// whole tab and filter client-side; otherwise just the requested page.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
setView((v) => ({ ...v, loading: true, error: null }));
|
|
53
|
+
(async () => {
|
|
54
|
+
try {
|
|
55
|
+
const offset = searching ? 0 : page * PAGE_SIZE;
|
|
56
|
+
const limit = searching ? SEARCH_LIMIT : PAGE_SIZE;
|
|
57
|
+
const r = await listLocalCliSessions(tab, { offset, limit });
|
|
58
|
+
if (cancelled) return;
|
|
59
|
+
const total = r.total ?? ((r.totalActive || 0) + (r.totalNonActive || 0));
|
|
60
|
+
setView({ loading: false, error: null, sessions: r.sessions || [], total });
|
|
61
|
+
setTotals((t) => (t[tab] === total ? t : { ...t, [tab]: total }));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (!cancelled) setView({ loading: false, error: e.message, sessions: [], total: 0 });
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
return () => { cancelled = true; };
|
|
67
|
+
}, [tab, page, searching, reloadTick]);
|
|
106
68
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
useEffect(() => {
|
|
69
|
+
// Snap back to page 0 when the tab changes or search toggles on/off.
|
|
70
|
+
useEffect(() => { setPage(0); }, [tab]);
|
|
71
|
+
useEffect(() => { setPage(0); }, [searching]);
|
|
110
72
|
|
|
111
73
|
const cfg = config.value || {};
|
|
112
74
|
const clis = cfg.clis || [];
|
|
113
|
-
// CLIs of the same upstream
|
|
114
|
-
//
|
|
115
|
-
const matchingClis = useMemo(
|
|
116
|
-
() => clis.filter((c) => c.type === tab),
|
|
117
|
-
[clis, tab],
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
// Effective CLI for the current tab: user override → first matching
|
|
121
|
-
// → configured default → first cli.
|
|
75
|
+
// CLIs of the same upstream type as the active tab — the ones whose
|
|
76
|
+
// `--resume <id>` template will actually work with these sessions.
|
|
77
|
+
const matchingClis = useMemo(() => clis.filter((c) => c.type === tab), [clis, tab]);
|
|
122
78
|
const effectiveCliId =
|
|
123
|
-
cliOverride[tab]
|
|
124
|
-
|| matchingClis[0]?.id
|
|
125
|
-
|| cfg.defaultCliId
|
|
126
|
-
|| clis[0]?.id
|
|
127
|
-
|| '';
|
|
79
|
+
cliOverride[tab] || matchingClis[0]?.id || cfg.defaultCliId || clis[0]?.id || '';
|
|
128
80
|
const effectiveCli = clis.find((c) => c.id === effectiveCliId) || null;
|
|
129
81
|
|
|
130
|
-
// Items the picker shows — prefer same-type CLIs at top, then dim others.
|
|
131
82
|
const pickerItems = useMemo(() => {
|
|
132
83
|
const Icon = IconForCliType(tab);
|
|
133
|
-
const top = matchingClis.map((c) => ({
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
label: c.name,
|
|
137
|
-
|
|
138
|
-
}));
|
|
139
|
-
const others = clis
|
|
140
|
-
.filter((c) => c.type !== tab)
|
|
141
|
-
.map((c) => {
|
|
142
|
-
const I = IconForCliType(c.type);
|
|
143
|
-
return {
|
|
144
|
-
id: c.id,
|
|
145
|
-
icon: html`<${I} />`,
|
|
146
|
-
label: c.name,
|
|
147
|
-
meta: `(non-${tab})`,
|
|
148
|
-
};
|
|
149
|
-
});
|
|
84
|
+
const top = matchingClis.map((c) => ({ id: c.id, icon: html`<${Icon} />`, label: c.name, meta: c.command }));
|
|
85
|
+
const others = clis.filter((c) => c.type !== tab).map((c) => {
|
|
86
|
+
const I = IconForCliType(c.type);
|
|
87
|
+
return { id: c.id, icon: html`<${I} />`, label: c.name, meta: `(non-${tab})` };
|
|
88
|
+
});
|
|
150
89
|
return [...top, ...others];
|
|
151
90
|
}, [clis, matchingClis, tab]);
|
|
152
91
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
offset: 0, hasMore: false, totalActive: 0, totalNonActive: 0,
|
|
156
|
-
};
|
|
157
|
-
const items = useMemo(() => {
|
|
92
|
+
// Search filters the loaded set (the whole tab while searching, else the page).
|
|
93
|
+
const rows = useMemo(() => {
|
|
158
94
|
const q = query.trim().toLowerCase();
|
|
159
|
-
if (!q) return
|
|
160
|
-
return
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
});
|
|
164
|
-
}, [state.items, query]);
|
|
165
|
-
|
|
166
|
-
const totalKnown = state.totalActive + state.totalNonActive;
|
|
167
|
-
const unimportedCount = state.items.filter((it) => !it.adopted).length;
|
|
95
|
+
if (!q) return view.sessions;
|
|
96
|
+
return view.sessions.filter((it) =>
|
|
97
|
+
`${it.summary || ''} ${it.cwd || ''} ${it.cliSessionId}`.toLowerCase().includes(q));
|
|
98
|
+
}, [view.sessions, query]);
|
|
168
99
|
|
|
169
100
|
const adopt = async (item) => {
|
|
170
|
-
|
|
171
|
-
if (!cliId) { setToast('configure a CLI first', 'error'); return; }
|
|
101
|
+
if (!effectiveCliId) { setToast('configure a CLI first', 'error'); return; }
|
|
172
102
|
setAdopting(item.cliSessionId);
|
|
173
103
|
try {
|
|
174
104
|
const r = await adoptSession({
|
|
175
|
-
cliId,
|
|
105
|
+
cliId: effectiveCliId,
|
|
176
106
|
cliSessionId: item.cliSessionId,
|
|
177
107
|
cwd: item.cwd,
|
|
178
108
|
title: item.summary || '',
|
|
179
109
|
});
|
|
180
110
|
if (r.alreadyAdopted) setToast('already in ccsm — opened existing record');
|
|
181
111
|
else setToast(`imported · ${item.cliSessionId.slice(0, 8)}…`);
|
|
182
|
-
|
|
183
|
-
...
|
|
184
|
-
|
|
185
|
-
...c[tab],
|
|
186
|
-
items: c[tab].items.map((x) => x.cliSessionId === item.cliSessionId
|
|
187
|
-
? { ...x, adopted: true } : x),
|
|
188
|
-
} : c[tab],
|
|
112
|
+
setView((v) => ({
|
|
113
|
+
...v,
|
|
114
|
+
sessions: v.sessions.map((x) => x.cliSessionId === item.cliSessionId ? { ...x, adopted: true } : x),
|
|
189
115
|
}));
|
|
190
116
|
onAdopted?.(r.session?.id);
|
|
191
117
|
} catch (e) {
|
|
@@ -195,135 +121,129 @@ export function AdoptModal({ onClose, onAdopted }) {
|
|
|
195
121
|
}
|
|
196
122
|
};
|
|
197
123
|
|
|
124
|
+
// ── pagination footer ──
|
|
125
|
+
const pageCount = Math.max(1, Math.ceil(view.total / PAGE_SIZE));
|
|
126
|
+
const from = view.total === 0 ? 0 : page * PAGE_SIZE + 1;
|
|
127
|
+
const to = Math.min(view.total, page * PAGE_SIZE + view.sessions.length);
|
|
128
|
+
const footer = searching
|
|
129
|
+
? html`<span class="adopt-pager-info">
|
|
130
|
+
${rows.length} match${rows.length === 1 ? '' : 'es'} · clear search to browse all
|
|
131
|
+
</span>`
|
|
132
|
+
: html`
|
|
133
|
+
<button type="button" class="action subtle small adopt-pager-btn"
|
|
134
|
+
disabled=${page === 0 || view.loading}
|
|
135
|
+
onClick=${() => setPage((p) => Math.max(0, p - 1))}>
|
|
136
|
+
<${IconChevronLeft} /> Prev
|
|
137
|
+
</button>
|
|
138
|
+
<span class="adopt-pager-info">
|
|
139
|
+
${view.total === 0 ? 'No sessions' : `${from}–${to} of ${view.total}`}
|
|
140
|
+
</span>
|
|
141
|
+
<button type="button" class="action subtle small adopt-pager-btn"
|
|
142
|
+
disabled=${(page + 1) >= pageCount || view.loading}
|
|
143
|
+
onClick=${() => setPage((p) => p + 1)}>
|
|
144
|
+
Next <${IconChevronRight} />
|
|
145
|
+
</button>`;
|
|
146
|
+
|
|
198
147
|
return html`
|
|
199
|
-
<${Modal} title="Import existing session" onClose=${onClose} width=${680}>
|
|
148
|
+
<${Modal} title="Import existing session" onClose=${onClose} width=${680} footer=${footer}>
|
|
200
149
|
<div class="adopt">
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const cnt = cache[t.type]?.items?.filter((x) => !x.adopted).length;
|
|
205
|
-
return html`
|
|
150
|
+
<div class="adopt-head">
|
|
151
|
+
<div class="adopt-tabs">
|
|
152
|
+
${TABS.map((t) => html`
|
|
206
153
|
<button type="button" key=${t.type}
|
|
207
154
|
class=${`adopt-tab${tab === t.type ? ' is-active' : ''}`}
|
|
208
155
|
onClick=${() => setTab(t.type)}>
|
|
209
156
|
<span class="adopt-tab-icon"><${t.Icon} /></span>
|
|
210
|
-
<span>${t.label}</span>
|
|
211
|
-
${typeof
|
|
212
|
-
<span class="adopt-tab-count">${
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<path d="M2 8a6 6 0 0 1 10.3-4.2L14 2v4h-4l1.5-1.5A4.5 4.5 0 1 0 12.5 8H14a6 6 0 1 1-12 0z"
|
|
220
|
-
fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
|
221
|
-
</svg>
|
|
222
|
-
</button>
|
|
223
|
-
</div>
|
|
157
|
+
<span class="adopt-tab-label">${t.label}</span>
|
|
158
|
+
${typeof totals[t.type] === 'number' && totals[t.type] > 0 ? html`
|
|
159
|
+
<span class="adopt-tab-count">${totals[t.type]}</span>` : null}
|
|
160
|
+
</button>`)}
|
|
161
|
+
<button type="button" class="adopt-rescan" title="Rescan disk"
|
|
162
|
+
onClick=${() => setReloadTick((n) => n + 1)}>
|
|
163
|
+
<${IconRefresh} />
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
224
166
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
emptyHint=${`No configured CLIs match ${tab}.`}
|
|
248
|
-
onSelect=${(id) => { setCliOverride((m) => ({ ...m, [tab]: id })); }}
|
|
249
|
-
onClose=${() => setPickerOpen(false)} />
|
|
250
|
-
</${Popover}>` : null}
|
|
167
|
+
<div class="adopt-tools">
|
|
168
|
+
<button type="button" ref=${cliAnchorRef}
|
|
169
|
+
class=${`adopt-cli-pill${pickerOpen ? ' is-open' : ''}`}
|
|
170
|
+
onClick=${() => setPickerOpen((v) => !v)}>
|
|
171
|
+
<span class="adopt-cli-pill-prefix">Import as</span>
|
|
172
|
+
<span class="adopt-cli-pill-icon">
|
|
173
|
+
${effectiveCli ? html`${(() => { const I = IconForCliType(effectiveCli.type); return html`<${I} />`; })()}` : null}
|
|
174
|
+
</span>
|
|
175
|
+
<span class="adopt-cli-pill-name">${effectiveCli?.name || 'choose CLI'}</span>
|
|
176
|
+
<${IconChevronDown} />
|
|
177
|
+
</button>
|
|
178
|
+
${pickerOpen ? html`
|
|
179
|
+
<${Popover} anchor=${cliAnchorRef} onClose=${() => setPickerOpen(false)} width=${300}>
|
|
180
|
+
<${PickerPanel}
|
|
181
|
+
title=${`CLI for ${tab} sessions`}
|
|
182
|
+
items=${pickerItems}
|
|
183
|
+
selectedId=${effectiveCliId}
|
|
184
|
+
showSearch=${pickerItems.length > 6}
|
|
185
|
+
emptyHint=${`No configured CLIs match ${tab}.`}
|
|
186
|
+
onSelect=${(id) => { setCliOverride((m) => ({ ...m, [tab]: id })); }}
|
|
187
|
+
onClose=${() => setPickerOpen(false)} />
|
|
188
|
+
</${Popover}>` : null}
|
|
251
189
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
</button>` : null}
|
|
190
|
+
<div class="adopt-search">
|
|
191
|
+
<span class="adopt-search-icon"><${IconSearch} /></span>
|
|
192
|
+
<input class="adopt-search-input"
|
|
193
|
+
placeholder=${`Search ${tab} sessions…`}
|
|
194
|
+
value=${query}
|
|
195
|
+
onInput=${(e) => setQuery(e.target.value)} />
|
|
196
|
+
${query ? html`
|
|
197
|
+
<button class="adopt-search-clear" type="button" title="Clear"
|
|
198
|
+
onClick=${() => setQuery('')}><${IconClose} /></button>` : null}
|
|
199
|
+
</div>
|
|
263
200
|
</div>
|
|
264
201
|
</div>
|
|
265
202
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
${state.loading ? html`
|
|
203
|
+
<div class="adopt-list">
|
|
204
|
+
${view.loading ? html`
|
|
269
205
|
<div class="adopt-empty"><span class="adopt-empty-spinner"></span> Scanning…</div>
|
|
270
|
-
` :
|
|
271
|
-
<div class="adopt-empty adopt-error">${
|
|
272
|
-
` :
|
|
206
|
+
` : view.error ? html`
|
|
207
|
+
<div class="adopt-empty adopt-error">${view.error}</div>
|
|
208
|
+
` : rows.length === 0 ? html`
|
|
273
209
|
<div class="adopt-empty">
|
|
274
210
|
<div class="adopt-empty-mark">∅</div>
|
|
275
|
-
No ${tab} sessions found on this machine
|
|
211
|
+
${searching ? html`No matches for "${query}".` : html`No ${tab} sessions found on this machine.`}
|
|
276
212
|
</div>
|
|
277
|
-
` : items.length === 0 ? html`
|
|
278
|
-
<div class="adopt-empty">No matches for "${query}".</div>
|
|
279
213
|
` : html`
|
|
280
|
-
<ul class="adopt-
|
|
281
|
-
${
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
214
|
+
<ul class="adopt-rows">
|
|
215
|
+
${rows.map((it) => {
|
|
216
|
+
const Icon = IconForCliType(tab);
|
|
217
|
+
return html`
|
|
218
|
+
<li class=${`adopt-row${it.adopted ? ' is-adopted' : ''}${it.active ? ' is-active' : ''}`}
|
|
219
|
+
key=${it.cliSessionId}>
|
|
220
|
+
<span class="adopt-row-icon"><${Icon} /></span>
|
|
221
|
+
<div class="adopt-row-main">
|
|
222
|
+
<div class="adopt-row-title">
|
|
223
|
+
${it.active ? html`<span class="adopt-row-live" title="A CLI process has this session open right now">live</span>` : null}
|
|
224
|
+
${it.summary || html`<span class="adopt-row-untitled">untitled session</span>`}
|
|
225
|
+
</div>
|
|
226
|
+
<div class="adopt-row-meta">
|
|
227
|
+
<span class="adopt-row-path mono" title=${it.cwd || ''}>${it.cwd || '—'}</span>
|
|
228
|
+
<span class="adopt-row-dot">·</span>
|
|
229
|
+
<span>${relTime(it.mtime)}</span>
|
|
230
|
+
<span class="adopt-row-dot">·</span>
|
|
231
|
+
<span class="adopt-row-id mono">${it.cliSessionId.slice(0, 8)}</span>
|
|
232
|
+
</div>
|
|
288
233
|
</div>
|
|
289
|
-
<div class="adopt-row-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
234
|
+
<div class="adopt-row-actions">
|
|
235
|
+
${it.adopted ? html`
|
|
236
|
+
<span class="adopt-row-badge">Imported</span>
|
|
237
|
+
` : html`
|
|
238
|
+
<button type="button" class="action primary small adopt-row-btn"
|
|
239
|
+
disabled=${adopting === it.cliSessionId || !effectiveCliId}
|
|
240
|
+
onClick=${() => adopt(it)}>
|
|
241
|
+
${adopting === it.cliSessionId ? 'Importing…' : 'Import'}
|
|
242
|
+
</button>`}
|
|
295
243
|
</div>
|
|
296
|
-
</
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
<span class="adopt-row-badge">Imported</span>
|
|
300
|
-
` : html`
|
|
301
|
-
<button type="button" class="action primary adopt-row-btn"
|
|
302
|
-
disabled=${adopting === it.cliSessionId || !effectiveCliId}
|
|
303
|
-
onClick=${() => adopt(it)}>
|
|
304
|
-
${adopting === it.cliSessionId ? 'Importing…' : 'Import'}
|
|
305
|
-
</button>
|
|
306
|
-
`}
|
|
307
|
-
</div>
|
|
308
|
-
</li>`)}
|
|
309
|
-
</ul>
|
|
310
|
-
${state.hasMore && !query ? html`
|
|
311
|
-
<div class="adopt-loadmore">
|
|
312
|
-
<button type="button" class="action subtle"
|
|
313
|
-
disabled=${state.loadingMore}
|
|
314
|
-
onClick=${loadMore}>
|
|
315
|
-
${state.loadingMore ? 'Loading…'
|
|
316
|
-
: `Load ${Math.min(PAGE_SIZE, state.totalNonActive - state.offset)} more · ${state.items.length} / ${totalKnown}`}
|
|
317
|
-
</button>
|
|
318
|
-
</div>` : !query && state.items.length > 0 ? html`
|
|
319
|
-
<div class="adopt-loadmore adopt-loadmore-done">
|
|
320
|
-
All ${totalKnown} sessions loaded
|
|
321
|
-
</div>` : null}
|
|
322
|
-
${query && state.hasMore ? html`
|
|
323
|
-
<div class="adopt-loadmore adopt-loadmore-hint">
|
|
324
|
-
Searching ${state.items.length} loaded · clear search and Load more to see older sessions
|
|
325
|
-
</div>` : null}
|
|
326
|
-
`}
|
|
244
|
+
</li>`;
|
|
245
|
+
})}
|
|
246
|
+
</ul>`}
|
|
327
247
|
</div>
|
|
328
248
|
</div>
|
|
329
249
|
</${Modal}>`;
|
|
@@ -331,13 +251,11 @@ export function AdoptModal({ onClose, onAdopted }) {
|
|
|
331
251
|
|
|
332
252
|
function relTime(ms) {
|
|
333
253
|
if (!ms) return '';
|
|
334
|
-
const
|
|
335
|
-
const s = Math.round(d / 1000);
|
|
254
|
+
const s = Math.round((Date.now() - ms) / 1000);
|
|
336
255
|
if (s < 60) return `${s}s ago`;
|
|
337
256
|
const m = Math.round(s / 60);
|
|
338
257
|
if (m < 60) return `${m}m ago`;
|
|
339
258
|
const h = Math.round(m / 60);
|
|
340
259
|
if (h < 48) return `${h}h ago`;
|
|
341
|
-
|
|
342
|
-
return `${days}d ago`;
|
|
260
|
+
return `${Math.round(h / 24)}d ago`;
|
|
343
261
|
}
|
|
@@ -183,6 +183,30 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
183
183
|
|
|
184
184
|
const host = hostRef.current;
|
|
185
185
|
term.open(host);
|
|
186
|
+
|
|
187
|
+
// Answer OSC 10/11 (default foreground / background colour) queries with
|
|
188
|
+
// the LIVE theme colours. CLIs like claude probe the terminal background
|
|
189
|
+
// (`OSC 11 ; ? ST`) to pick a light- or dark-tuned syntax theme. xterm.js
|
|
190
|
+
// doesn't answer these by default, so claude assumes a dark terminal and
|
|
191
|
+
// paints near-white tokens (comments, f-string interpolations, call
|
|
192
|
+
// names) that vanish on our light background — the "字体颜色和背景重复"
|
|
193
|
+
// bug. VSCode answers them; we match. Reply format is the xterm/X11
|
|
194
|
+
// `rgb:RRRR/GGGG/BBBB` (16-bit-per-channel) the query expects.
|
|
195
|
+
const answerColorOsc = (code, getHex) => (data) => {
|
|
196
|
+
if (data !== '?') return false; // only the query form
|
|
197
|
+
const hex = getHex(); // '#rrggbb'
|
|
198
|
+
const ch = (i) => parseInt(hex.slice(i, i + 2), 16);
|
|
199
|
+
const w = (v) => (v * 257).toString(16).padStart(4, '0'); // 8-bit → 16-bit
|
|
200
|
+
const reply = `\x1b]${code};rgb:${w(ch(1))}/${w(ch(3))}/${w(ch(5))}\x07`;
|
|
201
|
+
const ws = wsRef.current;
|
|
202
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data: reply }));
|
|
203
|
+
return true;
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
term.parser.registerOscHandler(11, answerColorOsc(11, () => themeRef.current.background));
|
|
207
|
+
term.parser.registerOscHandler(10, answerColorOsc(10, () => themeRef.current.foreground));
|
|
208
|
+
} catch {}
|
|
209
|
+
|
|
186
210
|
// Robust fit scheduler. A single requestAnimationFrame works most
|
|
187
211
|
// of the time but races on tab/session switches: the .tab-panel
|
|
188
212
|
// just flipped from display:none to display:flex and although the
|
|
@@ -304,6 +328,16 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
304
328
|
vv?.addEventListener?.('resize', onVisualResize);
|
|
305
329
|
vv?.addEventListener?.('scroll', onVisualResize);
|
|
306
330
|
|
|
331
|
+
// Mobile touch scrolling is handled NATIVELY: responsive.css makes the
|
|
332
|
+
// .xterm-screen layer pointer-transparent so finger drags fall through to
|
|
333
|
+
// xterm's own scrollable .xterm-viewport (real momentum, never drops
|
|
334
|
+
// mid-flick the way a JS-intercepted scroll does). The only casualty is
|
|
335
|
+
// tap-to-focus — with the screen ignoring pointer events a tap no longer
|
|
336
|
+
// reaches xterm's focus path, so the soft keyboard wouldn't open. Re-focus
|
|
337
|
+
// on tap explicitly. A drag emits no click, so this fires only on real taps.
|
|
338
|
+
const onHostClick = () => { try { term.focus(); } catch {} };
|
|
339
|
+
if (isMobile) host.addEventListener('click', onHostClick);
|
|
340
|
+
|
|
307
341
|
// Tab-switch refresh. The terminal lives inside a .tab-panel which gets
|
|
308
342
|
// display:none when another tab is active. WebGL renderers keep a glyph
|
|
309
343
|
// texture atlas in GPU memory; when the canvas hides + redisplays at a
|
|
@@ -440,27 +474,18 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
440
474
|
};
|
|
441
475
|
document.addEventListener('keydown', onShiftEnter, true);
|
|
442
476
|
|
|
443
|
-
// IME
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
// it grows leftward instead. Toggling a class on the host is enough;
|
|
451
|
-
// the CSS in terminals.css does the rest.
|
|
477
|
+
// While composing (IME), hide the terminal's own cursor so the blinking
|
|
478
|
+
// bar doesn't sit on top of the composition box (terminals.css paints the
|
|
479
|
+
// box at the cursor, showing the in-progress pinyin). The cursor is drawn
|
|
480
|
+
// on the canvas from theme.cursor, so CSS can't touch it: swap the theme
|
|
481
|
+
// to a transparent cursor AND issue the DECTCEM hide sequence (the theme
|
|
482
|
+
// swap alone doesn't reliably stop the blink frame loop). Restore on end.
|
|
483
|
+
// Use the live theme (themeRef) so the restore matches current light/dark.
|
|
452
484
|
const onCompStart = () => {
|
|
453
|
-
if (host) host.classList.add('is-composing');
|
|
454
|
-
// The terminal cursor is rendered on canvas (theme.cursor), so CSS
|
|
455
|
-
// can't hide it. Theme swap alone doesn't reliably stop the blink
|
|
456
|
-
// frame loop, so also issue the DECTCEM hide sequence which the
|
|
457
|
-
// renderer honours immediately. Use the live theme (themeRef) so the
|
|
458
|
-
// restore on compEnd matches whatever light/dark is current.
|
|
459
485
|
try { term.options.theme = { ...themeRef.current, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
|
|
460
486
|
try { term.write('\x1b[?25l'); } catch {}
|
|
461
487
|
};
|
|
462
488
|
const onCompEnd = () => {
|
|
463
|
-
if (host) host.classList.remove('is-composing');
|
|
464
489
|
try { term.options.theme = themeRef.current; } catch {}
|
|
465
490
|
try { term.write('\x1b[?25h'); } catch {}
|
|
466
491
|
};
|
|
@@ -482,6 +507,7 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
482
507
|
if (panelMo) panelMo.disconnect();
|
|
483
508
|
vv?.removeEventListener?.('resize', onVisualResize);
|
|
484
509
|
vv?.removeEventListener?.('scroll', onVisualResize);
|
|
510
|
+
if (isMobile) host.removeEventListener('click', onHostClick);
|
|
485
511
|
closedByUs = true;
|
|
486
512
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
487
513
|
try { wsRef.current?.close(); } catch {}
|
|
@@ -87,7 +87,7 @@ function cliFieldsFor({ creating } = {}) {
|
|
|
87
87
|
},
|
|
88
88
|
},
|
|
89
89
|
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
90
|
-
{ key: 'command', label: 'Command', mono: true, placeholder: '
|
|
90
|
+
{ key: 'command', label: 'Command', mono: true, placeholder: 'claude / codex / ...', required: true },
|
|
91
91
|
{ key: 'args', label: 'Args', mono: true, placeholder: '',
|
|
92
92
|
hint: 'Used on every launch. Shell-style quoting: -Model "claude-opus-4-8" or -Path \'C:\\some dir\\bin\'.' },
|
|
93
93
|
{ key: 'newSessionIdArgs', label: 'New session id args', mono: true, placeholder: '--session-id <id>',
|
|
@@ -182,7 +182,7 @@ function LaunchHero() {
|
|
|
182
182
|
},
|
|
183
183
|
},
|
|
184
184
|
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
185
|
-
{ key: 'command', label: 'Command', mono: true, placeholder: '
|
|
185
|
+
{ key: 'command', label: 'Command', mono: true, placeholder: 'claude / codex / ...', required: true },
|
|
186
186
|
{ key: 'args', label: 'Args (space-separated)', mono: true, placeholder: '' },
|
|
187
187
|
{ key: 'resumeArgs', label: 'Resume args (fallback)', mono: true, placeholder: '--continue',
|
|
188
188
|
hint: 'Used when ccsm has no captured upstream session id.' },
|