@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.
@@ -1,20 +1,13 @@
1
- // "Import existing session" modal. Browses sessions discovered on disk
2
- // for claude / codex / copilot, lets the user pick one, choose which
3
- // configured CLI it should be tied to, and adopts it — a ccsm
4
- // persistedSessions record is created with the upstream session id
5
- // pre-filled so clicking it later runs `<cli> --resume <id>` (via
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
- // Props:
9
- // onClose() close request
10
- // onAdopted(sessionId) — fires after a successful adopt with
11
- // the new (or pre-existing) record id
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, IconBranch,
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 = 30;
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
- // cache shape per tab: { loading, loadingMore, error, items, offset,
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 load = async (type, { force = false } = {}) => {
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
- const loadMore = async () => {
82
- const cur = cache[tab];
83
- if (!cur || cur.loadingMore || !cur.hasMore) return;
84
- setCache((c) => ({ ...c, [tab]: { ...c[tab], loadingMore: true } }));
85
- try {
86
- const r = await listLocalCliSessions(tab, { offset: cur.offset, limit: PAGE_SIZE });
87
- setCache((c) => {
88
- const entry = c[tab];
89
- const existingIds = new Set(entry.items.map((x) => x.cliSessionId));
90
- const additions = r.sessions.filter((s) => !existingIds.has(s.cliSessionId));
91
- return {
92
- ...c,
93
- [tab]: {
94
- ...entry,
95
- loadingMore: false,
96
- items: [...entry.items, ...additions],
97
- offset: cur.offset + additions.filter((s) => !s.active).length,
98
- hasMore: r.hasMore,
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
- useEffect(() => { load(tab); /* eslint-disable-next-line */ }, [tab]);
108
- // Clear search when switching tabs
109
- useEffect(() => { setQuery(''); }, [tab]);
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 `type` as the active tab — these are the
114
- // ones the row's `--resume <id>` template will actually work with.
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
- id: c.id,
135
- icon: html`<${Icon} />`,
136
- label: c.name,
137
- meta: c.command,
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
- const state = cache[tab] || {
154
- loading: true, loadingMore: false, items: [], error: null,
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 state.items;
160
- return state.items.filter((it) => {
161
- const hay = `${it.summary || ''} ${it.cwd || ''} ${it.cliSessionId}`.toLowerCase();
162
- return hay.includes(q);
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
- const cliId = effectiveCliId;
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
- setCache((c) => ({
183
- ...c,
184
- [tab]: c[tab] ? {
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
- <!-- Tabs row -->
202
- <div class="adopt-tabs">
203
- ${TABS.map((t) => {
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 cnt === 'number' && cnt > 0 ? html`
212
- <span class="adopt-tab-count">${cnt}</span>
213
- ` : null}
214
- </button>`;
215
- })}
216
- <button type="button" class="adopt-icon-btn" title="Rescan"
217
- onClick=${() => load(tab, { force: true })}>
218
- <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
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
- <!-- Tools row: CLI picker + search -->
226
- <div class="adopt-tools">
227
- <button type="button" ref=${cliAnchorRef}
228
- class=${`adopt-cli-pill${pickerOpen ? ' is-open' : ''}`}
229
- onClick=${() => setPickerOpen((v) => !v)}>
230
- <span class="adopt-cli-pill-prefix">Adopt as</span>
231
- <span class="adopt-cli-pill-icon">
232
- ${effectiveCli ? html`${(() => {
233
- const I = IconForCliType(effectiveCli.type);
234
- return html`<${I} />`;
235
- })()}` : null}
236
- </span>
237
- <span class="adopt-cli-pill-name">${effectiveCli?.name || 'choose CLI'}</span>
238
- <${IconChevronDown} />
239
- </button>
240
- ${pickerOpen ? html`
241
- <${Popover} anchor=${cliAnchorRef} onClose=${() => setPickerOpen(false)} width=${300}>
242
- <${PickerPanel}
243
- title=${`CLI for ${tab} sessions`}
244
- items=${pickerItems}
245
- selectedId=${effectiveCliId}
246
- showSearch=${pickerItems.length > 6}
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
- <div class="adopt-search">
253
- <span class="adopt-search-icon"><${IconSearch} /></span>
254
- <input class="adopt-search-input"
255
- placeholder=${state.loading ? 'Loading…' : `Search ${unimportedCount} sessions…`}
256
- value=${query} disabled=${state.loading}
257
- onInput=${(e) => setQuery(e.target.value)} />
258
- ${query ? html`
259
- <button class="adopt-search-clear" type="button"
260
- onClick=${() => setQuery('')} title="Clear">
261
- <${IconClose} />
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
- <!-- List body -->
267
- <div class="adopt-body">
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
- ` : state.error ? html`
271
- <div class="adopt-empty adopt-error">${state.error}</div>
272
- ` : state.items.length === 0 ? html`
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-list" data-shown=${items.length} data-total=${totalKnown}>
281
- ${items.map((it) => html`
282
- <li class=${`adopt-row${it.adopted ? ' is-adopted' : ''}${it.active ? ' is-active' : ''}`}
283
- key=${it.cliSessionId}>
284
- <div class="adopt-row-main">
285
- <div class="adopt-row-title">
286
- ${it.active ? html`<span class="adopt-row-live" title="A CLI process has this session open right now">● live</span>` : null}
287
- ${it.summary || html`<span class="adopt-row-untitled">untitled session</span>`}
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-meta">
290
- <span class="adopt-row-path mono" title=${it.cwd || ''}>${it.cwd || '—'}</span>
291
- <span class="adopt-row-dot">·</span>
292
- <span>${relTime(it.mtime)}</span>
293
- <span class="adopt-row-dot">·</span>
294
- <span class="adopt-row-id mono">${it.cliSessionId.slice(0, 8)}</span>
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
- </div>
297
- <div class="adopt-row-actions">
298
- ${it.adopted ? html`
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 d = Date.now() - ms;
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
- const days = Math.round(h / 24);
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 fix: xterm positions .xterm-helper-textarea via `left: <col-px>`
444
- // following the cursor. When the cursor is near the right edge and the
445
- // user starts composing (e.g. Chinese pinyin), the textarea + native
446
- // composition popup grow with the composed string and overflow the
447
- // terminal host which visually pushes the layout right. We can't cap
448
- // width / change wrapping (that breaks Chromium's IME event flow), but
449
- // we CAN re-anchor the textarea to the right edge while composing so
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: 'ccp / claude / ...', required: true },
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: 'ccp / claude / ...', required: true },
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.' },