heyquilt 0.3.116 → 0.3.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,40 +1,198 @@
1
- // Settings → Your AI: your AI on its own cloud machine, as one card that grows
2
- // (docs/mockups/2026-10-17-ai-vm.html, option A). Three Connect tiles; then the tool's own
3
- // sign-in as numbered steps, in place; then the cloud AI itself: its status, sessions, today's
4
- // use and its settings. Disconnect asks again inside the card. The card follows the API's long
5
- // poll (GET /api/cloud-ai?wait=<version>) while it is on screen.
6
- import { I, state, $, esc, toast, api, avatar, loadingNote } from './common.js'
1
+ // Settings → Agents: your AI on its own cloud machine (docs/superpowers/specs/2026-10-17-ai-vm.md).
2
+ // Not set up: a card that invites you to. Setting it up takes over the Settings panel (the pop-up's
3
+ // right side, or the page's tab panel) as five steps: its name and color, its AI tool, what it may
4
+ // do, where it works, then the tool's own sign-in. Set up: its card, with what it does changed in
5
+ // place. The card and the sign-in follow the API's long poll (GET /api/cloud-ai?wait=<version>).
6
+ import { I, state, $, esc, toast, api, loadingNote } from './common.js'
7
7
  import { toolLogo } from './tool-logo.js'
8
- import { CLOUD_TOOLS, cloudTool, cloudPhase, cloudStatus, signInSteps, cloudUsage } from './cloud-ai-model.js'
8
+ import { CLOUD_TOOLS, cloudTool, cloudPhase, cloudStatus, signInSteps, cloudUsage, shortTokens } from './cloud-ai-model.js'
9
9
 
10
- const TITLE = 'Your AI on a cloud machine'
10
+ const STEPS = ['Name', 'AI tool', 'What it does', 'Sessions', 'Sign in']
11
+ export const AI_COLORS = ['#2e7a80', '#4f4fa0', '#4a7a45', '#855a9c', '#a8701c', '#9c4f6b', '#3b6a9a']
12
+ const NAMES = ['Juniper', 'Atlas', 'Wren', 'Moss', 'Pilot']
13
+ const TASKS = [['asked', 'When asked'], ['mine', 'Assigned to it'], ['any', 'Any open task']]
14
+ const SESSIONS = [['every', 'Every session I join'], ['pick', 'Only the ones I pick'], ['none', 'None for now']]
15
+ const LIMITS = [[0, 'No limit'], [1e6, '1 million tokens'], [2e6, '2 million tokens'], [5e6, '5 million tokens'], [1e7, '10 million tokens']]
16
+ const NAME_OK = /^[\p{L}\p{N}][\p{L}\p{N} .'’-]{0,39}$/u
11
17
 
12
- /** The card's frame; bindCloudAi fills it. */
18
+ // The person's cloud AI as the API last said (undefined: not asked yet; null: none), shown in
19
+ // whichever Settings is open (`hosts`: root element -> its redraw).
20
+ const cloud = { vm: undefined, loading: false, polling: false, tools: null, hosts: new Map(), ui: { busy: '', error: '', confirming: false } }
21
+ // The setup while it is open: { step, draft, editing, busy, error }.
22
+ let setup = null
23
+
24
+ const person = () => (state.account && state.account.name) || (state.profile && state.profile.name) || 'You'
25
+ const own = () => `${person()}'s AI`
26
+ const at = (n) => `@${n}`
27
+ const redraw = () => { for (const [root, draw] of [...cloud.hosts]) { if (root.isConnected) draw(); else cloud.hosts.delete(root) } }
28
+ const seg = (list, on, attr) => `<div class="seg" role="group">${list.map(([v, l]) => `<button type="button" class="${v === on ? 'on' : ''}" ${attr}="${esc(String(v))}" aria-pressed="${v === on}">${esc(l)}</button>`).join('')}</div>`
29
+ const sw = (on, attr, label) => `<button type="button" class="ai-switch${on ? ' on' : ''}" role="switch" aria-checked="${on}" aria-label="${esc(label)}" ${attr}></button>`
30
+ const pill = (s) => `<span class="pill cai-pill ${s.tone}"><span class="dot"></span>${esc(s.label)}</span>`
31
+
32
+ /** Whether the setup is open: Settings then shows it in place of the Agents tab. */
33
+ export const cloudSetupOn = () => !!setup
34
+ /** Which of its five steps the setup shows (0 to 4), or -1. */
35
+ export const cloudSetupStep = () => setup ? setup.step : -1
36
+
37
+ /** What setting it up starts from: the AI as it is (changing it), or the person's defaults. */
38
+ function draftFrom (vm) {
39
+ if (vm) return { name: vm.name || own(), color: vm.color || AI_COLORS[0], tool: vm.tool, model: vm.model || '', cap: vm.cap || 0, prompt: vm.prompt || '', tasks: vm.tasks || 'mine', files: vm.files !== false, wake: vm.wake !== false, sessionsMode: vm.sessionsMode || 'every', rooms: [...(vm.rooms || [])] }
40
+ return { name: own(), color: AI_COLORS[0], tool: CLOUD_TOOLS[0].tool, model: '', cap: 0, prompt: 'Keep chat messages short. Test before you hand work back. Ask me before anything risky or expensive.', tasks: 'mine', files: true, wake: true, sessionsMode: 'every', rooms: openSessions().map((s) => s.room) }
41
+ }
42
+
43
+ /** The sessions open on this computer, for "Only the ones I pick": { room, name, people }. */
44
+ function openSessions () {
45
+ return [...(state.sessions ? state.sessions.values() : [])].map((s) => {
46
+ const st = s.status || {}
47
+ const people = ['You', ...(st.peers || []).filter((p) => p && p.kind !== 'agent' && !p.persona).map((p) => p.name).filter(Boolean)]
48
+ return { room: st.room, name: st.sessionName || String(s.dir || '').split(/[\\/]/).pop() || st.room, people }
49
+ }).filter((s) => s.room)
50
+ }
51
+
52
+ // ---------------------------------------------------------------- the AI's card --
53
+
54
+ function aiCard ({ name, color, tool, status, compact = false, now = '' }) {
55
+ const mine = name === own()
56
+ return `<div class="ai-card${compact ? ' compact' : ''}"><span class="avatar" style="background:${esc(color)}">${esc((name || '?').trim()[0] || '?')}</span>
57
+ <div class="who"><div class="name"><b>${esc(name)}</b>${mine ? '' : `<span class="ai-tag">${esc(own())}</span>`}</div>
58
+ <div class="sub">${esc(tool)}${compact ? ' · in the cloud' : ' on its own cloud machine'}</div>
59
+ ${compact ? '' : `<div class="sub"><span class="at">${esc(at(name))}</span>${mine ? '' : ` or <span class="at">@${esc(own())}</span>`} reaches it</div>`}
60
+ ${now ? `<div class="sub now">${esc(now)}</div>` : ''}</div>
61
+ ${status ? pill(status) : ''}</div>`
62
+ }
63
+
64
+ // ------------------------------------------------------------------ the card --
65
+
66
+ /** The card's frame in Settings → Agents; bindCloudAi fills it. */
13
67
  export function cloudAiCardHtml () {
14
68
  return `<section class="card settings-sec cloud-ai" id="cloud-ai-sec" aria-live="polite">
15
- <div class="sec-intro"><h2>${TITLE}</h2><p>Your own AI tool, signed in with your own account, on a machine of yours that is always there.</p></div>
69
+ <div class="sec-intro"><h2>Your AI</h2><p>On its own cloud machine.</p></div>
16
70
  <div class="sec-body"><p class="hint">${loadingNote()}</p></div>
17
71
  </section>`
18
72
  }
19
73
 
20
- const pill = (s) => `<span class="pill cai-pill ${s.tone}"><span class="dot"></span>${esc(s.label)}</span>`
74
+ function inviteHtml (ui) {
75
+ return `<div class="ai-invite">
76
+ <div class="ai-patch" aria-hidden="true">${I.plus}</div>
77
+ <div><h2>Give your AI a seat in your sessions</h2><p>An AI of yours on its own cloud machine. People write to it by name, and it keeps working when your computer is closed.</p></div>
78
+ <div class="ai-facts">
79
+ <div class="ai-fact"><i>${I.chat}</i><div><b>Answers for you</b><span>@mentions and messages for your AI.</span></div></div>
80
+ <div class="ai-fact"><i>${I.board}</i><div><b>Takes tasks you allow</b><span>Each on a branch of its own.</span></div></div>
81
+ <div class="ai-fact"><i>${I.lock}</i><div><b>Runs on your plan</b><span>Claude, ChatGPT or Cursor.</span></div></div>
82
+ </div>
83
+ ${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
84
+ <div class="ai-invite-foot"><span class="grow">Five short steps. You can change everything later.</span><button class="btn primary" type="button" data-ai-setup>${I.sparkle}<span>Set up your AI</span></button></div>
85
+ </div>`
86
+ }
87
+
88
+ function confirmHtml (vm) {
89
+ return `<div class="cai-confirm" role="group" aria-label="Disconnect your cloud AI?">
90
+ <p><b>Disconnect ${esc(vm.name)}?</b> Its machine and disk are deleted, with any work it hasn't pushed. Your ${esc(cloudTool(vm.tool).account)} account itself is not touched.</p>
91
+ <div class="cai-two"><button class="btn" type="button" data-cloud-keep>Keep it</button><button class="btn danger" type="button" data-cloud-delete>${I.trash}<span>Delete machine and disk</span></button></div>
92
+ </div>`
93
+ }
21
94
 
22
- function connectHtml (ui) {
23
- return `<div class="sec-intro"><h2>${TITLE}</h2><p>Your own AI tool, signed in with your own account, on a machine of yours that is always there. It becomes "your AI": what anyone writes or hands to your AI goes to it.</p></div>
24
- <div class="sec-body cai-body">
25
- <div class="cai-tools">${CLOUD_TOOLS.map((t, i) => `<div class="cai-tool">
26
- ${toolLogo(t.tool, { className: 'cai-logo' })}
27
- <b>${esc(t.tool)}</b><span class="hint">Sign in with ${esc(t.account)}</span>
28
- <button class="btn sm${i === 0 ? ' primary' : ''}" type="button" data-cloud-connect="${esc(t.tool)}" ${ui.busy ? 'disabled' : ''}>${ui.busy === t.tool ? loadingNote('Starting…') : `Connect ${esc(t.short)}`}</button>
29
- </div>`).join('')}</div>
95
+ /** Set up but not signed in yet (the setup was closed on its last step): the way back to it. */
96
+ function signingHtml (vm, ui) {
97
+ return `<div class="sec-intro"><h2>Your AI</h2><p>Almost there: ${esc(cloudTool(vm.tool).short)} still needs signing in on its machine.</p></div>
98
+ <div class="sec-body ai-run">${aiCard({ name: vm.name, color: vm.color || AI_COLORS[0], tool: vm.tool, status: cloudStatus(vm) })}
30
99
  ${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
31
- <p class="hint">Included in every plan for now. Quilt never sees your Claude, ChatGPT or Cursor login, and your tool's own plan pays for what it uses.</p>
100
+ ${ui.confirming ? confirmHtml(vm) : `<div class="sec-actions"><button class="btn danger" type="button" data-cloud-disconnect>Disconnect…</button><button class="btn primary" type="button" data-ai-signin>Continue setup</button></div>`}
32
101
  </div>`
33
102
  }
34
103
 
104
+ function doesHtml (d, name) {
105
+ return `<div class="ai-list">
106
+ <div class="ai-row"><i>${I.chat}</i><div><b>Answer people who write to it</b><span class="hint">@mentions and direct messages for ${esc(name)}${name === own() ? '' : ` or ${esc(own())}`}.</span></div><span class="ai-always">Always</span></div>
107
+ <div class="ai-row wide"><i>${I.board}</i><div><b>Pick up tasks from the board</b><span class="hint">When it finishes one, it starts the next.</span></div><div class="ctl">${seg(TASKS, d.tasks, 'data-ai-tasks')}</div></div>
108
+ <div class="ai-row"><i>${I.branch}</i><div><b>Change files and push its own branches</b><span class="hint">Each task on a branch of its own with a pull request. A person merges it.</span></div>${sw(d.files, 'data-ai-files', 'Change files and push its own branches')}</div>
109
+ <div class="ai-row"><i>${I.power}</i><div><b>Wake for every message</b><span class="hint">${d.wake ? 'A message to it wakes its machine within a minute.' : 'Messages wait until it next wakes: for a task, or when you work in one of its sessions.'}</span></div>${sw(d.wake, 'data-ai-wake', 'Wake for every message')}</div>
110
+ </div>`
111
+ }
112
+
113
+ function readyHtml (vm, ui) {
114
+ const u = cloudUsage(vm)
115
+ const sleeping = ['suspended', 'stopped', 'failed'].includes(vm.state)
116
+ const sessions = vm.sessions || []
117
+ const where = { every: 'Joins every session you join', pick: 'Joins the sessions you picked', none: 'Joins no session for now' }[vm.sessionsMode || 'every']
118
+ return `<div class="sec-intro"><h2>Your AI</h2><p>On its own cloud machine. What people write to it, and the tasks you let it take, go here.</p></div>
119
+ <div class="sec-body ai-run">
120
+ ${aiCard({ name: vm.name, color: vm.color || AI_COLORS[0], tool: vm.tool, status: cloudStatus(vm) })}
121
+ ${vm.state === 'failed' && vm.error ? `<p class="error">${esc(vm.error)}</p>` : ''}
122
+ ${sleeping ? `<div class="ai-asleep"><span class="hint">${vm.state === 'suspended' ? 'Asleep while there is nothing to do. It wakes for a task, a message to it, or when you work in one of its sessions.' : 'Its machine stopped.'}</span><button class="btn sm" type="button" data-cloud-wake ${ui.busy === 'wake' ? 'disabled' : ''}>${vm.state === 'suspended' ? 'Wake now' : 'Start it again'}</button></div>` : ''}
123
+ <div class="ai-meters">
124
+ <div title="${esc(where)}"><b>${sessions.length}</b><span>session${sessions.length === 1 ? '' : 's'}</span></div>
125
+ <div><b>${esc(u.tokens)}</b><span>tokens today${u.pct === null ? '' : `, ${u.pct}% of ${esc(u.cap)}`}</span></div>
126
+ <div><b>${u.runs}</b><span>run${u.runs === 1 ? '' : 's'} today</span></div>
127
+ </div>
128
+ ${u.over ? '<p class="hint warn">The daily limit is reached: work for it waits on the board until tomorrow.</p>' : ''}
129
+ ${doesHtml({ tasks: vm.tasks || 'mine', files: vm.files !== false, wake: vm.wake !== false }, vm.name)}
130
+ ${sessions.length ? `<div class="ai-list">${sessions.map((x) => `<div class="ai-sess static"><span class="ai-on"></span><b>${esc(x.name || x.room)}</b><span class="hint">joined</span></div>`).join('')}</div>` : ''}
131
+ ${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
132
+ ${ui.confirming ? confirmHtml(vm) : `<div class="sec-actions"><button class="btn danger" type="button" data-cloud-disconnect>Disconnect…</button><button class="btn" type="button" data-ai-edit>${I.pencil}<span>Change its setup</span></button></div>`}
133
+ </div>`
134
+ }
135
+
136
+ /** The card's inside for `vm` (null: not set up) and what the person is doing in it. */
137
+ export function cloudAiInnerHtml (vm, ui = {}) {
138
+ const phase = cloudPhase(vm)
139
+ if (phase === 'none') return inviteHtml(ui)
140
+ return phase === 'signin' ? signingHtml(vm, ui) : readyHtml(vm, ui)
141
+ }
142
+
143
+ // ----------------------------------------------------------------- the setup --
144
+
145
+ const q = (h, p, inner) => `<section class="ai-q"><header><h3>${h}</h3><p>${p}</p></header>${inner}</section>`
146
+
147
+ function nameStep (d) {
148
+ return q('What should your AI be called?', 'People see this name in chat, on the task board and on the files it changes. It is always shown as yours.',
149
+ `<input class="input ai-name" id="ai-name" value="${esc(d.name)}" maxlength="40" autocomplete="off" spellcheck="false" aria-label="Its name">
150
+ <div class="ai-chips">${[own(), ...NAMES].map((n) => `<button class="ai-chip${n === d.name ? ' on' : ''}" type="button" data-ai-name="${esc(n)}">${esc(n)}</button>`).join('')}</div>
151
+ <div class="ai-sub">Color</div>
152
+ <div class="ai-colors">${AI_COLORS.map((c) => `<button class="ai-color${c === d.color ? ' on' : ''}" style="background:${c}" type="button" data-ai-color="${c}" aria-label="Color ${c}" aria-pressed="${c === d.color}"></button>`).join('')}</div>`)
153
+ }
154
+
155
+ function toolStep (d) {
156
+ const models = ((cloud.tools || []).find((t) => t.tool === d.tool) || {}).models || []
157
+ return q(`Which AI does ${esc(d.name)} run on?`, 'Your own tool, signed in with your own account on its machine. Your plan pays for what it uses; Quilt never sees the login.',
158
+ `<div class="ai-tools">${CLOUD_TOOLS.map((t) => `<button class="ai-tool${t.tool === d.tool ? ' on' : ''}" type="button" data-ai-tool="${esc(t.tool)}" aria-pressed="${t.tool === d.tool}">${toolLogo(t.tool, { className: 'cai-logo' })}<b>${esc(t.tool)}</b><span class="hint">Sign in with ${esc(t.account)}</span><span class="tick">${t.tool === d.tool ? I.check : ''}</span></button>`).join('')}</div>
159
+ <div class="ai-pair"><div class="field"><label for="ai-model">Model</label><select class="input" id="ai-model" data-ai-model><option value="">The tool's default</option>${models.map((m) => `<option value="${esc(m)}" ${m === d.model ? 'selected' : ''}>${esc(m)}</option>`).join('')}${d.model && !models.includes(d.model) ? `<option value="${esc(d.model)}" selected>${esc(d.model)}</option>` : ''}</select></div>
160
+ <div class="field"><label for="ai-cap">Daily limit</label><select class="input" id="ai-cap" data-ai-cap>${LIMITS.map(([v, l]) => `<option value="${v}" ${v === d.cap ? 'selected' : ''}>${l}</option>`).join('')}${LIMITS.some(([v]) => v === d.cap) ? '' : `<option value="${d.cap}" selected>${esc(shortTokens(d.cap))} tokens</option>`}</select></div></div>`)
161
+ }
162
+
163
+ function doesStep (d) {
164
+ return q(`What may ${esc(d.name)} do?`, `It always answers people who write to ${esc(at(d.name))}. The rest is up to you, and you can change it any time.`,
165
+ `${doesHtml(d, d.name)}
166
+ <div class="field ai-rules"><label for="ai-rules">House rules</label><textarea class="input" id="ai-rules" rows="3" maxlength="4000" data-ai-rules placeholder="Anything it should always do, or never do.">${esc(d.prompt)}</textarea><span class="hint">Read at the start of every run, on top of the project's own rules.</span></div>`)
167
+ }
168
+
169
+ function sessionsStep (d) {
170
+ const list = openSessions()
171
+ const rows = list.length
172
+ ? `<div class="ai-list">${list.map((s) => `<label class="ai-sess${d.sessionsMode === 'pick' ? '' : ' off'}"><input type="checkbox" data-ai-room="${esc(s.room)}" ${d.sessionsMode === 'every' || (d.sessionsMode === 'pick' && d.rooms.includes(s.room)) ? 'checked' : ''} ${d.sessionsMode === 'pick' ? '' : 'disabled'}><span><b>${esc(s.name)}</b><span class="hint">${esc(s.people.length > 1 ? s.people.join(', ') : 'Only you')}</span></span><span class="ai-faces">${s.people.slice(0, 5).map((n) => `<span class="avatar" title="${esc(n)}">${esc((n === 'You' ? person() : n)[0] || '')}</span>`).join('')}</span></label>`).join('')}</div>
173
+ <p class="hint">${d.sessionsMode === 'every' ? 'And every session you open or join from now on.' : 'Sessions you open later: pick them here when you want it there.'}</p>`
174
+ : `<p class="hint">${d.sessionsMode === 'every' ? 'No session is open on this computer now: it joins the ones you open or join from now on.' : 'No session is open on this computer now. Open one, then pick it here.'}</p>`
175
+ return q(`Where does ${esc(d.name)} work?`, 'It joins sessions as you do, and stays in them when you close your computer.',
176
+ `${seg(SESSIONS, d.sessionsMode, 'data-ai-sessions')}${d.sessionsMode === 'none' ? '<p class="hint">It joins no session until you pick some here.</p>' : rows}`)
177
+ }
178
+
179
+ function signInStep (vm, d) {
180
+ const t = cloudTool(d.tool)
181
+ const steps = vm ? signInSteps(vm) : []
182
+ return q(`Sign ${esc(d.tool)} in on ${esc(d.name)}'s machine`, `The last step: ${esc(t.short)}'s own sign-in, running on the machine. Quilt shows you its steps.`,
183
+ `<div class="ai-signin"><ol class="cai-steps">${steps.map(stepHtml).join('')}</ol>
184
+ <div class="ai-signin-again">${vm && vm.state === 'failed' ? `<button class="btn sm" type="button" data-cloud-wake>${I.refresh}<span>Start the machine again</span></button>` : `<button class="btn sm ghost" type="button" data-cloud-restart>${I.refresh}<span>Start the sign-in again</span></button>`}</div></div>
185
+ <div class="ai-sub">What you chose</div>
186
+ <div class="ai-review">
187
+ <div><span>Name</span><b>${esc(d.name)}</b><button class="ai-change" type="button" data-ai-goto="0">Change</button></div>
188
+ <div><span>AI</span><b>${esc(d.tool)}${d.model ? ` · ${esc(d.model)}` : ''} · ${d.cap ? `${esc(shortTokens(d.cap))} tokens a day` : 'no daily limit'}</b><button class="ai-change" type="button" data-ai-goto="1">Change</button></div>
189
+ <div><span>Does</span><b>Answers; tasks ${esc(Object.fromEntries(TASKS)[d.tasks].toLowerCase())}; ${d.files ? 'pushes its own branches' : 'changes no files'}</b><button class="ai-change" type="button" data-ai-goto="2">Change</button></div>
190
+ <div><span>Works in</span><b>${esc(Object.fromEntries(SESSIONS)[d.sessionsMode])}</b><button class="ai-change" type="button" data-ai-goto="3">Change</button></div>
191
+ </div>`)
192
+ }
193
+
35
194
  function stepHtml (s) {
36
195
  const body = []
37
- // While the tool checks a pasted code there is nothing more to type.
38
196
  const pasting = s.key === 'paste' && (s.canPaste || !s.now)
39
197
  if (s.hint && (s.key !== 'paste' || pasting)) body.push(`<span class="hint">${esc(s.hint)}</span>`)
40
198
  if (s.error) body.push(`<span class="error">${esc(s.error)}</span>`)
@@ -52,169 +210,229 @@ function stepHtml (s) {
52
210
  return `<li class="cai-step${s.done ? ' done' : ''}${s.now ? ' now' : ''}${s.error ? ' bad' : ''}"><div><b>${esc(s.title)}</b>${body.join('')}</div></li>`
53
211
  }
54
212
 
55
- function confirmHtml (vm) {
56
- return `<div class="cai-confirm" role="group" aria-label="Disconnect your cloud AI?">
57
- <p><b>Disconnect your cloud AI?</b> Its machine and disk are deleted, with any work it hasn't pushed. Your ${esc(cloudTool(vm.tool).account)} account itself is not touched.</p>
58
- <div class="cai-two"><button class="btn" type="button" data-cloud-keep>Keep it</button><button class="btn danger" type="button" data-cloud-delete>${I.trash}<span>Delete machine and disk</span></button></div>
59
- </div>`
213
+ /**
214
+ * The setup, in three parts for its host: `head` (back to Agents, its title, the steps), `body`
215
+ * (the AI's card and the question) and `foot` (Back, a line, the step's button).
216
+ */
217
+ export function cloudSetupHtml () {
218
+ const { step, draft: d, editing, busy, error } = setup
219
+ const vm = cloud.vm || null
220
+ const head = `<div class="ai-flow-head"><button class="back" type="button" data-ai-close>${I.arrowLeft}<span>Agents</span></button><span class="sep"></span><h4>${editing ? `Change ${esc(vm ? vm.name : d.name)}'s setup` : 'Set up your AI'}</h4><span class="count">Step ${step + 1} of ${STEPS.length}</span></div>
221
+ <ol class="ai-steps">${STEPS.map((s, i) => `<li class="${i < step ? 'done' : i === step ? 'now' : ''}">${s}</li>`).join('')}</ol>`
222
+ const asks = [nameStep, toolStep, doesStep, sessionsStep]
223
+ const body = `<div class="ai-flow-body">${aiCard({ name: d.name || '?', color: d.color, tool: d.tool, status: step === 4 && vm ? cloudStatus(vm) : null, compact: step > 0 })}
224
+ ${step < 4 ? asks[step](d) : signInStep(vm, d)}</div>`
225
+ const line = error ? `<span class="error">${esc(error)}</span>`
226
+ : step === 3 && !editing ? `Next: sign ${esc(d.tool)} in.`
227
+ : step === 4 ? `${esc(d.name)} starts as soon as ${esc(cloudTool(d.tool).short)} is signed in.` : 'You can change all of this later.'
228
+ const next = step < 3 ? '<button class="btn primary" type="button" data-ai-next>Continue</button>'
229
+ : step === 3 ? `<button class="btn primary" type="button" data-ai-start ${busy ? 'disabled' : ''}>${busy ? loadingNote(editing ? 'Saving…' : 'Starting…') : editing ? 'Save changes' : `Start ${esc(d.name)}'s machine`}</button>`
230
+ : ''
231
+ const foot = `<div class="ai-flow-foot"><button class="btn" type="button" data-ai-prev ${step && step < 4 ? '' : 'disabled'}>Back</button><span class="grow">${line}</span>${next}</div>`
232
+ return { head, body, foot }
60
233
  }
61
234
 
62
- function signInHtml (vm, ui) {
63
- const t = cloudTool(vm.tool)
64
- // A machine that didn't start is started again; otherwise the tool's sign-in is.
65
- const again = vm.state === 'failed'
66
- ? `<button class="btn" type="button" data-cloud-wake ${ui.busy === 'wake' ? 'disabled' : ''}>${I.refresh}<span>Start the machine again</span></button>`
67
- : `<button class="btn" type="button" data-cloud-restart ${ui.busy === 'restart' ? 'disabled' : ''}>${I.refresh}<span>Start the sign-in again</span></button>`
68
- return `<div class="sec-intro"><h2>Sign ${esc(vm.tool)} in on your cloud machine</h2><p>${esc(t.short)}'s own sign-in, running on the machine. Quilt shows you its steps.</p>${pill(cloudStatus(vm))}</div>
69
- <div class="sec-body cai-body">
70
- <ol class="cai-steps">${signInSteps(vm).map(stepHtml).join('')}</ol>
71
- ${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
72
- ${ui.confirming ? confirmHtml(vm) : `<div class="cai-two">${again}<button class="btn danger" type="button" data-cloud-disconnect>Disconnect…</button></div>`}
73
- </div>`
235
+ /** Closes the setup: Settings shows the Agents tab again, at the AI's card. */
236
+ const closeSetup = () => { setup = null; cloud.toCard = true }
237
+
238
+ /** Opens the setup: `editing` changes the AI there is; `step` starts it there (4: the sign-in). */
239
+ function openSetup ({ editing = false, step = 0 } = {}) {
240
+ setup = { step, draft: draftFrom(editing || step === 4 ? cloud.vm : null), editing, busy: false, error: '' }
241
+ if (!cloud.tools) api('GET', '/api/cloud-ai/tools').then((r) => { cloud.tools = r.tools || []; if (setup && setup.step === 1) redraw() }, () => {})
242
+ redraw()
243
+ watch()
74
244
  }
75
245
 
76
- function readyHtml (vm, ui) {
77
- const st = cloudStatus(vm)
78
- const u = cloudUsage(vm)
79
- const online = vm.state === 'running' ? true : vm.state === 'suspended' ? 'away' : false
80
- const sleeping = ['suspended', 'stopped', 'failed'].includes(vm.state)
81
- const sessions = (vm.sessions || []).length
82
- ? `<div class="cai-sessions">${vm.sessions.map((s) => `<div class="cai-sess"><span class="grow"><b>${esc(s.name || s.room)}</b></span><span class="hint">joined</span></div>`).join('')}</div>`
83
- : '<p class="hint">In no session yet. It joins the sessions you join, and stays when you leave for the day.</p>'
84
- return `<div class="sec-intro"><h2>${TITLE}</h2><p>This is your AI: tasks, @mentions and messages for it come here, even while your computer is closed.</p></div>
85
- <div class="sec-body cai-body">
86
- <div class="cai-who">${avatar(vm.name || 'AI', state.profile && state.profile.color, online)}
87
- <div class="grow"><b>${esc(vm.name || 'Cloud AI')}</b><span class="hint">${esc(vm.tool)} on your cloud machine</span></div>
88
- ${pill(st)}
89
- ${sleeping ? `<button class="btn sm" type="button" data-cloud-wake ${ui.busy === 'wake' ? 'disabled' : ''}>${vm.state === 'suspended' ? 'Wake now' : 'Start it again'}</button>` : ''}
90
- </div>
91
- ${vm.state === 'failed' && vm.error ? `<p class="error">${esc(vm.error)}</p>` : ''}
92
- ${vm.state === 'suspended' ? '<p class="hint">Asleep while there is nothing to do. It wakes by itself when you open or work in one of its sessions, or on a task, an @mention or a message for it.</p>' : ''}
93
- ${sessions}
94
- <div class="cai-kv">
95
- <div><b>${esc(u.tokens)}</b><span>tokens today</span></div>
96
- <div><b>${u.runs}</b><span>run${u.runs === 1 ? '' : 's'} today</span></div>
97
- <div><b>${u.pct === null ? 'No cap' : `${u.pct}%`}</b><span>${u.pct === null ? 'set a daily cap below' : `of ${esc(u.cap)} daily cap`}</span></div>
98
- </div>
99
- ${u.pct === null ? '' : `<div class="cai-meter${u.over ? ' over' : ''}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${u.pct}" aria-label="Today's use of the daily cap"><i style="width:${u.pct}%"></i></div>`}
100
- ${u.over ? '<p class="hint warn">The daily cap is reached: work for your AI waits on the board until tomorrow.</p>' : ''}
101
- <hr class="cai-sep">
102
- <form class="cai-form" id="cloud-settings-form" autocomplete="off">
103
- <div class="cai-two">
104
- <div class="field"><label for="cai-model">Model</label>
105
- <input class="input" id="cai-model" name="model" list="cai-models" value="${esc(vm.model || '')}" placeholder="${esc((vm.models || [])[0] || 'The tool’s default')}">
106
- <datalist id="cai-models">${(vm.models || []).map((m) => `<option value="${esc(m)}">`).join('')}</datalist></div>
107
- <div class="field"><label for="cai-cap">Daily cap (tokens)</label>
108
- <input class="input" id="cai-cap" name="cap" type="number" min="0" step="1000" value="${vm.cap || ''}" placeholder="No limit"></div>
109
- </div>
110
- <div class="field"><label for="cai-prompt">Instructions for every run</label>
111
- <textarea class="input" id="cai-prompt" name="prompt" rows="3" placeholder="Anything it should always do, or never do.">${esc(vm.prompt || '')}</textarea></div>
112
- ${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
113
- ${ui.confirming ? confirmHtml(vm) : `<div class="cai-two"><button class="btn primary" type="submit" ${ui.busy === 'save' ? 'disabled' : ''}>Save</button><button class="btn danger" type="button" data-cloud-disconnect>Disconnect…</button></div>`}
114
- </form>
115
- </div>`
246
+ function checkName () {
247
+ const n = String(setup.draft.name || '').trim().replace(/\s+/g, ' ')
248
+ if (!n) return 'Give your AI a name.'
249
+ if (!NAME_OK.test(n)) return 'Its name: up to 40 letters, numbers and spaces.'
250
+ if (n.toLowerCase() === person().toLowerCase()) return 'That is your own name: pick another one for your AI.'
251
+ setup.draft.name = n
252
+ return ''
116
253
  }
117
254
 
118
- /** The card's inside for `vm` (null: not connected) and what the person is doing in it. */
119
- export function cloudAiInnerHtml (vm, ui = {}) {
120
- const phase = cloudPhase(vm)
121
- if (phase === 'none') return connectHtml(ui)
122
- return phase === 'signin' ? signInHtml(vm, ui) : readyHtml(vm, ui)
123
- }
124
-
125
- /** Fills the card, and keeps it current with the API while it is on screen. */
126
- export function bindCloudAi (sec) {
127
- let vm = null
128
- let polling = false
129
- const ui = { busy: '', error: '', confirming: false }
130
- const edited = new Map() // field name -> what the person typed, kept across redraws
131
-
132
- const draw = () => {
133
- const focus = document.activeElement && sec.contains(document.activeElement) ? document.activeElement.name || document.activeElement.id : ''
134
- sec.innerHTML = cloudAiInnerHtml(vm, ui)
135
- for (const [name, value] of edited) { const el = sec.querySelector(`[name="${name}"]`); if (el && !el.disabled) el.value = value }
136
- if (focus) { const el = sec.querySelector(`[name="${focus}"]`) || document.getElementById(focus); if (el && sec.contains(el) && !el.disabled) el.focus() }
137
- }
138
- const take = (r) => {
139
- const before = cloudPhase(vm)
140
- vm = r.vm
141
- if (cloudPhase(vm) !== before) { edited.clear(); ui.confirming = false }
142
- draw()
143
- if (vm) poll()
255
+ /** The draft as the API takes it ("<person>'s AI" is its name anyway: sent blank, so it follows theirs). */
256
+ const settingsOf = (d) => ({ tool: d.tool, name: d.name === own() ? '' : d.name, color: d.color, model: d.model, cap: d.cap, prompt: d.prompt, tasks: d.tasks, files: d.files, wake: d.wake, sessionsMode: d.sessionsMode, rooms: d.sessionsMode === 'pick' ? d.rooms : [] })
257
+
258
+ async function finish () {
259
+ const d = setup.draft
260
+ setup.busy = true
261
+ setup.error = ''
262
+ redraw()
263
+ try {
264
+ if (setup.editing && cloud.vm) {
265
+ cloud.vm = (await api('POST', '/api/cloud-ai/settings', settingsOf(d))).vm
266
+ toast(`Saved ${d.name}'s setup`)
267
+ closeSetup()
268
+ } else {
269
+ cloud.vm = (await api('POST', '/api/cloud-ai/connect', settingsOf(d))).vm
270
+ Object.assign(setup, { step: 4, busy: false })
271
+ watch()
272
+ }
273
+ } catch (err) {
274
+ if (setup) Object.assign(setup, { busy: false, error: err.message })
144
275
  }
145
- // The API holds each request until something changes (or 25 s pass), so this is not a busy loop.
146
- const poll = async () => {
147
- if (polling) return
148
- polling = true
149
- for (;;) {
150
- if (!sec.isConnected || !vm) break
276
+ redraw()
277
+ }
278
+
279
+ // ------------------------------------------------------------ keeping current --
280
+
281
+ /** Follows the API while a Settings shows the card or the setup: the card, and the sign-in as it goes. */
282
+ function watch () {
283
+ if (cloud.polling || !cloud.vm) return
284
+ cloud.polling = true
285
+ ;(async () => {
286
+ while (cloud.vm && [...cloud.hosts.keys()].some((r) => r.isConnected)) {
151
287
  try {
152
- const r = await api('GET', `/api/cloud-ai?wait=${encodeURIComponent(vm.version || 0)}`)
153
- if (!sec.isConnected) break
154
- if (!r.vm || !vm || r.vm.version !== vm.version || cloudPhase(r.vm) !== cloudPhase(vm)) take(r)
288
+ take((await api('GET', `/api/cloud-ai?wait=${encodeURIComponent(cloud.vm.version || 0)}`)).vm)
155
289
  } catch (err) {
156
- if (err.signedOut || !sec.isConnected) break
290
+ if (err.signedOut) break
157
291
  await new Promise((resolve) => setTimeout(resolve, 5000))
158
292
  }
159
293
  }
160
- polling = false
161
- }
294
+ cloud.polling = false
295
+ })()
296
+ }
297
+
298
+ function take (vm) {
299
+ const before = cloud.vm
300
+ cloud.vm = vm || null
301
+ // Signed in while the setup showed its last step: it is done.
302
+ if (setup && setup.step === 4 && vm && vm.signedIn) { closeSetup(); toast(`${vm.name} is ready`) }
303
+ const changed = before === undefined || !before !== !vm || (before && vm && (before.version !== vm.version || cloudPhase(before) !== cloudPhase(vm)))
304
+ if (changed) redraw()
305
+ watch()
306
+ }
307
+
308
+ // --------------------------------------------------------------- the binding --
309
+
310
+ /**
311
+ * Binds the card (in Settings → Agents) and the setup when it is open. `root` holds both: the
312
+ * Settings page, or the pop-up (whose header and footer the setup takes too). `refresh` draws that
313
+ * Settings again, so the setup takes over its panel, and gives it back when it closes.
314
+ */
315
+ export function bindCloudAi (root, refresh) {
316
+ const ui = cloud.ui
317
+ cloud.hosts.set(root, () => {
318
+ const flow = !!root.querySelector('.ai-flow-body')
319
+ const sec = $('#cloud-ai-sec', root)
320
+ if (!flow && !sec) return // another tab
321
+ if (setup || flow) return refresh()
322
+ if (cloud.vm !== undefined) sec.innerHTML = cloudAiInnerHtml(cloud.vm, ui)
323
+ })
324
+ const sec = $('#cloud-ai-sec', root)
325
+ if (sec && cloud.vm !== undefined) sec.innerHTML = cloudAiInnerHtml(cloud.vm, ui)
326
+ if (sec && cloud.toCard) { cloud.toCard = false; requestAnimationFrame(() => sec.scrollIntoView({ block: 'start' })) }
327
+ if (cloud.vm === undefined && !cloud.loading) {
328
+ cloud.loading = true
329
+ api('GET', '/api/cloud-ai').then((r) => take(r.vm), (err) => {
330
+ for (const s of document.querySelectorAll('#cloud-ai-sec .sec-body')) s.innerHTML = `<p class="hint warn">${esc(err.message)}</p>`
331
+ }).finally(() => { cloud.loading = false })
332
+ } else watch()
333
+ if (root.dataset.cloudBound) return
334
+ root.dataset.cloudBound = '1'
335
+
162
336
  const act = async (busy, fn) => {
163
337
  ui.busy = busy
164
338
  ui.error = ''
165
- draw()
166
- try { await fn() } catch (err) { ui.error = err.message }
339
+ redraw()
340
+ try { await fn() } catch (err) { ui.error = err.message; if (setup) setup.error = err.message }
167
341
  ui.busy = ''
168
- draw()
342
+ redraw()
169
343
  }
344
+ const signInUrl = () => (cloud.vm && cloud.vm.signIn && cloud.vm.signIn.url) || ''
345
+ // A change goes into the setup's draft while it is open; on the card (set up) it is saved at once.
346
+ const change = async (patch) => {
347
+ if (setup) { Object.assign(setup.draft, patch); setup.error = ''; redraw(); return }
348
+ if (!cloud.vm) return
349
+ try { take((await api('POST', '/api/cloud-ai/settings', patch)).vm); toast('Saved') } catch (err) { toast(err.message) }
350
+ }
351
+ const current = (k, dflt) => setup ? setup.draft[k] : cloud.vm && cloud.vm[k] !== undefined ? cloud.vm[k] : dflt
170
352
 
171
- sec.addEventListener('input', (e) => { if (e.target.name) edited.set(e.target.name, e.target.value) })
172
- sec.addEventListener('click', async (e) => {
353
+ root.addEventListener('click', async (e) => {
173
354
  const b = e.target.closest('button')
174
- if (!b || b.disabled) return
175
- if (b.dataset.cloudConnect) return act(b.dataset.cloudConnect, async () => take(await api('POST', '/api/cloud-ai/connect', { tool: b.dataset.cloudConnect })))
176
- if (b.hasAttribute('data-cloud-open')) { window.open(signInUrl(), '_blank', 'noopener'); return }
177
- if (b.hasAttribute('data-cloud-copy')) {
355
+ if (!b || b.disabled || !root.contains(b)) return
356
+ const data = b.dataset
357
+ if ('aiSetup' in data) return openSetup()
358
+ if ('aiEdit' in data) return openSetup({ editing: true })
359
+ if ('aiSignin' in data) return openSetup({ step: 4 })
360
+ if ('aiClose' in data) { closeSetup(); return redraw() }
361
+ if ('aiPrev' in data) { setup.step = Math.max(0, setup.step - 1); setup.error = ''; return redraw() }
362
+ if ('aiNext' in data) {
363
+ if (setup.step === 0) { const bad = checkName(); if (bad) { setup.error = bad; return redraw() } }
364
+ setup.step++
365
+ setup.error = ''
366
+ return redraw()
367
+ }
368
+ // From the sign-in, back to a question: what is changed there is saved (the AI exists by then).
369
+ if ('aiGoto' in data) { setup.step = Number(data.aiGoto); setup.editing = !!cloud.vm; return redraw() }
370
+ if ('aiStart' in data) {
371
+ const bad = checkName()
372
+ if (bad) { Object.assign(setup, { step: 0, error: bad }); return redraw() }
373
+ return finish()
374
+ }
375
+ if (data.aiName !== undefined) return change({ name: data.aiName })
376
+ if (data.aiColor !== undefined) return change({ color: data.aiColor })
377
+ if (data.aiTool !== undefined) return change({ tool: data.aiTool, model: '' })
378
+ if (data.aiTasks !== undefined) return change({ tasks: data.aiTasks })
379
+ if ('aiFiles' in data) return change({ files: !current('files', true) })
380
+ if ('aiWake' in data) return change({ wake: !current('wake', true) })
381
+ if (data.aiSessions !== undefined) {
382
+ const rooms = setup && data.aiSessions === 'pick' && !setup.draft.rooms.length ? openSessions().map((s) => s.room) : current('rooms', [])
383
+ return change({ sessionsMode: data.aiSessions, rooms })
384
+ }
385
+ if ('cloudOpen' in data) { window.open(signInUrl(), '_blank', 'noopener'); return }
386
+ if ('cloudCopy' in data) {
178
387
  try { await navigator.clipboard.writeText(signInUrl()); toast('Copied the link') } catch { toast('Select the link and press ⌘/Ctrl+C to copy') }
179
388
  return
180
389
  }
181
- if (b.hasAttribute('data-cloud-restart')) return act('restart', async () => { await api('POST', '/api/cloud-ai/sign-in', {}); toast(`Starting ${cloudTool(vm.tool).short}'s sign-in again`) })
182
- if (b.hasAttribute('data-cloud-wake')) return act('wake', async () => take(await api('POST', '/api/cloud-ai/wake')))
183
- if (b.hasAttribute('data-cloud-disconnect')) { ui.confirming = true; ui.error = ''; draw(); $('[data-cloud-keep]', sec)?.focus(); return }
184
- if (b.hasAttribute('data-cloud-keep')) { ui.confirming = false; draw(); return }
185
- if (b.hasAttribute('data-cloud-delete')) {
390
+ if ('cloudRestart' in data) return act('restart', async () => { await api('POST', '/api/cloud-ai/sign-in', {}); toast(`Starting ${cloudTool(cloud.vm.tool).short}'s sign-in again`) })
391
+ if ('cloudWake' in data) return act('wake', async () => take((await api('POST', '/api/cloud-ai/wake')).vm))
392
+ if ('cloudDisconnect' in data) { ui.confirming = true; ui.error = ''; return redraw() }
393
+ if ('cloudKeep' in data) { ui.confirming = false; return redraw() }
394
+ if ('cloudDelete' in data) {
186
395
  return act('delete', async () => {
187
396
  await api('POST', '/api/cloud-ai/disconnect')
188
397
  ui.confirming = false
189
- take({ vm: null })
398
+ take(null)
190
399
  toast('Disconnected: its machine and disk are deleted')
191
400
  })
192
401
  }
193
402
  })
194
- sec.addEventListener('submit', (e) => {
195
- e.preventDefault()
196
- const f = new FormData(e.target)
197
- if (e.target.id === 'cloud-code-form') {
198
- const code = String(f.get('code') || '').trim()
199
- if (!code) return
200
- return act('code', async () => {
201
- await api('POST', '/api/cloud-ai/sign-in', { code })
202
- edited.delete('code')
203
- toast(`Sent to ${cloudTool(vm.tool).short} on your machine`)
204
- })
403
+ // Typing keeps the field's focus: the AI's card and the name chips follow without a redraw.
404
+ root.addEventListener('input', (e) => {
405
+ if (!setup) return
406
+ const t = e.target
407
+ if (t.id === 'ai-name') {
408
+ setup.draft.name = t.value
409
+ const card = $('.ai-card', root)
410
+ if (card) card.outerHTML = aiCard({ name: t.value || '?', color: setup.draft.color, tool: setup.draft.tool })
411
+ for (const c of root.querySelectorAll('[data-ai-name]')) c.classList.toggle('on', c.dataset.aiName === t.value)
205
412
  }
206
- if (e.target.id === 'cloud-settings-form') {
207
- return act('save', async () => {
208
- const r = await api('POST', '/api/cloud-ai/settings', { model: f.get('model'), prompt: f.get('prompt'), cap: f.get('cap') })
209
- edited.clear()
210
- take(r)
211
- toast('Saved')
212
- })
413
+ if (t.matches('[data-ai-rules]')) setup.draft.prompt = t.value
414
+ })
415
+ root.addEventListener('change', (e) => {
416
+ const t = e.target
417
+ if (t.matches('[data-ai-model]')) change({ model: t.value })
418
+ if (t.matches('[data-ai-cap]')) change({ cap: Number(t.value) || 0 })
419
+ if (t.matches('[data-ai-room]') && setup) {
420
+ const rooms = new Set(setup.draft.rooms)
421
+ if (t.checked) rooms.add(t.dataset.aiRoom); else rooms.delete(t.dataset.aiRoom)
422
+ setup.draft.rooms = [...rooms]
213
423
  }
214
424
  })
215
- const signInUrl = () => (vm && vm.signIn && vm.signIn.url) || ''
216
-
217
- api('GET', '/api/cloud-ai').then(take, (err) => {
218
- $('.sec-body', sec).innerHTML = `<p class="hint warn">${esc(err.message)}</p>`
425
+ root.addEventListener('keydown', (e) => {
426
+ if (e.key === 'Enter' && e.target.id === 'ai-name') { e.preventDefault(); $('[data-ai-next]', root)?.click() }
427
+ })
428
+ root.addEventListener('submit', (e) => {
429
+ if (e.target.id !== 'cloud-code-form') return
430
+ e.preventDefault()
431
+ const code = String(new FormData(e.target).get('code') || '').trim()
432
+ if (!code) return
433
+ act('code', async () => {
434
+ await api('POST', '/api/cloud-ai/sign-in', { code })
435
+ toast(`Sent to ${cloudTool(cloud.vm.tool).short} on its machine`)
436
+ })
219
437
  })
220
438
  }