crewforth 3.0.4 → 3.1.0-rc.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +3 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +86 -34
  5. package/kit/CLAUDE.md +1 -1
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-commit-agent.md +7 -3
  8. package/kit/agents/crew-frontend-expert.md +4 -4
  9. package/kit/agents/crew-performance-expert.md +1 -1
  10. package/kit/agents/crew-review-agent.md +1 -1
  11. package/kit/eval/doctor.sh +57 -3
  12. package/kit/eval/golden-routing.txt +13 -0
  13. package/kit/eval/lib/git-bash.sh +11 -2
  14. package/kit/eval/parser-conformance.sh +1 -0
  15. package/kit/eval/smoke-test.sh +2543 -373
  16. package/kit/hooks/commit-msg +1 -1
  17. package/kit/hooks/context-usage.sh +7 -1
  18. package/kit/hooks/guard-bash.sh +1545 -104
  19. package/kit/hooks/guard-commit-scan.sh +75 -19
  20. package/kit/hooks/guard-powershell.sh +65 -15
  21. package/kit/hooks/guard-write.sh +74 -22
  22. package/kit/hooks/no-bash-guard.ps1 +71 -0
  23. package/kit/hooks/pre-commit +5 -2
  24. package/kit/hooks/prompt-approval.sh +484 -0
  25. package/kit/hooks/route-hint.sh +13 -5
  26. package/kit/hooks/session-rehydrate.sh +18 -0
  27. package/kit/hooks/skill-trust.sh +17 -2
  28. package/kit/owned-blobs.tsv +43 -0
  29. package/kit/settings.json +20 -4
  30. package/kit/skills/a11y/SKILL.md +1 -0
  31. package/kit/skills/crew-doctor/SKILL.md +2 -2
  32. package/kit/skills/frontend/SKILL.md +3 -3
  33. package/kit/skills/frontend-flutter/SKILL.md +94 -0
  34. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
  35. package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
  36. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  37. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  38. package/kit/studio/server/index.js +14 -4
  39. package/kit/studio/server/lib/graph.js +64 -3
  40. package/kit/studio/server/lib/kit.js +16 -2
  41. package/kit/studio/server/lib/permissions.js +77 -2
  42. package/kit/studio/server/lib/projects.js +60 -7
  43. package/kit/studio/server/lib/session.js +16 -2
  44. package/kit/studio/web/app.js +1765 -573
  45. package/kit/studio/web/approvals.js +124 -0
  46. package/kit/studio/web/canvas.js +932 -885
  47. package/kit/studio/web/chat.js +322 -145
  48. package/kit/studio/web/convo.js +174 -0
  49. package/kit/studio/web/dock.js +221 -0
  50. package/kit/studio/web/graph-plan.js +457 -0
  51. package/kit/studio/web/icons/builtin.svg +1 -0
  52. package/kit/studio/web/index.html +116 -50
  53. package/kit/studio/web/inspect.js +110 -0
  54. package/kit/studio/web/list-plan.js +112 -0
  55. package/kit/studio/web/list.js +157 -0
  56. package/kit/studio/web/liveness.js +66 -0
  57. package/kit/studio/web/nav.js +237 -0
  58. package/kit/studio/web/newsession.js +235 -0
  59. package/kit/studio/web/press.js +65 -0
  60. package/kit/studio/web/style.css +1281 -1014
  61. package/kit/studio/web/theme.js +43 -0
  62. package/kit/studio/web/timeline-plan.js +291 -0
  63. package/kit/studio/web/timeline.js +400 -0
  64. package/kit/studio/web/tokens.css +131 -0
  65. package/kit/studio/web/toolbar-fit.js +35 -0
  66. package/package.json +5 -4
@@ -0,0 +1,66 @@
1
+ // Live, Stale or Offline: when did the panel last hear from its server.
2
+ //
3
+ // "Heard from" is any answer on any channel: the fleet poll, the project poll, an event on the graph stream. The
4
+ // fleet poll alone would be the wrong clock. Each of its answers waits on a `claude agents` spawn, which the server
5
+ // gives ten seconds, so a slow CLI stretches that poll while the rest of the panel is fed as usual.
6
+ //
7
+ // Measured, gaps between answers:
8
+ // fleet poll, healthy macOS 2.2 s quiet, 2.5 s under load · Windows 2.1 s quiet, 2.5 s under load
9
+ // fleet poll, CLI takes 4 s 6.5 s
10
+ // fleet poll, CLI takes 12 s 12.8 s (every answer "not measured": the spawn timed out)
11
+ // project poll, same two servers 5.2 s (it does not wait on the CLI)
12
+ //
13
+ // So with every channel counted the longest healthy silence is one project poll, 5.2 s, and STALE_AFTER_MS is
14
+ // about twice that. Counting the fleet poll alone, ten seconds would have called a panel with a slow CLI stale
15
+ // while it was answering every five.
16
+ //
17
+ // Offline is not a longer silence. It is the last request failing: a refused connection answers at once, so there
18
+ // is nothing to wait for.
19
+ export const STALE_AFTER_MS = 10000;
20
+
21
+ /**
22
+ * @param nowMs the clock
23
+ * @param lastOkMs when an answer last arrived, or null if none has
24
+ * @param lastFailed whether the most recent request failed
25
+ * @returns { state: 'connecting' | 'live' | 'stale' | 'offline', word, detail, tone }
26
+ */
27
+ export function liveness(nowMs, lastOkMs, lastFailed) {
28
+ if (lastFailed) {
29
+ return {
30
+ state: 'offline',
31
+ word: 'Offline',
32
+ tone: 'fail',
33
+ detail: lastOkMs === null ? 'the server has not answered' : `last update ${clock(lastOkMs)}`,
34
+ };
35
+ }
36
+ if (lastOkMs === null) return { state: 'connecting', word: 'Connecting', tone: null, detail: '' };
37
+ const silence = Math.max(0, nowMs - lastOkMs);
38
+ const said = `${Math.round(silence / 1000)}s ago`;
39
+ if (silence > STALE_AFTER_MS) return { state: 'stale', word: 'Stale', tone: 'waiting', detail: said };
40
+ return { state: 'live', word: 'Live', tone: 'good', detail: said };
41
+ }
42
+
43
+ /**
44
+ * What the stage says while the server is not answering: which picture is on screen, and when the next try is.
45
+ *
46
+ * The picture stays — it is the last thing known — and it is named as old. The countdown is to a request this
47
+ * page is really going to make; "Retry now" makes it at once.
48
+ *
49
+ * @param state liveness().state
50
+ * @param nextTryMs when the next poll is due, or null when none is scheduled
51
+ */
52
+ export function offlineNote(state, lastOkMs, nextTryMs, nowMs) {
53
+ if (state !== 'offline') return null;
54
+ const left = nextTryMs == null ? null : Math.max(0, Math.ceil((nextTryMs - nowMs) / 1000));
55
+ return {
56
+ text: lastOkMs === null ? 'The server has not answered yet, so there is nothing to show.' : `Showing the last update from ${clock(lastOkMs)}.`,
57
+ retry: left == null ? 'Reconnecting' : left === 0 ? 'Reconnecting now' : `Reconnecting in ${left}s`,
58
+ stale: lastOkMs !== null,
59
+ };
60
+ }
61
+
62
+ function clock(ms) {
63
+ const d = new Date(ms);
64
+ const two = (n) => String(n).padStart(2, '0');
65
+ return `${two(d.getHours())}:${two(d.getMinutes())}`;
66
+ }
@@ -0,0 +1,237 @@
1
+ // What the top bar and the navigator say, kept apart from where they draw it.
2
+ //
3
+ // Everything here is a function of the data the server sent. None of it touches the page, so each rule can be
4
+ // asserted on its own: which word a status gets, what a version badge offers to copy, what a row is called when
5
+ // the transcript carried no title. The drawing is in app.js.
6
+ //
7
+ // One rule runs through it, the panel's own: a value that was not read is never written as if it had been. A
8
+ // status nobody recognises keeps its own word and gets no colour; a branch that could not be read leaves the line
9
+ // without one; a project that cannot be told to update by a command gets a hint instead of a command.
10
+
11
+ /* ------------------------------------------------------------- status --- */
12
+
13
+ // The words the design uses, and the token each is drawn in. `tone` names a status-* token; null is the hollow
14
+ // ring of something that is over.
15
+ const AGENT_STATUS = {
16
+ running: { word: 'running', tone: 'busy' },
17
+ starting: { word: 'starting', tone: 'busy' },
18
+ done: { word: 'done', tone: 'good' },
19
+ failed: { word: 'failed', tone: 'fail' },
20
+ killed: { word: 'killed', tone: 'fail' },
21
+ stopped: { word: 'stopped', tone: 'fail' },
22
+ stale: { word: 'stale', tone: 'idle' },
23
+ ended: { word: 'ended', tone: null },
24
+ };
25
+ const CHIP_ORDER = ['running', 'starting', 'stale', 'done', 'failed', 'killed', 'stopped', 'ended'];
26
+
27
+ /** The word and the tone one agent status is drawn in. A status this table does not know keeps its own word. */
28
+ export function agentStatus(status) {
29
+ const known = AGENT_STATUS[status];
30
+ return known
31
+ ? { word: known.word, tone: known.tone, known: true }
32
+ : { word: String(status ?? 'unknown'), tone: null, known: false };
33
+ }
34
+
35
+ /**
36
+ * One chip per status that has at least one agent, so the chips add up to the total. A status this table does
37
+ * not know is still a chip: its own word, no colour, `known: false`.
38
+ */
39
+ export function summaryChips(byStatus) {
40
+ const entries = Object.entries(byStatus ?? {}).filter(([, n]) => n > 0);
41
+ const rank = (k) => { const i = CHIP_ORDER.indexOf(k); return i === -1 ? CHIP_ORDER.length : i; };
42
+ entries.sort((a, b) => rank(a[0]) - rank(b[0]) || a[0].localeCompare(b[0]));
43
+ return entries.map(([status, count]) => {
44
+ const known = AGENT_STATUS[status];
45
+ return {
46
+ status,
47
+ count,
48
+ known: Boolean(known),
49
+ tone: known ? known.tone : null,
50
+ text: `${count} ${known ? known.word : status}`,
51
+ };
52
+ });
53
+ }
54
+
55
+ // A session, as the machine reports it. `claude agents` knows three states; a session it does not list is not
56
+ // running anywhere on this machine.
57
+ const SESSION_STATUS = {
58
+ busy: { word: 'Running', tone: 'busy' },
59
+ waiting: { word: 'Needs you', tone: 'waiting' },
60
+ idle: { word: 'Idle', tone: 'idle' },
61
+ };
62
+
63
+ /**
64
+ * @param fleet the /api/fleet answer, or null before the first one
65
+ * @returns { key, word, tone, known } — key is 'unmeasured' when the fleet was not read: that is not "ended".
66
+ */
67
+ export function sessionStatus(sessionId, fleet) {
68
+ if (!fleet || fleet.measured === false) return { key: 'unmeasured', word: null, tone: null, known: false };
69
+ const row = (fleet.sessions ?? []).find((s) => s.sessionId === sessionId && s.local !== false);
70
+ if (!row) return { key: 'ended', word: 'Ended', tone: null, known: true };
71
+ const known = SESSION_STATUS[row.status];
72
+ if (!known) return { key: String(row.status), word: String(row.status), tone: null, known: false };
73
+ return { key: row.status, word: known.word, tone: known.tone, known: true, waitingFor: row.waitingFor ?? null };
74
+ }
75
+
76
+ /**
77
+ * Is this the first run: the transcripts were read, and there are none?
78
+ * An answer that could not be read is not a first run — it is not measured, and the navigator says that.
79
+ */
80
+ export function isFirstRun(data) {
81
+ return Boolean(data) && data.measured !== false && Array.isArray(data.projects) && data.projects.length === 0;
82
+ }
83
+
84
+ /* --------------------------------------------------------------- rows --- */
85
+
86
+ /** What a session is called: the label given here, the title it carries, and only then its id. */
87
+ export function sessionName(sn, labels) {
88
+ const own = labels?.[sn.sessionId];
89
+ if (typeof own === 'string' && own.trim()) return own.trim();
90
+ if (sn.title) return sn.title;
91
+ return String(sn.sessionId).slice(0, 8);
92
+ }
93
+
94
+ /** The line under the name: branch, state, agents. A part that was not read is left out, not filled in. */
95
+ export function sessionSub(sn, status) {
96
+ const parts = [];
97
+ if (sn.branch) parts.push(sn.branch);
98
+ if (status?.key === 'waiting' && status.waitingFor) parts.push(`Needs you · ${status.waitingFor}`);
99
+ else if (status?.word) parts.push(status.word);
100
+ if (sn.agentCount > 0) parts.push(`${sn.agentCount} ${sn.agentCount === 1 ? 'agent' : 'agents'}`);
101
+ return parts.join(' · ');
102
+ }
103
+
104
+ /** "now", "4m", "2h", "3d" — how long ago, coarse on purpose. */
105
+ export function ago(thenMs, nowMs) {
106
+ if (typeof thenMs !== 'number' || !Number.isFinite(thenMs)) return '';
107
+ const s = Math.max(0, Math.round((nowMs - thenMs) / 1000));
108
+ if (s < 60) return 'now';
109
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
110
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
111
+ return `${Math.floor(s / 86400)}d`;
112
+ }
113
+
114
+ /** "seen just now", "seen 3 min ago", "seen 2 h ago" — for a fact recorded earlier, not read now. */
115
+ export function seenAgo(thenMs, nowMs) {
116
+ if (typeof thenMs !== 'number' || !Number.isFinite(thenMs)) return 'seen at an unrecorded time';
117
+ const s = Math.max(0, Math.round((nowMs - thenMs) / 1000));
118
+ if (s < 60) return 'seen just now';
119
+ if (s < 3600) return `seen ${Math.floor(s / 60)} min ago`;
120
+ if (s < 86400) return `seen ${Math.floor(s / 3600)} h ago`;
121
+ return `seen ${Math.floor(s / 86400)} d ago`;
122
+ }
123
+
124
+ /* ------------------------------------------------------------- search --- */
125
+
126
+ /** Project name, branch, session id, and the name the row shows. An empty query matches everything. */
127
+ export function matchesSession(query, project, sn, labels) {
128
+ const q = String(query ?? '').trim().toLowerCase();
129
+ if (!q) return true;
130
+ return [project.label, sn.branch, sn.sessionId, sessionName(sn, labels)]
131
+ .some((v) => typeof v === 'string' && v.toLowerCase().includes(q));
132
+ }
133
+
134
+ /** `text` cut into parts, the ones that match `query` marked. Case is kept as written. */
135
+ export function highlight(text, query) {
136
+ const t = String(text ?? '');
137
+ const q = String(query ?? '').trim().toLowerCase();
138
+ if (!q) return [{ text: t, hit: false }];
139
+ const out = [];
140
+ const low = t.toLowerCase();
141
+ let i = 0;
142
+ while (i < t.length) {
143
+ const at = low.indexOf(q, i);
144
+ if (at === -1) { out.push({ text: t.slice(i), hit: false }); break; }
145
+ if (at > i) out.push({ text: t.slice(i, at), hit: false });
146
+ out.push({ text: t.slice(at, at + q.length), hit: true });
147
+ i = at + q.length;
148
+ }
149
+ return out.length ? out : [{ text: t, hit: false }];
150
+ }
151
+
152
+ /* -------------------------------------------------------------- badge --- */
153
+
154
+ /**
155
+ * The version badge on a project row.
156
+ *
157
+ * `copy` is the command a click copies, and it is only ever the one the server sent. Without one — a project
158
+ * with no Crewforth files, which is also what a plugin install looks like from here — the badge offers the
159
+ * `/crew-update` hint in its title and copies nothing.
160
+ */
161
+ export function badgeFor(kit) {
162
+ if (!kit || !kit.installed) {
163
+ return {
164
+ text: 'no Crewforth',
165
+ tone: 'none',
166
+ copy: null,
167
+ title: 'No Crewforth files in this project. A plugin install leaves none, so this cannot tell the two '
168
+ + 'apart; to update one, run /crew-update in the project.',
169
+ };
170
+ }
171
+ const command = typeof kit.updateCommand === 'string' && kit.updateCommand ? kit.updateCommand : null;
172
+ const how = command ? `click to copy: ${command}` : 'run /crew-update in this project';
173
+ if (kit.compared === false) {
174
+ return {
175
+ text: `${kit.version} ?`,
176
+ tone: 'unknown',
177
+ copy: null,
178
+ title: `Crewforth ${kit.version} — not compared: ${kit.reason ?? 'no reason given'}`,
179
+ };
180
+ }
181
+ if (kit.outdated) {
182
+ return {
183
+ text: `${kit.version} · update`,
184
+ tone: 'old',
185
+ copy: command,
186
+ title: `Crewforth ${kit.version} is behind ${kit.latest} — ${how}`,
187
+ };
188
+ }
189
+ if (kit.ahead) {
190
+ return {
191
+ text: kit.version,
192
+ tone: 'ahead',
193
+ copy: null,
194
+ title: `Crewforth ${kit.version} is ahead of the published ${kit.latest}`,
195
+ };
196
+ }
197
+ return { text: kit.version, tone: 'current', copy: null, title: `Crewforth ${kit.version} — current` };
198
+ }
199
+
200
+ /* --------------------------------------------------------------- live --- */
201
+
202
+ /** The Live tab: sessions that are working or waiting on someone, the waiting ones first. */
203
+ export function liveSessions(fleet) {
204
+ if (!fleet || fleet.measured === false) return [];
205
+ const rank = { waiting: 0, busy: 1 };
206
+ return (fleet.sessions ?? [])
207
+ .filter((s) => s.status in rank)
208
+ .sort((a, b) => rank[a.status] - rank[b.status] || (b.startedAt ?? 0) - (a.startedAt ?? 0));
209
+ }
210
+
211
+ /**
212
+ * The Machines tab. Two kinds of other machine, and they are not the same kind of fact: a peer Studio was asked
213
+ * just now; a Remote Control session is what one session last recorded, at `seenAt`.
214
+ */
215
+ export function machines(fleet) {
216
+ const peers = (fleet?.origins ?? []).filter((o) => !o.local).map((o) => ({
217
+ kind: 'peer',
218
+ name: o.name,
219
+ ok: o.ok !== false,
220
+ reason: o.reason ?? null,
221
+ sessions: typeof o.sessions === 'number' ? o.sessions : null,
222
+ }));
223
+ const roster = fleet?.roster ?? null;
224
+ const remote = (roster?.peers ?? []).filter((p) => p.remote).map((p) => ({
225
+ kind: 'snapshot',
226
+ name: p.name,
227
+ status: p.status ?? null,
228
+ note: p.note || p.kind || '',
229
+ seenAt: roster.seenAt ?? null,
230
+ }));
231
+ return {
232
+ peers,
233
+ remote,
234
+ rosterMeasured: Boolean(roster?.measured),
235
+ rosterReason: roster?.reason ?? null,
236
+ };
237
+ }
@@ -0,0 +1,235 @@
1
+ // The New session panel: where a session runs, how much it may do, and what it is asked first.
2
+ //
3
+ // The choices are the server's. The projects are the ones it listed; the modes are the ones it offers, and this
4
+ // file can describe a mode but cannot add one. Starting is guarded by a one-shot: two clicks start one session.
5
+ import { oneShot } from './convo.js';
6
+
7
+ /**
8
+ * What each permission mode means for a session started HERE.
9
+ *
10
+ * Not the terminal's wording. In a terminal a mode decides what Claude Code asks about. Here every tool call
11
+ * waits in the approval dock whatever the mode, and an answer given there is the approval: outside plan mode the
12
+ * gate tells Claude Code so. That leaves two modes that behave alike in Studio, and the panel says that instead
13
+ * of repeating a difference that only exists in a terminal.
14
+ */
15
+ export const MODE_TEXT = {
16
+ plan: { word: 'Plan', says: 'Reads and plans. Cannot change files.' },
17
+ acceptEdits: { word: 'Accept edits', says: 'Edits files and runs commands, each one after you allow it in the approval dock.' },
18
+ default: { word: 'Default', says: 'The same in Studio: every call waits for you. It differs from Accept edits only in a terminal.' },
19
+ };
20
+
21
+ const PREFERRED = 'plan';
22
+
23
+ /** The modes to offer: exactly the server's list, in its order, each with what is known about it. */
24
+ export function modeChoices(offered) {
25
+ const list = Array.isArray(offered) ? offered.filter((m) => typeof m === 'string') : [];
26
+ const initial = list.includes(PREFERRED) ? PREFERRED : (list[0] ?? null);
27
+ return list.map((mode) => ({
28
+ mode,
29
+ // A mode this file has no words for is still the server's to offer: it is shown under its own name.
30
+ word: MODE_TEXT[mode]?.word ?? mode,
31
+ says: MODE_TEXT[mode]?.says ?? null,
32
+ initial: mode === initial,
33
+ }));
34
+ }
35
+
36
+ /** The projects a session can be started in: on this machine, with a directory that is still there. */
37
+ export function projectChoices(projects, preferKey = null) {
38
+ const list = (projects ?? []).filter((p) => p.local !== false && p.exists && p.cwd);
39
+ const initial = list.find((p) => p.key === preferKey) ?? list.find((p) => p.current) ?? list[0] ?? null;
40
+ return list.map((p) => ({
41
+ key: p.key,
42
+ cwd: p.cwd,
43
+ label: p.label ?? p.cwd,
44
+ branch: p.branch ?? null,
45
+ kit: p.kit?.installed ? (p.kit.version ?? 'installed') : null,
46
+ initial: p === initial,
47
+ }));
48
+ }
49
+
50
+ /** "Runs in <path> on branch <branch>." — the branch only when the server read one. */
51
+ export function runsIn(choice, shorten = (p) => p) {
52
+ if (!choice) return null;
53
+ return { path: shorten(choice.cwd), branch: choice.branch };
54
+ }
55
+
56
+ function mk(tag, cls, text) {
57
+ const n = document.createElement(tag);
58
+ if (cls) n.className = cls;
59
+ if (text != null) n.textContent = text;
60
+ return n;
61
+ }
62
+
63
+ export class NewSession {
64
+ /**
65
+ * @param onStart ({ cwd, permissionMode, first }) => Promise<{ ok, reason? }>
66
+ * @param onClose () => void
67
+ * @param shorten path => the short form the rest of the panel uses
68
+ */
69
+ constructor(root, { onStart, onClose, shorten } = {}) {
70
+ this.root = root;
71
+ this.onClose = onClose ?? (() => {});
72
+ this.shorten = shorten ?? ((p) => p);
73
+ this.projects = [];
74
+ this.modes = [];
75
+ this.mode = null;
76
+ // Spent by the first click and re-armed only by opening the panel again, or by a start that was refused.
77
+ this.start = oneShot((args) => (onStart ?? (() => ({ ok: false, reason: 'nothing to start' })))(args));
78
+ this.#build();
79
+ }
80
+
81
+ #build() {
82
+ const head = mk('div', 'np-head');
83
+ const close = mk('button', 'btn icon sm ghost');
84
+ close.type = 'button';
85
+ close.setAttribute('aria-label', 'Close');
86
+ close.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
87
+ close.addEventListener('click', () => this.close());
88
+ head.append(mk('h2', 'np-title', 'New session'), mk('span', 'row-fill'), close);
89
+
90
+ const form = mk('form', 'np-body');
91
+ form.addEventListener('submit', (e) => { e.preventDefault(); this.submit(); });
92
+
93
+ const projWrap = mk('div', 'np-field');
94
+ const projLabel = mk('label', 'np-label', 'Project');
95
+ projLabel.htmlFor = 'np-project';
96
+ this.projectEl = mk('select', 'np-select');
97
+ this.projectEl.id = 'np-project';
98
+ this.projectEl.addEventListener('change', () => this.paintRunsIn());
99
+ this.runsEl = mk('span', 'sub np-runs');
100
+ projWrap.append(projLabel, this.projectEl, this.runsEl);
101
+
102
+ this.modesEl = mk('fieldset', 'np-modes');
103
+
104
+ const firstWrap = mk('div', 'np-field');
105
+ const firstLabel = mk('label', 'np-label', 'First message');
106
+ firstLabel.htmlFor = 'np-first';
107
+ this.firstEl = mk('textarea', 'np-first');
108
+ this.firstEl.id = 'np-first';
109
+ this.firstEl.rows = 5;
110
+ this.firstEl.placeholder = 'What should this session do?';
111
+ firstWrap.append(firstLabel, this.firstEl,
112
+ mk('span', 'sub', 'Optional. You can also start empty and write in the conversation panel.'));
113
+
114
+ // What starting here means, said before the button: a closed panel is a refusal.
115
+ const note = mk('div', 'np-note');
116
+ note.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 7.5v3.5M8 5h.01"/></svg>';
117
+ note.append(mk('span', 'sub',
118
+ 'Every tool call in this session waits for your approval here in Studio. If Studio is closed, the request is denied.'));
119
+
120
+ this.errorEl = mk('div', 'np-error');
121
+ this.errorEl.setAttribute('role', 'alert');
122
+ this.errorEl.hidden = true;
123
+
124
+ form.append(projWrap, this.modesEl, firstWrap, note, this.errorEl);
125
+
126
+ const foot = mk('div', 'np-foot');
127
+ const cancel = mk('button', 'btn', 'Cancel');
128
+ cancel.type = 'button';
129
+ cancel.addEventListener('click', () => this.close());
130
+ this.startEl = mk('button', 'btn primary', 'Start session');
131
+ this.startEl.type = 'button';
132
+ this.startEl.addEventListener('click', () => this.submit());
133
+ foot.append(cancel, this.startEl);
134
+
135
+ this.root.replaceChildren(head, form, foot);
136
+ }
137
+
138
+ get isOpen() { return !this.root.hidden; }
139
+
140
+ /**
141
+ * Show the panel. Opening one that is already open changes nothing — in particular it does not arm a second
142
+ * start, so a second click on the bar's button cannot become a second session.
143
+ */
144
+ open({ projects, modes, preferKey } = {}) {
145
+ if (this.isOpen) return false;
146
+ this.projects = projectChoices(projects, preferKey);
147
+ this.modes = modeChoices(modes);
148
+ this.mode = this.modes.find((m) => m.initial)?.mode ?? null;
149
+ this.firstEl.value = '';
150
+ this.errorEl.hidden = true;
151
+ this.start.arm();
152
+ this.paint();
153
+ this.root.hidden = false;
154
+ return true;
155
+ }
156
+
157
+ close() {
158
+ if (!this.isOpen) return;
159
+ this.root.hidden = true;
160
+ this.onClose();
161
+ }
162
+
163
+ get project() { return this.projects.find((p) => p.key === this.projectEl.value) ?? null; }
164
+
165
+ paint() {
166
+ this.projectEl.replaceChildren(...this.projects.map((p) => {
167
+ const o = mk('option', null, p.kit ? `${p.label} · Crewforth ${p.kit}` : `${p.label} · no Crewforth`);
168
+ o.value = p.key;
169
+ o.selected = p.initial;
170
+ return o;
171
+ }));
172
+ const initial = this.projects.find((p) => p.initial);
173
+ if (initial) this.projectEl.value = initial.key;
174
+ this.paintRunsIn();
175
+ this.paintModes();
176
+ this.paintStart();
177
+ }
178
+
179
+ paintRunsIn() {
180
+ const r = runsIn(this.project, this.shorten);
181
+ if (!r) {
182
+ this.runsEl.textContent = 'No project on this machine to start a session in.';
183
+ return;
184
+ }
185
+ const parts = ['Runs in ', mk('code', null, r.path)];
186
+ if (r.branch) parts.push(' on branch ', mk('code', null, r.branch));
187
+ parts.push('.');
188
+ this.runsEl.replaceChildren(...parts);
189
+ }
190
+
191
+ paintModes() {
192
+ const legend = mk('legend', 'np-label', 'Permission mode');
193
+ const rows = this.modes.map((m) => {
194
+ const row = mk('label', 'np-mode');
195
+ const radio = mk('input');
196
+ radio.type = 'radio';
197
+ radio.name = 'np-mode';
198
+ radio.value = m.mode;
199
+ radio.checked = m.mode === this.mode;
200
+ radio.addEventListener('change', () => { this.mode = m.mode; this.paintModes(); });
201
+ row.classList.toggle('on', m.mode === this.mode);
202
+ const text = mk('span', 'np-mode-text');
203
+ const word = mk('span', 'np-mode-word', m.word);
204
+ if (m.initial) word.append(mk('span', 'sub', ' · default'));
205
+ text.append(word);
206
+ if (m.says) text.append(mk('span', 'sub', m.says));
207
+ row.append(radio, text);
208
+ return row;
209
+ });
210
+ this.modesEl.replaceChildren(legend, ...rows, mk('span', 'sub', 'Modes that skip Crewforth\'s gates are not offered.'));
211
+ }
212
+
213
+ paintStart() {
214
+ const busy = this.start.spent();
215
+ this.startEl.disabled = busy || !this.project || !this.mode;
216
+ this.startEl.textContent = busy ? 'Starting…' : 'Start session';
217
+ }
218
+
219
+ async submit() {
220
+ const project = this.project;
221
+ if (!project || !this.mode) return null;
222
+ const pending = this.start({ cwd: project.cwd, permissionMode: this.mode, first: this.firstEl.value.trim() });
223
+ this.paintStart();
224
+ let out;
225
+ try { out = await pending; } catch (e) { out = { ok: false, reason: e?.message ?? String(e) }; }
226
+ if (out?.ok) {
227
+ this.close();
228
+ } else {
229
+ this.errorEl.textContent = `Could not start a session — ${out?.reason ?? 'no reason given'}`;
230
+ this.errorEl.hidden = false;
231
+ this.paintStart();
232
+ }
233
+ return out;
234
+ }
235
+ }
@@ -0,0 +1,65 @@
1
+ // A view that redraws itself does not redraw while a button is down on it.
2
+ //
3
+ // The Timeline and the List put their rows in again about once a second while something in them moves. A browser
4
+ // sends a click only when the element that took the press is still in the page at the release. A redraw between
5
+ // the two took that element out, and the press chose nothing: measured, 3 presses of 20 at an ordinary 90 ms, and
6
+ // 20 of 20 when the button was held longer than a redraw. Keeping the row's own element and refilling it does not
7
+ // help: what takes the press is the text inside it, and a press whose target is gone gets no click at all.
8
+ //
9
+ // So the redraw waits. While a press is down the view says "later", and when the press is over, and the click it
10
+ // made has been delivered, the view draws what it put off. A press that is held and held is not a click: after
11
+ // HOLD_MS the view stops waiting for it.
12
+
13
+ export const HOLD_MS = 3000;
14
+
15
+ export class Press {
16
+ /**
17
+ * @param redraw called once after a press, if a redraw was put off during it
18
+ * @param timers { set(fn, ms) => id, clear(id) } — the page's setTimeout and clearTimeout, or a test's
19
+ */
20
+ constructor(redraw, timers = { set: (fn, ms) => setTimeout(fn, ms), clear: (id) => clearTimeout(id) }) {
21
+ this.redraw = redraw;
22
+ this.timers = timers;
23
+ this.down = false;
24
+ this.owed = false;
25
+ this.cap = null;
26
+ }
27
+
28
+ /** A button went down on the view. */
29
+ press() {
30
+ this.down = true;
31
+ this.timers.clear(this.cap);
32
+ this.cap = this.timers.set(() => this.release(), HOLD_MS);
33
+ }
34
+
35
+ /** It came up, or was taken away. What was put off is drawn after the click has been delivered. */
36
+ release() {
37
+ if (!this.down) return;
38
+ this.down = false;
39
+ this.timers.clear(this.cap);
40
+ this.cap = null;
41
+ this.timers.set(() => { if (this.owed && !this.down) { this.owed = false; this.redraw(); } }, 0);
42
+ }
43
+
44
+ /**
45
+ * Asked by the view at the top of its redraw: true means "not now". A redraw that does run settles the debt.
46
+ */
47
+ defer() {
48
+ if (this.down) { this.owed = true; return true; }
49
+ this.owed = false;
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Watch a view. The press is seen on the view; the release is seen on the window, because a press that began
55
+ * on a row can end anywhere. The space bar is a press too: a button answers it at the key's release.
56
+ */
57
+ watch(root, win) {
58
+ root.addEventListener('pointerdown', () => this.press(), true);
59
+ root.addEventListener('keydown', (e) => { if (e.key === ' ') this.press(); }, true);
60
+ for (const type of ['pointerup', 'pointercancel', 'keyup']) win.addEventListener(type, () => this.release(), true);
61
+ // The window losing the keyboard, not an element in it: a press moves the focus, and that blur is not a release.
62
+ win.addEventListener('blur', (e) => { if (e.target === win) this.release(); });
63
+ return this;
64
+ }
65
+ }