create-pmos 0.214.0 → 0.216.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-sessions-panel-test.js +69 -11
- package/workspace/c3-work-test.js +2 -2
- package/workspace/guide/console3-sessions.md +33 -9
- package/workspace/guide/my-work.md +5 -2
- package/workspace/lib/day-run.js +30 -1
- package/workspace/lib/my-work.js +1 -1
- package/workspace/lib/proactive-cli.js +8 -2
- package/workspace/lib/routes-my-work.js +44 -17
- package/workspace/lib/routes-pm-items.js +8 -0
- package/workspace/lib/transcript-read.js +93 -10
- package/workspace/lib/work-assigned.js +23 -11
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-case.js +8 -2
- package/workspace/public/pm/c3-session-conv.js +164 -0
- package/workspace/public/pm/c3-session-term.js +63 -16
- package/workspace/public/pm/c3-settings.js +1 -1
- package/workspace/public/pm/console3.css +67 -0
- package/workspace/public/pm/pm-work-view.js +44 -22
- package/workspace/public/pm/tab-term.js +9 -0
- package/workspace/server.js +32 -12
- package/workspace/skills/day-task.skill +1 -0
- package/workspace/test-files.json +1 -1
|
@@ -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
|
-
|
|
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 =
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.216.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.216.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* normalizeCase(model) -> the canonical case (pure; accepts the mockup's field names and the descriptive ones)
|
|
5
5
|
* slideFor(caseModel, pickedId) -> { enabled, reason, label } for the slide under the case (pure)
|
|
6
6
|
* renderCase(model, opts) -> the `.case` element; opts: { picked, onPick(id, option), onConfirm({ option, hash, case }),
|
|
7
|
+
* noConf (no confidence figure: the recommended option says "Suggested", the others nothing),
|
|
8
|
+
* renderQuote(text) → nodes (the quote drawn as rich text, for example a markdown draft),
|
|
7
9
|
* outbox: boolean, onGo(go) }
|
|
8
10
|
*
|
|
9
11
|
* What the mockup's case file holds, top to bottom, and what this draws:
|
|
@@ -114,6 +116,7 @@ const fxList = (a) => el('div', { class: 'fx' }, ...a.map((f) => el('span', { cl
|
|
|
114
116
|
export function renderCase(model, opts = {}) {
|
|
115
117
|
const c = normalizeCase(model);
|
|
116
118
|
const outbox = !!opts.outbox;
|
|
119
|
+
const noConf = !!opts.noConf;
|
|
117
120
|
let picked = c.options.some((o) => o.id === opts.picked) ? opts.picked : c.recId;
|
|
118
121
|
const root = el('div', { class: 'case', 'data-case': c.id || null, style: '--tc:' + c.tc });
|
|
119
122
|
const optsHost = c.options.length ? el('div', { class: 'opts', role: 'radiogroup', 'aria-label': 'Options' }) : null;
|
|
@@ -125,11 +128,14 @@ export function renderCase(model, opts = {}) {
|
|
|
125
128
|
return el('button', { type: 'button', class: 'opt' + (o.rec ? ' rec' : ''), role: 'radio', 'aria-checked': String(on), 'aria-pressed': String(on), 'data-opt': o.id,
|
|
126
129
|
onclick: () => pick(o.id) },
|
|
127
130
|
el('span', { class: 'pk' }, c3ic('check', 'sm')),
|
|
128
|
-
|
|
131
|
+
// `noConf`: a real item has no confidence figure, so only the recommended option is marked, "Suggested", with no bar
|
|
132
|
+
noConf ? (o.rec ? el('span', { class: 'rk' }, c3ic('spark', 'sm'), 'Suggested') : null)
|
|
133
|
+
: el('span', { class: 'rk', title: 'Confidence from the sources listed above' }, o.rec ? [c3ic('spark', 'sm'), 'Recommended · ' + c.confidence + '%'] : null),
|
|
129
134
|
el('b', null, o.label),
|
|
130
135
|
fxList(o.effects),
|
|
131
136
|
o.rec ? el('div', { class: 'conf' }, el('i', { style: 'width:' + c.confidence + '%' })) : null);
|
|
132
137
|
}));
|
|
138
|
+
if (noConf) optsHost.querySelectorAll('.conf').forEach((n) => n.remove()); // no figure, no bar
|
|
133
139
|
};
|
|
134
140
|
let disposeHold = null;
|
|
135
141
|
const drawSlide = () => {
|
|
@@ -164,7 +170,7 @@ export function renderCase(model, opts = {}) {
|
|
|
164
170
|
if (opts.onPick) opts.onPick(id, c.options.find((o) => o.id === id));
|
|
165
171
|
}
|
|
166
172
|
|
|
167
|
-
if (c.quote) root.append(el('blockquote', { class: 'qd' }, c.quote));
|
|
173
|
+
if (c.quote) root.append(el('blockquote', { class: 'qd' + (opts.renderQuote ? ' qd-md' : '') }, opts.renderQuote ? opts.renderQuote(c.quote) : c.quote));
|
|
168
174
|
root.append(el('p', { class: 'sit' }, c.situation));
|
|
169
175
|
if (c.move) root.append(el('div', { class: 'c3ag' }, el('div', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, c3ic('spark', 'sm'), ' Suggested move: ' + c.move)));
|
|
170
176
|
if (c.evidence.length) {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
const wrap = el('div', { class: 'c3t-tw' }, host
|
|
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
|
|
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
|
|
158
|
-
const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body
|
|
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
|
-
|
|
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
|
};
|