create-pmos 0.214.0 → 0.215.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.
@@ -50,7 +50,36 @@ const cp = require('child_process');
50
50
  // refusal stands (an honest floor, not a silent truncation — the pane pid still had no record).
51
51
  const MAX_WALK_DEPTH = 2;
52
52
 
53
- const HOME = () => process.env.PMOS_CLAUDE_HOME || path.join(os.homedir(), '.claude');
53
+ /*
54
+ * Every folder Claude Code may keep its session records in. A PM with profiles runs Claude with CLAUDE_CONFIG_DIR set
55
+ * (`alias claude-work='CLAUDE_CONFIG_DIR=~/.claude-work claude'`), so its records are in ~/.claude-work/sessions, not
56
+ * ~/.claude/sessions: reading only ~/.claude made every such session "wrote no session record". The folders: the server's
57
+ * own CLAUDE_CONFIG_DIR, ~/.claude, each ~/.claude-* folder that has a `sessions` folder, and each PMOS profile's
58
+ * CLAUDE_CONFIG_DIR. PMOS_CLAUDE_HOME, when set, is the only one (tests). A pid has at most one live record, so looking in
59
+ * several folders does not weaken the identity proof: the record still has to name a cwd inside the workspace root.
60
+ */
61
+ const HOMES_MS = 30000;
62
+ let homesSnap = null;
63
+ function claudeHomes(now = Date.now()) {
64
+ if (process.env.PMOS_CLAUDE_HOME) return [process.env.PMOS_CLAUDE_HOME];
65
+ if (homesSnap && now - homesSnap.at < HOMES_MS) return homesSnap.list;
66
+ const hd = os.homedir();
67
+ const list = [];
68
+ const add = (d) => { if (d && typeof d === 'string' && path.isAbsolute(d) && !list.includes(path.resolve(d))) list.push(path.resolve(d)); };
69
+ add(process.env.CLAUDE_CONFIG_DIR);
70
+ add(path.join(hd, '.claude'));
71
+ try {
72
+ for (const n of fs.readdirSync(hd).sort()) {
73
+ if (/^\.claude-./.test(n) && fs.existsSync(path.join(hd, n, 'sessions'))) add(path.join(hd, n));
74
+ }
75
+ } catch { /* no home listing: ~/.claude alone */ }
76
+ try {
77
+ const r = require('./profiles').readProfiles(hd, { detect: true });
78
+ for (const pf of (r && r.profiles) || []) add(pf && pf.env && pf.env.CLAUDE_CONFIG_DIR);
79
+ } catch { /* profiles unreadable: the folders above */ }
80
+ homesSnap = { at: now, list };
81
+ return list;
82
+ }
54
83
  // A transcript can grow large; cap what we read so a long session cannot stall a broadcast.
55
84
  const MAX_BYTES = 8 * 1024 * 1024;
56
85
  // Synthetic assistant records are the CLI's own bookkeeping, not model turns — never counted,
@@ -128,6 +157,15 @@ function descendants(pid, listChildren, maxDepth = MAX_WALK_DEPTH) {
128
157
  * consider, pane or descendant, identically.
129
158
  */
130
159
  function resolvePid(pid, root, home) {
160
+ if (Array.isArray(home)) {
161
+ let first = null;
162
+ for (const h of home) {
163
+ const r = resolvePid(pid, root, h);
164
+ if (!r.noRecord) return r; // the folder that holds this pid's record answers
165
+ first = first || r;
166
+ }
167
+ return first || { noRecord: true };
168
+ }
131
169
  let reg;
132
170
  try {
133
171
  reg = JSON.parse(fs.readFileSync(path.join(home, 'sessions', `${pid}.json`), 'utf8'));
@@ -150,7 +188,7 @@ function resolvePid(pid, root, home) {
150
188
  * process levels for a descendant that resolves in-root — see the child-walk note at the head.
151
189
  * `listChildren` is injectable for tests; it defaults to a real `ps` snapshot.
152
190
  */
153
- function findTranscript(pid, root, home = HOME(), listChildren = defaultChildren) {
191
+ function findTranscript(pid, root, home = claudeHomes(), listChildren = defaultChildren) {
154
192
  if (!pid) return { missing: "can't identify this session's transcript — no process id" };
155
193
  const direct = resolvePid(pid, root, home);
156
194
  if (direct.sessionId) return direct;
@@ -321,8 +359,8 @@ function readHarness(file, clean = (x) => x) {
321
359
  * · a `user` record with string content (or text blocks) that is not meta, a sidechain or a compact
322
360
  * summary, and is not the CLI's own command bookkeeping — a slash command shows as `/name`;
323
361
  * · an `assistant` record's text blocks, from a real model turn (never `<synthetic>`);
324
- * · tool calls fold into one activity line between them ("Used Bash, Read ×2"), never their input,
325
- * which can hold anything.
362
+ * · tool calls fold into one activity line between them ("Used Bash, Read ×2"); each call keeps a short
363
+ * target (callTarget: the file, pattern or first command line), never the whole input, which can hold anything.
326
364
  * Every string goes through `clean` (server.js's scrub, D08). The newest `limit` items are returned. */
327
365
  const CONV_LIMIT = 80;
328
366
  const CONV_TEXT_MAX = 8000;
@@ -343,7 +381,29 @@ function userText(r) {
343
381
  return t.trim();
344
382
  }
345
383
 
346
- function readConversation(file, clean = (x) => x, limit = CONV_LIMIT) {
384
+ /* What one tool call worked on, in a few words: the file, the pattern, the first line of the command, the address. Only these
385
+ * named fields are read, never the whole input, and the result is at most TARGET_MAX characters (a path inside `root` is shown
386
+ * relative to it). The caller scrubs it (D08). Anything else gives ''. */
387
+ const TARGET_MAX = 120;
388
+ const CALLS_MAX = 40;
389
+ function callTarget(input, root) {
390
+ if (!input || typeof input !== 'object') return '';
391
+ const str = (v) => (typeof v === 'string' ? v : '');
392
+ const rel = (p) => { const r = root && path.isAbsolute(p) ? path.relative(root, p) : ''; return r && !r.startsWith('..') && !path.isAbsolute(r) ? r : p; };
393
+ let t = '';
394
+ const file = str(input.file_path) || str(input.notebook_path);
395
+ if (file) t = rel(file);
396
+ else if (str(input.command)) t = str(input.command).split('\n')[0];
397
+ else if (str(input.pattern)) t = str(input.pattern) + (str(input.path) ? ' in ' + rel(str(input.path)) : '');
398
+ else if (str(input.url)) t = str(input.url);
399
+ else if (str(input.query)) t = str(input.query);
400
+ else if (str(input.description)) t = str(input.description);
401
+ else if (str(input.path)) t = rel(str(input.path));
402
+ t = t.replace(/\s+/g, ' ').trim();
403
+ return t.length > TARGET_MAX ? t.slice(0, TARGET_MAX - 1) + '…' : t;
404
+ }
405
+
406
+ function readConversation(file, clean = (x) => x, limit = CONV_LIMIT, root = null) {
347
407
  let raw;
348
408
  try {
349
409
  const st = fs.statSync(file);
@@ -362,13 +422,15 @@ function readConversation(file, clean = (x) => x, limit = CONV_LIMIT) {
362
422
  const out = [];
363
423
  const cap = (t) => (t.length > CONV_TEXT_MAX ? t.slice(0, CONV_TEXT_MAX) + ' …' : t);
364
424
  const push = (role, text, at) => { out.push({ role, text: clean(cap(text)), at: at || null }); };
365
- const tool = (name, at) => {
425
+ const tool = (name, input, at) => {
366
426
  const last = out[out.length - 1];
367
427
  const n = clean(prettyTool(name));
428
+ const call = { tool: n, target: clean(callTarget(input, root)) };
368
429
  if (last && last.role === 'tool') {
369
430
  last.tools[n] = (last.tools[n] || 0) + 1;
431
+ if (last.calls.length < CALLS_MAX) last.calls.push(call);
370
432
  last.at = at || last.at;
371
- } else out.push({ role: 'tool', tools: { [n]: 1 }, at: at || null });
433
+ } else out.push({ role: 'tool', tools: { [n]: 1 }, calls: [call], at: at || null });
372
434
  };
373
435
  for (const line of raw.split('\n')) {
374
436
  if (!line) continue;
@@ -391,14 +453,35 @@ function readConversation(file, clean = (x) => x, limit = CONV_LIMIT) {
391
453
  // one model turn can stream its text in several records: join them into one bubble
392
454
  if (last && last.role === 'assistant' && last.msgId && last.msgId === m.id) last.text = clean(cap(last.text + '\n\n' + c.text.trim()));
393
455
  else { push('assistant', c.text.trim(), r.timestamp); out[out.length - 1].msgId = m.id || null; }
394
- } else if (c.type === 'tool_use' && c.name) tool(c.name, r.timestamp);
456
+ } else if (c.type === 'tool_use' && c.name) tool(c.name, c.input, r.timestamp);
395
457
  }
396
458
  }
397
459
  const messages = out.slice(-limit).map((x) => (x.role === 'tool'
398
- ? { role: 'tool', tools: x.tools, at: x.at }
460
+ ? { role: 'tool', tools: x.tools, calls: x.calls, at: x.at }
399
461
  : { role: x.role, text: x.text, at: x.at }));
400
462
  return { messages };
401
463
  }
402
464
 
403
- module.exports = { findTranscript, readHarness, readConversation, CONV_LIMIT, prettyTool, slugFor, resolvePid, descendants,
465
+ /*
466
+ * Claude Code's permission prompt, read from the pane's visible screen (tmux capture-pane -p, plain text). The transcript has
467
+ * the open call but not the question or its choices, which exist only on the screen. Returns { question, options:[{n, label}] }
468
+ * when the screen's last "Do you want to …" line is followed by numbered options, else null. The page types an option's
469
+ * number to answer, which is the key Claude Code's own menu takes. Box-drawing borders are dropped; labels are scrubbed.
470
+ */
471
+ const ASK_RE = /^Do you want to .+\?$/;
472
+ function permissionAsk(screen, clean = (x) => x) {
473
+ const lines = String(screen || '').split('\n').map((l) => l.replace(/[│┃]/g, ' ').replace(/\s+$/, '').trim());
474
+ let at = -1;
475
+ for (let i = lines.length - 1; i >= 0; i--) if (ASK_RE.test(lines[i])) { at = i; break; }
476
+ if (at < 0) return null;
477
+ const options = [];
478
+ for (const l of lines.slice(at + 1, at + 12)) {
479
+ const m = /^(?:[❯›>]\s*)?(\d)[.)]\s+(.+)$/.exec(l);
480
+ if (m) { if (options.length < 6 && !options.some((o) => o.n === m[1])) options.push({ n: m[1], label: clean(m[2].trim()).slice(0, 160) }); }
481
+ else if (options.length && l) break;
482
+ }
483
+ return options.length >= 2 ? { question: clean(lines[at]).slice(0, 200), options } : null;
484
+ }
485
+
486
+ module.exports = { permissionAsk, claudeHomes, callTarget, findTranscript, readHarness, readConversation, CONV_LIMIT, prettyTool, slugFor, resolvePid, descendants,
404
487
  MAX_BYTES, MAX_WALK_DEPTH, PS_SNAPSHOT_MS, defaultChildren };
@@ -31,7 +31,7 @@ function agentOf(x) {
31
31
  if (!state) return null;
32
32
  return {
33
33
  state, suggest: x.suggest || null, instruction: x.instruction || null,
34
- draft: t && (t.draft || t.body) ? { summary: t.draft || null, body: t.body || null } : null,
34
+ draft: t && (t.draft || t.body) ? { summary: t.draft || null, body: t.body || null, edited_at: t.edited_at || null } : null,
35
35
  sent: t && t.sent ? { link: t.sent.link || null, at: t.sent.at || null } : null,
36
36
  error: t ? t.error || null : null, session: t && t.session ? String(t.session) : null, asks: t ? t.asks || null : null, revisions: t ? t.revisions || 0 : 0, started_at: t ? t.started_at || null : null, decided_at: x.decided_at || null,
37
37
  };
@@ -77,7 +77,7 @@ function taskAgent(taskId, suggest, run) {
77
77
  const base = { suggest, instruction: null, draft: null, sent: null, error: null, session: null, asks: null, revisions: 0, started_at: null, decided_at: null };
78
78
  if (!t) return { state: 'suggest', ...base };
79
79
  return { ...base, state: TASK_STATE[t.status] || 'queued', instruction: t.instruction || null,
80
- draft: t.draft ? { summary: t.draft.summary || null, body: t.draft.body || null } : null,
80
+ draft: t.draft ? { summary: t.draft.summary || null, body: t.draft.body || null, edited_at: t.draft.edited_at || null } : null,
81
81
  sent: t.sent ? { link: t.sent.link || null, at: t.sent.at || null } : null, error: t.error || null,
82
82
  session: t.stalled_session ? String(t.stalled_session) : null, asks: t.asks || null, revisions: Array.isArray(t.revisions) ? t.revisions.length : 0,
83
83
  started_at: t.started_at || null, decided_at: (t.decided && t.decided.at) || null };
@@ -100,17 +100,29 @@ function localDraftSpec(item, instruction) {
100
100
  const localAgent = (item, run) => taskAgent(localTaskId(item), localSuggest(item), run);
101
101
  /** The newest of `runs` (newest first) that holds task `taskId`, or null. An item the PM added keeps its draft across days. */
102
102
  const runWith = (runs, taskId) => (runs || []).find((r) => r && Array.isArray(r.tasks) && r.tasks.some((t) => t && t.id === taskId)) || null;
103
+ /** The local dates of the last `days` days, newest first. */
104
+ function recentDates(nowMs, days = 7) {
105
+ const pad = (n) => String(n).padStart(2, '0');
106
+ const out = [];
107
+ for (let i = 0; i < days; i++) { const d = new Date(nowMs); d.setDate(d.getDate() - i); out.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`); }
108
+ return out;
109
+ }
103
110
  /** The day runs of the last `days` days, newest first, through `readDayRun(date)`. */
104
111
  function recentRuns(readDayRun, nowMs, days = 7) {
105
- const out = [];
106
- if (typeof readDayRun !== 'function') return out;
107
- const pad = (n) => String(n).padStart(2, '0');
108
- for (let i = 0; i < days; i++) {
109
- const d = new Date(nowMs); d.setDate(d.getDate() - i);
110
- let r = null; try { r = readDayRun(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`); } catch { r = null; }
111
- if (r) out.push(r);
112
+ if (typeof readDayRun !== 'function') return [];
113
+ return recentDates(nowMs, days).map((d) => { try { return readDayRun(d); } catch { return null; } }).filter(Boolean);
114
+ }
115
+ /** The brief item `id` the PM owns, from the newest brief of the last `days` days that holds it (`readBrief(date)` → brief or
116
+ * null), or null. An item of yesterday's brief that the PM kept in Work keeps its agent offer (amendment 12). */
117
+ function findBriefItem(readBrief, id, nowMs, days = 7) {
118
+ if (typeof readBrief !== 'function') return null;
119
+ for (const d of recentDates(nowMs, days)) {
120
+ let b = null; try { b = readBrief(d); } catch { b = null; }
121
+ if (!b || b.date !== d) continue;
122
+ const x = [...(Array.isArray(b.priorities) ? b.priorities : []), ...(Array.isArray(b.items) ? b.items : [])].find((y) => y && y.id === id);
123
+ if (x) return x.owner === 'pm' && !x.worth_asking ? x : null;
112
124
  }
113
- return out;
125
+ return null;
114
126
  }
115
127
 
116
128
  function fromBrief(brief, todayIso, run = null) {
@@ -148,4 +160,4 @@ function overlay(item, byId) {
148
160
  return out;
149
161
  }
150
162
 
151
- module.exports = { fromMyWork, fromBrief, addable, createdEvent, overlay, agentOf, briefAgent, briefSuggest, briefDraftSpec, taskAgent, localTaskId, localOffer, localSuggest, localDraftSpec, localAgent, runWith, recentRuns, AGENT };
163
+ module.exports = { fromMyWork, fromBrief, addable, createdEvent, overlay, agentOf, briefAgent, briefSuggest, briefDraftSpec, taskAgent, localTaskId, localOffer, localSuggest, localDraftSpec, localAgent, runWith, recentRuns, recentDates, findBriefItem, AGENT };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.214.0",
3
+ "version": "0.215.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.214.0",
9
+ "version": "0.215.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.214.0",
3
+ "version": "0.215.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -0,0 +1,164 @@
1
+ /* pm/c3-session-conv.js — a session as a conversation, the first view of the session sheet (c3-session-term.js).
2
+ * The approved mockup (2026-10-14, version 1) without its side-by-side layout; see planning/prd/c3-session-conversation-prd.md.
3
+ * convView({ id, onTerminal }) -> { node, input, start(), stop() }
4
+ * The words come from GET /api/sessions/conversation: Claude Code's own transcript, word for word (nothing is summarised and
5
+ * nothing is read from terminal bytes). A run of tool calls is one line that opens to list each call and its target. While a
6
+ * tool call is open and Claude Code's "Do you want to …" prompt is on the pane's screen, the reply's `ask` draws a card whose
7
+ * buttons type the option's number. Sends go over the page's terminal socket (tab-term.js typeIntoSession), as the Sessions
8
+ * panel's message box does; Stop types Esc. A plain shell, an unreadable record and an ended session each say so plainly. */
9
+ import { el } from './dom.js';
10
+ import { pmGet } from './http.js';
11
+ import { toast } from './c3-toast.js';
12
+ import { c3ic } from './pm-work-view.js';
13
+ import { renderMd } from './c3-session-chat.js';
14
+ import { button } from './primitives.js';
15
+
16
+ const POLL_MS = 1500;
17
+ const NOT_SENT = 'Not sent: the connection to the workspace server is down. Your message is still in the box.';
18
+
19
+ /** "Used Read ×2, Edit" for a run of tool calls. Pure, for Node. */
20
+ export const toolSummary = (tools) => 'Used ' + Object.entries(tools || {}).map(([n, k]) => (k > 1 ? n + ' ×' + k : n)).join(', ');
21
+ /** The status tag for a reply: [label, tone]. Pure, for Node. */
22
+ export function statusOf(r) {
23
+ if (!r) return ['Reading', 'muted'];
24
+ if (r.state === 'ended') return ['Ended', 'muted'];
25
+ if (r.state === 'shell') return ['Shell', 'muted'];
26
+ if (r.state !== 'conv') return ['Running', 'acc'];
27
+ if (r.ask) return ['Needs you', 'warning'];
28
+ if (r.turn === 'agent' || r.turn === 'tool') return ['Working', 'acc'];
29
+ if (r.turn === 'user') return ['Your turn', 'muted'];
30
+ return ['Running', 'acc'];
31
+ }
32
+
33
+ const keys = async (id, data) => { const t = await import('./tab-term.js').catch(() => null); return !!(t && typeof t.pressKeys === 'function' && await t.pressKeys(id, data)); };
34
+ const type = async (id, text) => { const t = await import('./tab-term.js').catch(() => null); return !!(t && typeof t.typeIntoSession === 'function' && await t.typeIntoSession(id, text)); };
35
+ /* a primitives button (keyboard, role) carrying the console3 look and children (the pattern c3-dock.js uses) */
36
+ const c3btn = (attrs, onClick, ...kids) => { const b = button({ variant: 'ghost', attrs, onClick }); b.replaceChildren(...kids); return b; };
37
+ const pill = (label, onClick, attrs = {}) => c3btn({ class: 'pill', ...attrs }, onClick, label);
38
+
39
+ export function convView({ id, onTerminal } = {}) {
40
+ const st = { data: null, pending: [], busy: false, timer: null, stick: true, sig: '' };
41
+ const tag = el('span', { class: 'tg', 'data-cv': 'status' });
42
+ const metaRest = el('span', { class: 'chips2', 'data-cv': 'meta' });
43
+ const meta = el('div', { class: 'c3t-cv-meta' }, tag, metaRest);
44
+ const feed = el('div', { class: 'c3t-cv-feed', 'data-cv': 'feed', 'aria-live': 'polite' });
45
+ const input = el('textarea', { rows: '1', 'aria-label': 'Message', placeholder: 'Message Claude', 'data-cv': 'msg',
46
+ onkeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } },
47
+ oninput: () => grow() });
48
+ const stopBtn = c3btn({ class: 'ib sm', title: 'Stop Claude (types Esc)', 'aria-label': 'Stop Claude', 'data-cv': 'stop', hidden: '' },
49
+ async () => { if (!await keys(id, '\x1b')) toast({ text: NOT_SENT, tone: 'error' }); else toast({ text: 'Stopped. Claude waits for your next message.' }); }, c3ic('stop', 'sm'));
50
+ const sendBtn = c3btn({ class: 'ib pri sm', title: 'Send (Enter)', 'aria-label': 'Send', 'data-cv': 'send' }, () => send(), c3ic('out', 'sm'));
51
+ const slash = el('span', { class: 'chips2' },
52
+ pill('/compact', () => { input.value = '/compact'; input.focus(); grow(); }, { title: 'Claude summarises the conversation so far to free room', 'data-cv-slash': '/compact' }),
53
+ pill('/clear', () => { input.value = '/clear'; input.focus(); grow(); }, { title: 'Start a fresh conversation in this session', 'data-cv-slash': '/clear' }));
54
+ const comp = el('div', { class: 'c3t-cv-comp', 'data-cv': 'composer' },
55
+ el('div', { class: 'box' }, input, stopBtn, sendBtn),
56
+ el('div', { class: 'hint' }, slash, el('span', { class: 'kb' }, 'Enter sends · Shift Enter for a new line')));
57
+ const node = el('div', { class: 'c3t-cv-main', 'data-c3-conv': id }, meta, feed, comp);
58
+
59
+ // the box grows with its lines, up to eight, by its own rows (no inline style)
60
+ function grow() { input.rows = Math.max(1, Math.min(8, input.value.split('\n').length)); }
61
+
62
+ async function send() {
63
+ const text = input.value.trim();
64
+ if (!text) return;
65
+ const p = { text, at: Date.now() };
66
+ st.pending.push(p); st.stick = true; input.value = ''; grow(); paint();
67
+ if (!await type(id, text)) {
68
+ st.pending = st.pending.filter((x) => x !== p); input.value = text; grow(); paint();
69
+ toast({ text: NOT_SENT, tone: 'error' });
70
+ return;
71
+ }
72
+ setTimeout(poll, 400);
73
+ }
74
+
75
+ function toolsRow(m) {
76
+ const calls = Array.isArray(m.calls) ? m.calls : [];
77
+ return el('details', { class: 'tools', 'data-chat': 'tool' },
78
+ el('summary', null, el('span', { class: 'car' }, c3ic('next', 'sm')), c3ic('tree', 'sm'), toolSummary(m.tools)),
79
+ calls.length ? el('ul', null, ...calls.map((c) => el('li', null, el('b', null, c.tool), c.target ? el('span', { title: c.target }, c.target) : null))) : null);
80
+ }
81
+ function askCard(a) {
82
+ return el('div', { class: 'perm', role: 'group', 'aria-label': 'Claude asks before it runs a tool', 'data-cv': 'ask' },
83
+ el('b', { class: 'chips2' }, c3ic('alert', 'sm'), st.data && st.data.openCall ? 'Claude wants to run ' + st.data.openCall : 'Claude asks before it goes on'),
84
+ el('div', { class: 'q' }, a.question),
85
+ el('div', { class: 'c3t-tray' },
86
+ ...a.options.map((o) => pill(o.label, async () => { if (!await keys(id, o.n)) toast({ text: NOT_SENT, tone: 'error' }); else setTimeout(poll, 500); },
87
+ { 'data-cv-opt': o.n, title: 'Answer "' + o.label + '" (types ' + o.n + ')' })),
88
+ c3btn({ class: 'chip2 lk', 'data-cv': 'ask-term' }, () => onTerminal && onTerminal(), c3ic('term', 'sm'), 'Answer in the terminal')));
89
+ }
90
+ function none(icon, title, text, ...btns) {
91
+ return el('div', { class: 'none', 'data-cv': 'none' }, c3ic(icon), el('b', null, title), el('span', { class: 'dim' }, text), el('div', { class: 'chips2' }, ...btns));
92
+ }
93
+ const termBtn = () => c3btn({ class: 'pill', 'data-cv': 'open-term' }, () => onTerminal && onTerminal(), c3ic('term', 'sm'), 'Open the terminal');
94
+
95
+ function paint() {
96
+ const d = st.data;
97
+ const [label, tone] = statusOf(d);
98
+ tag.dataset.tone = tone;
99
+ tag.textContent = label;
100
+ metaRest.replaceChildren(...[d && d.agent ? (d.agent === 'shell' ? 'Shell' : d.agent === 'claude' ? 'Claude' : d.agent) : null, d && d.model]
101
+ .filter(Boolean).map((t) => el('span', null, t)));
102
+ const conv = d && d.state === 'conv';
103
+ comp.hidden = !d || d.state === 'ended' || d.state === 'unlinked' || d.state === 'other';
104
+ input.placeholder = d && d.state === 'shell' ? 'Type a command for the shell' : d && d.ask ? 'Answer above, or type to tell Claude something else' : 'Message Claude';
105
+ slash.hidden = !conv;
106
+ stopBtn.hidden = !(conv && (d.turn === 'agent' || d.turn === 'tool') && !d.ask);
107
+
108
+ const kids = [];
109
+ if (!d) kids.push(el('div', { class: 'm st' }, 'Reading the conversation…'));
110
+ else if (d.state === 'shell') {
111
+ kids.push(none('term', 'No Claude in this shell yet', 'This is a plain shell. Start Claude here and its conversation shows in this view.',
112
+ c3btn({ class: 'pill', 'aria-pressed': 'true', 'data-cv': 'start-claude' },
113
+ async () => { if (!await type(id, 'claude')) toast({ text: NOT_SENT, tone: 'error' }); else setTimeout(poll, 1500); }, c3ic('spark', 'sm'), 'Start Claude here'),
114
+ termBtn()));
115
+ } else if (d.state === 'unlinked') {
116
+ kids.push(none('alert', "Claude's conversation can't be read here", d.missing + '. PMOS will not guess which transcript is this session\'s, because that could show another session\'s words.', termBtn()));
117
+ } else if (d.state === 'other') {
118
+ kids.push(none('term', 'This agent keeps no Claude transcript', 'Its output is in the terminal.', termBtn()));
119
+ } else if (d.state === 'ended') {
120
+ kids.push(none('clock', 'This session has ended', 'Its terminal output is kept in the Sessions panel under Past.'));
121
+ } else {
122
+ for (const m of d.messages || []) {
123
+ if (m.role === 'user') kids.push(el('div', { class: 'who me' }, 'You'), el('div', { class: 'm me', 'data-chat': 'me' }, m.text));
124
+ else if (m.role === 'assistant') kids.push(el('div', { class: 'who' }, c3ic('spark', 'sm'), 'Claude'), el('div', { class: 'm bot', 'data-chat': 'bot' }, renderMd(m.text)));
125
+ else kids.push(toolsRow(m));
126
+ }
127
+ if (!(d.messages || []).length && !st.pending.length) kids.push(el('div', { class: 'm st', 'data-chat': 'empty' }, 'No messages yet. Type below to talk to Claude.'));
128
+ }
129
+ for (const p of st.pending) kids.push(el('div', { class: 'who me' }, 'You'), el('div', { class: 'm me', 'data-chat': 'me', 'data-pending': '' }, p.text));
130
+ if (conv && d.ask) kids.push(askCard(d.ask));
131
+ else if (conv && (d.turn === 'agent' || d.turn === 'tool' || st.pending.length)) {
132
+ kids.push(el('div', { class: 'live', role: 'status', 'data-chat': 'working' }, el('span', { class: 'c3spin' }, c3ic('clock', 'sm')),
133
+ d.turn === 'tool' && d.openCall ? 'Running ' + d.openCall + ', or waiting for your allow in the terminal' : 'Claude is working…'));
134
+ }
135
+ const atEnd = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60;
136
+ feed.replaceChildren(el('div', { class: 'wrap' }, ...kids));
137
+ if (atEnd || st.stick) { feed.scrollTop = feed.scrollHeight; st.stick = false; }
138
+ }
139
+
140
+ async function poll() {
141
+ if (st.busy) return;
142
+ st.busy = true;
143
+ try {
144
+ const r = await pmGet('/api/sessions/conversation?id=' + encodeURIComponent(id)).catch(() => null);
145
+ if (!r || !r.ok) { if (!st.data) { st.data = { state: 'unlinked', messages: [], missing: (r && r.error) || 'The conversation could not be read' }; paint(); } return; }
146
+ const msgs = r.messages || [];
147
+ // a message sent from here is done when the transcript holds it; one Claude never recorded goes after a minute
148
+ st.pending = st.pending.filter((p) => !msgs.some((m) => m.role === 'user' && m.text.trim() === p.text && (!m.at || Date.parse(m.at) >= p.at - 5000))
149
+ && Date.now() - p.at < 60000);
150
+ const sig = [r.stamp, r.state, r.turn, r.ask ? JSON.stringify(r.ask) : '', st.pending.length].join('|');
151
+ if (sig === st.sig) return;
152
+ st.sig = sig; st.data = r;
153
+ paint();
154
+ } finally { st.busy = false; }
155
+ }
156
+
157
+ paint();
158
+ return {
159
+ node, input,
160
+ start() { if (!st.timer) { poll(); st.timer = setInterval(() => { if (!node.isConnected) return; poll(); }, POLL_MS); } },
161
+ stop() { if (st.timer) { clearInterval(st.timer); st.timer = null; } },
162
+ get state() { return st.data ? st.data.state : null; },
163
+ };
164
+ }
@@ -1,5 +1,7 @@
1
- /* pm/c3-session-term.js — a session's real terminal inside console3, in a sheet, so "Open the session" no longer leaves /c3.html.
2
- * openSessionTerm({ id, name })
1
+ /* pm/c3-session-term.js — a session inside console3, in a sheet, so "Open the session" no longer leaves /c3.html.
2
+ * openSessionTerm({ id, name, view }) view: 'conv' (the default) or 'term'
3
+ * The sheet opens on the conversation (c3-session-conv.js); Terminal shows the session's real terminal and Conversation comes back.
4
+ * The terminal is mounted on first use and stays attached while the sheet is open, so switching never restarts anything.
3
5
  * The terminal is tab-term.js's own (the one the main app draws): the same socket, attach, resize and input. Closing the sheet detaches the
4
6
  * pane and leaves the session running, as closing a tab in the main app does. xterm and its addons are the main app's vendored globals,
5
7
  * loaded here on the first open because /c3.html does not load them up front. */
@@ -9,6 +11,7 @@ import { toast } from './c3-toast.js';
9
11
  import { pmGet, pmPost } from './http.js';
10
12
  import { button } from './primitives.js';
11
13
  import { c3ic } from './pm-work-view.js';
14
+ import { convView } from './c3-session-conv.js';
12
15
 
13
16
  /* a primitives button (keyboard, role) carrying the console3 look and children (the pattern c3-dock.js uses) */
14
17
  const c3btn = (attrs, onClick, ...kids) => { const b = button({ variant: 'ghost', attrs, onClick }); b.replaceChildren(...kids); return b; };
@@ -135,35 +138,79 @@ function makePad(id, term, tab) {
135
138
  return { box, ta, dispose() { if (closed) return; closed = true; window.removeEventListener('pagehide', onHide); flush(); } };
136
139
  }
137
140
 
138
- export async function openSessionTerm({ id, name } = {}) {
141
+ /* the sheet's two views: the conversation (c3-session-conv.js) first, the live terminal one press away. VIEWS: [key, label, title] */
142
+ export const VIEWS = [['conv', 'Conversation', 'The conversation: your messages and Claude\'s replies'], ['term', 'Terminal', 'The live terminal of this session']];
143
+ /** Which view a session opens on: the one asked for, else Terminal for an agent that keeps no Claude transcript, else Conversation. Pure. */
144
+ export const firstView = (agent, asked) => (asked === 'term' || asked === 'conv' ? asked : agent && agent !== 'claude' && agent !== 'shell' ? 'term' : 'conv');
145
+ /* the running sessions the sheet's list shows (at 2/3 and Whole): newest first, background runs left out */
146
+ const listed = (all) => (Array.isArray(all) ? all : []).filter((x) => x && x.id && x.state !== 'exited' && x.shown !== 'exited' && !x.headless && !x.automatic)
147
+ .sort((a, b) => String(b.created || '').localeCompare(String(a.created || '')));
148
+
149
+ /** The session sheet: Conversation | Terminal, All sessions and the sizes. `view` 'term' opens straight on the terminal (Set up Laya). */
150
+ export async function openSessionTerm({ id, name, view } = {}) {
139
151
  if (!id) return null;
140
152
  rememberSession(id);
141
- if (!name) { const r = await pmGet('/api/sessions').catch(() => null); const x = r && Array.isArray(r.sessions) ? r.sessions.find((y) => y.id === id) : null; name = x ? x.name : id; }
142
- const [ok, term] = await Promise.all([loadVendor(), import('./tab-term.js').catch(() => null)]);
143
- if (!ok || !term) { toast({ text: 'The terminal could not be loaded on this page.', tone: 'error' }); return null; }
153
+ const r = await pmGet('/api/sessions').catch(() => null);
154
+ const all = r && Array.isArray(r.sessions) ? r.sessions : [];
155
+ const me = all.find((y) => y.id === id) || null;
156
+ if (!name) name = me ? me.name : id;
144
157
  const tab = { id: 'c3-session:' + id, kind: 'term', sessionId: id, spawn: false, sess: id };
145
158
  const host = el('div', { class: 'c3t-term', 'data-c3-session-term': id });
146
- const pad = makePad(id, term, tab);
147
- const wrap = el('div', { class: 'c3t-tw' }, host, pad.box);
159
+ let term = null; let pad = null; let mounted = false;
160
+ const wrap = el('div', { class: 'c3t-tw', 'data-c3-view-pane': 'term' }, host);
161
+ const conv = convView({ id, onTerminal: () => show('term') });
162
+ conv.node.setAttribute('data-c3-view-pane', 'conv');
163
+ const list = el('nav', { class: 'c3t-cv-list', 'aria-label': 'Running sessions', 'data-c3-sess-list': '' },
164
+ el('div', { class: 'eyebrow' }, 'Running'),
165
+ ...listed(all).map((x) => c3btn({ 'aria-current': String(x.id === id), 'data-c3-sess-pick': x.id, title: x.name },
166
+ () => { if (x.id !== id) openSessionTerm({ id: x.id, name: x.name }); },
167
+ el('span', { class: 'nm' }, x.name || x.id), el('span', { class: 'sub' }, [x.agent === 'shell' ? 'Shell' : x.agent, x.shown || x.state].filter(Boolean).join(' · ')))));
168
+ const body = el('div', { class: 'c3t-cv', 'data-c3-session': id }, list, el('div', { class: 'c3t-cv-panes' }, conv.node, wrap));
169
+
148
170
  // the size control: Normal is the 600px sheet, the others are the classic dock's presets. Hidden on a phone, where the sheet is full width.
149
171
  let size = sizeOf(store.get(SIZE_KEY));
150
172
  const sizeBtns = SIZES.map(([k, label, title]) => c3btn({ 'aria-pressed': String(k === size), 'data-c3-size': k, title, 'aria-label': title }, () => setSize(k), label));
151
173
  const seg = el('div', { class: 'seg c3t-size', role: 'group', 'aria-label': 'Terminal size', 'data-c3-size-seg': '' }, ...sizeBtns);
174
+ let cur = firstView(me && me.agent, view);
175
+ const viewBtns = VIEWS.map(([k, label, title]) => c3btn({ 'aria-pressed': String(k === cur), 'data-c3-view': k, title, 'aria-label': label }, () => show(k),
176
+ c3ic(k === 'conv' ? 'reply' : 'term', 'sm'), el('span', { class: 'lb' }, label)));
177
+ const views = el('div', { class: 'seg c3t-views', role: 'group', 'aria-label': 'View', 'data-c3-view-seg': '' }, ...viewBtns);
152
178
  // the way back to every session: the Sessions panel, in place of this sheet
153
- const all = c3btn({ class: 'chip2 lk', 'data-c3-all-sessions': '', title: 'All sessions: the Sessions panel' }, () => { sheet.close(); import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
154
- c3ic('menu', 'sm'), 'All sessions');
179
+ const allBtn = c3btn({ class: 'chip2 lk c3t-all', 'data-c3-all-sessions': '', title: 'All sessions: the Sessions panel', 'aria-label': 'All sessions' }, () => { sheet.close(); import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
180
+ c3ic('menu', 'sm'), el('span', { class: 'lb' }, 'All sessions'));
155
181
  const cls = (k) => 'tsz-' + k;
156
182
  // Esc and Tab belong to the shell while the terminal has focus (Esc interrupts an agent); the close button and a press outside still close.
157
- // The scratchpad is outside the terminal host, so its keys are the page's own.
158
- const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body: wrap, actions: [all, seg], cls: cls(size),
159
- onClose: () => { pad.dispose(); term.disposeTerm(tab); }, passKeys: (e) => host.contains(e.target) });
183
+ // The scratchpad and the conversation are outside the terminal host, so their keys are the page's own.
184
+ const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body, actions: [views, allBtn, seg], cls: 'c3t-ss ' + cls(size),
185
+ onClose: () => { conv.stop(); if (pad) pad.dispose(); if (term && mounted) term.disposeTerm(tab); }, passKeys: (e) => host.contains(e.target) });
186
+
187
+ async function mountTerminal() {
188
+ if (mounted) return true;
189
+ const [ok, t] = await Promise.all([loadVendor(), import('./tab-term.js').catch(() => null)]);
190
+ if (!ok || !t) { toast({ text: 'The terminal could not be loaded on this page.', tone: 'error' }); return false; }
191
+ if (mounted || !host.isConnected) return mounted;
192
+ term = t; mounted = true;
193
+ pad = makePad(id, term, tab);
194
+ wrap.append(pad.box);
195
+ term.mountTerm(host, tab);
196
+ return true;
197
+ }
198
+ async function show(k) {
199
+ cur = k === 'term' ? 'term' : 'conv';
200
+ viewBtns.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.c3View === cur)));
201
+ body.dataset.view = cur;
202
+ conv.node.hidden = cur !== 'conv'; wrap.hidden = cur !== 'term';
203
+ if (cur === 'conv') { conv.start(); requestAnimationFrame(() => conv.input.focus()); return; }
204
+ conv.stop(); // the hidden conversation does not read the transcript; it reads it again on the way back
205
+ if (!await mountTerminal()) return;
206
+ requestAnimationFrame(() => { term.refitVisible(); term.focusTerm(tab); });
207
+ }
160
208
  function setSize(k) {
161
209
  sheet.sheet.classList.remove(cls(size)); size = sizeOf(k); sheet.sheet.classList.add(cls(size));
162
210
  store.set(SIZE_KEY, size);
163
211
  sizeBtns.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.c3Size === size)));
164
- requestAnimationFrame(() => { term.refitVisible(); requestAnimationFrame(() => term.refitVisible()); });
212
+ if (term) requestAnimationFrame(() => { term.refitVisible(); requestAnimationFrame(() => term.refitVisible()); });
165
213
  }
166
- term.mountTerm(host, tab);
167
- requestAnimationFrame(() => { term.refitVisible(); term.focusTerm(tab); });
214
+ await show(cur);
168
215
  return sheet;
169
216
  }
@@ -613,7 +613,7 @@ export function mountSettings(host, ctx = {}) {
613
613
  M.typesafe = r.key; delete M.err['typesafe.local'];
614
614
  M.localTest = { ok: !!r.started, text: r.started ? 'A terminal named Set up Laya opened with the commands typed in. Press Enter there, wait until Laya says it is running, then press Test.' : 'No terminal opened (' + (r.reason || 'unknown') + '). Run this in a terminal: ' + r.command };
615
615
  // the server only starts the session; the page must open it, or nothing appears to happen
616
- if (r.started && r.session) import('./c3-session-term.js').then((m) => m.openSessionTerm({ id: r.session, name: 'Set up Laya' })).catch(() => {});
616
+ if (r.started && r.session) import('./c3-session-term.js').then((m) => m.openSessionTerm({ id: r.session, name: 'Set up Laya', view: 'term' })).catch(() => {});
617
617
  } else { M.localTest = null; M.err['typesafe.local'] = (r && r.error) || 'Not started.'; }
618
618
  draw();
619
619
  };
@@ -1689,6 +1689,66 @@
1689
1689
  /* terminal sheet sizes, as the classic dock has them; the phone sheet is already full width */
1690
1690
  .c3 .c3t-size{flex:none}
1691
1691
  .c3 .c3t-size button{padding:4px 8px}
1692
+ /* the session sheet (c3-session-term.js): Conversation | Terminal, a list of running sessions at 2/3 and Whole, and the conversation (c3-session-conv.js) */
1693
+ .c3 .c3t-views button .ic{flex:none}
1694
+ .c3 .c3t-cv{display:grid;grid-template-columns:minmax(0,1fr);height:calc(100vh - 150px);min-height:240px;min-width:0}
1695
+ .c3 .c3t-cv-panes{display:flex;flex-direction:column;min-height:0;min-width:0}
1696
+ .c3 .c3t-cv-panes>[hidden]{display:none}
1697
+ .c3 .c3t-cv-panes .c3t-tw{flex:1 1 0;height:auto;min-height:0}
1698
+ .c3 .c3t-cv-list{display:none;border-right:1px solid var(--border);overflow:auto;padding:0 var(--sp-2) 0 0;margin-right:var(--sp-3);gap:2px;align-content:start}
1699
+ .c3 .c3t-cv-list .eyebrow{padding:var(--sp-2) 10px 4px;font:500 var(--fs-1) var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--muted-foreground)}
1700
+ .c3 .c3t-cv-list button{display:grid;gap:2px;text-align:left;border:0;background:none;padding:8px 10px;border-radius:8px;cursor:pointer;color:var(--foreground);font:inherit;font-size:var(--fs-3);width:100%;min-width:0}
1701
+ .c3 .c3t-cv-list button .nm,.c3 .c3t-cv-list button .sub{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1702
+ .c3 .c3t-cv-list button .sub{font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
1703
+ .c3 .c3t-cv-list button[aria-current="true"]{background:color-mix(in oklab,var(--acc) 12%,transparent)}
1704
+ .c3 .c3t-cv-list button[aria-current="true"] .nm{color:var(--acc)}
1705
+ .c3 .c3t-cv-main{display:flex;flex-direction:column;flex:1 1 0;min-height:0;min-width:0}
1706
+ .c3 .c3t-cv-meta{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap;padding-bottom:var(--sp-2);border-bottom:1px solid var(--border);font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
1707
+ .c3 .c3t-cv-meta .tg{--tc:var(--acc)}
1708
+ .c3 .c3t-cv-meta .tg[data-tone="muted"]{--tc:var(--muted-foreground)}
1709
+ .c3 .c3t-cv-meta .tg[data-tone="warning"]{--tc:var(--warning)}
1710
+ .c3 .c3t-cv-feed{flex:1 1 0;min-height:0;overflow:auto;padding:var(--sp-3) 2px}
1711
+ .c3 .c3t-cv-feed>.wrap{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:var(--sp-2)}
1712
+ .c3 .c3t-cv .m{padding:8px 14px;border-radius:8px;max-width:85%;overflow-wrap:anywhere;font-size:var(--fs-3);line-height:1.5}
1713
+ .c3 .c3t-cv .m.me{align-self:flex-end;background:var(--acc);color:var(--on-accent);white-space:pre-wrap}
1714
+ .c3 .c3t-cv .m.bot{align-self:flex-start;background:var(--muted)}
1715
+ .c3 .c3t-cv .m.st{color:var(--muted-foreground);font:var(--fs-2) var(--mono);padding:0}
1716
+ .c3 .c3t-cv .m.bot .c3t-md{display:grid;gap:6px}
1717
+ .c3 .c3t-cv .m.bot .c3t-md>*{margin:0}
1718
+ .c3 .c3t-cv .m.bot ul,.c3 .c3t-cv .m.bot ol{padding-left:18px}
1719
+ .c3 .c3t-cv .m.bot code{font:var(--fs-2) var(--mono)}
1720
+ .c3 .c3t-cv .m.bot pre{margin:0;padding:8px 10px;border-radius:6px;background:var(--background);border:1px solid var(--border);overflow:auto;max-height:240px}
1721
+ .c3 .c3t-cv .who{display:flex;gap:6px;align-items:center;margin-top:var(--sp-2);font:500 var(--fs-1) var(--mono);color:var(--muted-foreground)}
1722
+ .c3 .c3t-cv .who.me{align-self:flex-end}
1723
+ .c3 .c3t-cv details.tools{align-self:flex-start;max-width:85%;font:var(--fs-2) var(--mono);color:var(--muted-foreground)}
1724
+ .c3 .c3t-cv details.tools summary{cursor:pointer;display:flex;gap:6px;align-items:center;list-style:none;padding:2px 0}
1725
+ .c3 .c3t-cv details.tools summary::-webkit-details-marker{display:none}
1726
+ .c3 .c3t-cv details.tools[open] summary .car{display:inline-flex;transform:rotate(90deg)}
1727
+ .c3 .c3t-cv details.tools ul{list-style:none;margin:4px 0 0;padding:0 0 0 18px;display:grid;gap:4px;border-left:2px solid var(--border)}
1728
+ .c3 .c3t-cv details.tools li{display:flex;gap:6px;align-items:baseline;min-width:0}
1729
+ .c3 .c3t-cv details.tools li b{color:var(--foreground);font-weight:500}
1730
+ .c3 .c3t-cv details.tools li span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
1731
+ .c3 .c3t-cv .live{align-self:flex-start;display:flex;gap:8px;align-items:center;font:var(--fs-2) var(--mono);color:var(--muted-foreground)}
1732
+ .c3 .c3t-cv .perm{border:1px solid var(--warning);border-radius:8px;padding:var(--sp-3);display:grid;gap:var(--sp-2);background:color-mix(in oklab,var(--warning) 6%,var(--card))}
1733
+ .c3 .c3t-cv .perm .q{font:var(--fs-2) var(--mono)}
1734
+ .c3 .c3t-cv .perm .c3t-tray{display:flex;gap:var(--sp-2);flex-wrap:wrap;align-items:center}
1735
+ .c3 .c3t-cv .none{margin:var(--sp-4) auto;max-width:420px;text-align:center;display:grid;gap:var(--sp-3);justify-items:center}
1736
+ .c3 .c3t-cv .none>.ic{width:28px;height:28px;color:var(--muted-foreground)}
1737
+ .c3 .c3t-cv .none .dim{font-size:var(--fs-2)}
1738
+ .c3 .c3t-cv-comp{border-top:1px solid var(--border);padding-top:var(--sp-2);display:grid;gap:6px}
1739
+ .c3 .c3t-cv-comp[hidden],.c3 .c3t-cv-comp .box>[hidden]{display:none}
1740
+ .c3 .c3t-cv-comp .box{display:flex;gap:var(--sp-2);align-items:flex-end;border:1px solid var(--border);border-radius:8px;padding:6px 6px 6px 12px;background:var(--card)}
1741
+ .c3 .c3t-cv-comp .box:focus-within{border-color:var(--ring)}
1742
+ .c3 .c3t-cv-comp textarea{flex:1;border:0;background:none;resize:none;font:inherit;font-size:var(--fs-3);color:var(--foreground);padding:5px 0;outline:none;min-width:0}
1743
+ .c3 .c3t-cv-comp .hint{display:flex;gap:var(--sp-2);justify-content:space-between;align-items:center;flex-wrap:wrap;font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
1744
+ .c3 .c3t-cv-comp .hint .chips2[hidden]{display:none}
1745
+ @media (min-width:761px){
1746
+ .c3:not(.ph) .sheet.c3t-ss.tsz-two-thirds .c3t-cv,.c3:not(.ph) .sheet.c3t-ss.tsz-whole .c3t-cv{grid-template-columns:220px minmax(0,1fr)}
1747
+ .c3:not(.ph) .sheet.c3t-ss.tsz-two-thirds .c3t-cv-list,.c3:not(.ph) .sheet.c3t-ss.tsz-whole .c3t-cv-list{display:grid}
1748
+ }
1749
+ .c3.ph .c3t-views .lb,.c3.ph .c3t-all .lb{display:none}
1750
+ .c3.ph .c3t-cv-comp .hint .kb{display:none}
1751
+ .c3.ph .c3t-cv .m,.c3.ph .c3t-cv details.tools{max-width:92%}
1692
1752
  .c3.ph .c3t-size{display:none}
1693
1753
  @media (min-width:761px){
1694
1754
  .c3 .sheet.wide.tsz-half{width:50vw}
@@ -1891,6 +1951,8 @@
1891
1951
  .c3 .wk-agent-sum{margin:0}
1892
1952
  .c3 .wk-agent-body{--tc:var(--acc)}
1893
1953
  .c3 .wk-agent-body .sit{white-space:pre-line}
1954
+ .c3 .wk-agent-editbox{display:grid;gap:var(--sp-2)}
1955
+ .c3 .wk-agent-edit{width:100%;box-sizing:border-box;font:inherit;font-size:var(--fs-2);line-height:1.5;padding:8px 10px;border:1px solid var(--border);background:var(--background);color:var(--foreground);border-radius:6px;resize:vertical}
1894
1956
  .c3 .tag.wk-off{background:var(--accw);color:var(--acc)}
1895
1957
  .c3 .tag.wk-nb{background:var(--muted);color:var(--foreground)}
1896
1958
  .c3 .tag.wk-on{background:var(--acc);color:var(--background)}