A live macOS picker window for Flutter UI variants: Claude writes the variants into a temporary app, and your choice, notes and revisions come back to the…

A Claude Code mod that shows UI variants for your Flutter project in a live macOS window instead of describing them with diagrams in the terminal.
Claude writes 2–4 variants of a screen, a color scheme or a widget into a temporary picker app. You compare them side by side in real device frames, ask for revisions and pick one. Your choice goes back to the session by itself, and Claude implements it in the project. You never have to switch to the terminal.

| Several questions | Device frames |
|---|---|
![]() | ![]() |
| Ask for a revision | Claude is revising |
|---|---|
![]() | ![]() |
.fvm/flutter_sdk, that Flutter is used.Enable the mod only in the Flutter projects where you want it (local scope). In other projects it is not loaded and takes no context.
In a Claude Code terminal session inside the project:
/plugin install ui-variants --marketplace DemeoStep/ui-variants
Answer y to add the marketplace, then choose "Install for you, in this repo only (local scope)".
Or from the command line in the project root:
claude plugin marketplace add DemeoStep/ui-variants
claude plugin install ui-variants@demeostep --scope local
The local install writes one line to the project's .claude/settings.local.json. Claude Code adds that file to your global git excludes, so it never ends up in a commit.
To turn the mod off and on again:
claude plugin disable ui-variants@demeostep --scope local
claude plugin enable ui-variants@demeostep --scope local
To let Claude write variants into the temporary app without permission prompts, add this to ~/.claude/settings.json:
{
"permissions": {
"additionalDirectories": ["/Users/<you>/Library/Caches/ui-variants"],
"allow": ["Edit(~/Library/Caches/ui-variants/**)"]
}
}
There is nothing to run. When a task has several equally good options, Claude loads the ui-variants skill on its own and shows them in the window. You can also ask for variants explicitly:
/ui-variants:ui-variants sign-in screen
Skill arguments:
close closes the project's window;clean closes the window and deletes the temporary app.The first time in a project the window takes about a minute to open while the app is created and built. After that, variants refresh with a hot restart.
skills/ui-variants/SKILL.md tells Claude when and how to show variants. Only its description stays in context, about 90 tokens.hooks/register.js does nothing until the skill loads. Then it creates or updates a temporary app in ~/Library/Caches/ui-variants/<package>-<hash>/: flutter create, the templates from templates/, a path dependency on your project, flutter pub get. Your project is never changed; only its pubspec.yaml is read.flutter run -d macos or hot restarts the running window..state/decision.json. The mod sends them to the session as the next message ($.prompt.submit) and acknowledges them to the window through .state/ack.json and .state/requests-ack.json.Device frames come from the device_frame package, resolved in the >=1.2.0 <2.0.0 range. The newest iPhones, which the package does not have yet, are defined by the mod. If device_frame does not fit your project's dependencies, the mod builds the picker with simplified built-in frames instead.
Network is used only by flutter pub get.
claude -p: the process exits before the mod can start it.The mod runs only on macOS, and the macOS check is isMacos in hooks/register.js. If you need the mod on another OS, fork the repository: the code is MIT-licensed. The picker itself (templates/lib/) is plain Flutter and does not depend on the OS. What ties the mod to macOS is how it creates, starts, refreshes and closes the window:
| What | Where | macOS now | Linux | Windows |
|---|---|---|---|---|
| OS check | isMacos | uname -s is Darwin | accept Linux | OS is Windows_NT |
| Temporary app folder | hostPaths | ~/Library/Caches/ui-variants | $XDG_CACHE_HOME/ui-variants or ~/.cache/ui-variants | %LOCALAPPDATA%\ui-variants; HOME is usually not set |
| App creation | prepareHost, disableSandbox | flutter create --platforms=macos, sandbox off in the entitlements | --platforms=linux | --platforms=windows |
| Window title, raising the window | templates/macos/Runner/MainFlutterWindow.swift.tmpl, lib/_host/window.dart | Swift and the ui_variants/window channel | linux/runner/my_application.cc | windows/runner/main.cpp |
| Built app, found by its path to close it | appBundle | build/macos/Build/Products/Debug/ui_variants_host.app | build/linux/<arch>/debug/bundle/ui_variants_host | build\windows\x64\runner\Debug\ui_variants_host.exe |
| Detached launch | LAUNCH_SCRIPT | sh, nohup, perl with setsid, flutter run -d macos | -d linux; setsid from util-linux if perl is missing | no sh or perl |
| Hot restart | hotRestart | kill -USR2 to flutter run | the same | no signals |
| Closing the window | STOP_SCRIPT, isAlive, windowPid | ps, kill, pkill | the same | tasklist, taskkill |
Reading the flutter run log | logTail | tail -c | the same | read it with $.fs |
| Deleting files | prepareHost, clean in answerSkill | rm -rf | the same | no rm |
| Finding Flutter | findFlutter, searchFlutter | which, the login shell, Homebrew paths | add the snap path | where flutter, flutter.bat |
| Toolchain | Xcode | clang, cmake, ninja-build, pkg-config, libgtk-3-dev | Visual Studio with "Desktop development with C++" |
Linux is the short way: the Unix commands and SIGUSR2 behave as on macOS. The mod sends the signal by name, so its different number on Linux does not matter.
Windows is harder because of hot restart. flutter run takes no signals there, and $.process.spawn writes to a process's stdin only once, so the mod cannot drive flutter run --machine and its app.restart command by itself. One way out is a small launcher in Dart, which ships with Flutter: it starts flutter run --machine, watches request files in .state/ and sends app.restart or app.stop. Also check that flutter.bat starts through $.process.run, which runs commands without a shell, and that the window outlives the Claude Code process.
tests/register.test.ts stubs every command the mod runs, so update the stubs together with the code.
claude plugin validate .
claude plugin test .
Bump version in .claude-plugin/plugin.json after every change. The mod uses it to update the templates in already created temporary apps; lib/registry.dart, lib/theme.dart and lib/variants/ are never overwritten.
The screenshots show the real picker window rendered with demo variants.
MIT © 2026 Oleksii Horiainov
hooks/register.js 892 lines1// ui-variants mod: a live picker window for UI variants in Flutter projects on macOS.
2//
3// Until the ui-variants skill is loaded in a session, the mod does nothing.
4// Loading the skill creates a temporary picker app in ~/Library/Caches/ui-variants;
5// after a Claude turn that writes variants, the mod opens the window (or hot
6// restarts it), and sends the choice, notes and revisions from the window back
7// into the session as the next message. The window closes after a choice and
8// when the session ends.
9
10import {
11 choicePrompt,
12 choiceSignature,
13 compareVersions,
14 decisionChoices,
15 fillPlaceholders,
16 flutterError,
17 isFlutterPubspec,
18 lastLine,
19 noteLine,
20 notesSignature,
21 parseArgs,
22 parseDecision,
23 pubspecName,
24 pubspecSdk,
25 revisionPrompt,
26 sendSignature,
27 shortHash,
28 widerSdk,
29 withoutSandbox,
30 yamlQuote,
31} from './util.js'
32
33const TICK_MS = 2000
34const OWNER_BEAT_MS = 5000
35const OWNER_STALE_MS = 15000
36const RESTART_CHECK_MS = 4000
37const RESTART_GIVE_UP_MS = 60000
38const STORE_TTL_MS = 14 * 24 * 60 * 60 * 1000
39const ALIVE_TTL_MS = 24 * 60 * 60 * 1000
40const CLOSE_AFTER_CHOICE_MS = 2500
41const LONG_MS = 300000
42
43const NOT_FLUTTER = 'This is not a Flutter project, so the picker window is not available here. Describe the variants in text.'
44const MACOS_ONLY = 'The picker window works only on macOS, so it is not available here. Describe the variants in text.'
45
46// Temporary app files that template updates never overwrite.
47const PROTECTED = /^lib\/(registry\.dart|theme\.dart|variants\/)/
48
49// Device frames: the device_frame package (a version range, so pub can pick one
50// compatible with the project's dependencies) or built-in frames if none fits.
51const DEVICE_FRAME_DEPENDENCY = " device_frame: '>=1.2.0 <2.0.0'"
52const FRAMES_TEMPLATES = { device_frame: 'lib/_host/frames.dart', builtin: 'fallback/frames.dart' }
53
54// Starts flutter run in the background, detached from the Claude Code process:
55// the window survives a mod reload, and the mod itself closes it (session end,
56// close, clean). Prints the PID of the started process.
57const LAUNCH_SCRIPT = [
58 'mkdir -p "$UIV_STATE"',
59 'rm -f "$UIV_STATE/flutter.pid"',
60 // A window that outlived its flutter run cannot be refreshed: close it.
61 'pkill -f "$UIV_APP_BUNDLE" 2>/dev/null',
62 'nohup /usr/bin/perl -MPOSIX -e \'POSIX::setsid(); exec @ARGV or die "exec: $!"\' ' +
63 '"$UIV_FLUTTER" run -d macos --pid-file "$UIV_STATE/flutter.pid" ' +
64 '"--dart-define=UI_VARIANTS_STATE=$UIV_STATE" ' +
65 '"--dart-define=UI_VARIANTS_PACKAGE=$UIV_PACKAGE" ' +
66 '</dev/null >"$UIV_STATE/flutter.log" 2>&1 &',
67 'echo $!',
68].join('\n')
69
70// Closes the window with one quick command (session.end has about 1.5 s in all).
71// Only this project's flutter run gets the signal: its command line contains the
72// .state path, so another flutter run, or a PID reused by another process, is
73// left alone. Prints 1 if anything was running.
74const STOP_SCRIPT = [
75 'found=0',
76 'for p in $UIV_PIDS; do',
77 ' if ps -p "$p" -o command= 2>/dev/null | grep -qF -- "$UIV_STATE"; then',
78 ' kill -TERM "$p" 2>/dev/null && found=1',
79 ' fi',
80 'done',
81 'pkill -f "$UIV_APP_BUNDLE" && found=1',
82 'echo $found',
83].join('\n')
84
85// Module state. It is empty after a mod reload and is restored from $.store in
86// session.start.
87let active = null
88let watchTimer = null
89let closeTimer = null
90let needsRefresh = false
91let launching = null
92let pendingRestart = false
93let restartCheck = null
94let seen = { choice: undefined, notes: '' }
95let decisionMtime = -1
96let isTicking = false
97let ownerBeatAt = 0
98let lastFingerprint = ''
99let version = undefined
100
101// $.store keys: every key includes the project path.
102function activeKey(root, sessionId) {
103 return `active|${root}|${sessionId}`
104}
105function seenKey(root, sessionId) {
106 return `seen|${root}|${sessionId}`
107}
108function windowKey(root) {
109 return `window|${root}`
110}
111function submittedKey(root) {
112 return `submitted|${root}`
113}
114function ownerKey(root) {
115 return `owner|${root}`
116}
117function hostKey(root) {
118 return `host|${root}`
119}
120function flutterKey(root) {
121 return `flutter|${root}`
122}
123function aliveKey(root, sessionId) {
124 return `alive|${root}|${sessionId}`
125}
126function requestedKey(root) {
127 return `requested|${root}`
128}
129
130function message(error) {
131 return error instanceof Error ? error.message : String(error)
132}
133
134function resetState() {
135 active = null
136 needsRefresh = false
137 launching = null
138 pendingRestart = false
139 restartCheck = null
140 seen = { choice: undefined, notes: '' }
141 decisionMtime = -1
142 isTicking = false
143 ownerBeatAt = 0
144 lastFingerprint = ''
145}
146
147function stopWatch() {
148 if (watchTimer !== null) watchTimer.cancel()
149 watchTimer = null
150 if (closeTimer !== null) closeTimer.cancel()
151 closeTimer = null
152}
153
154function startWatch($) {
155 if (watchTimer === null) watchTimer = $.clock.every(TICK_MS, () => void tick($))
156}
157
158/** Paths of the project's temporary app. */
159function hostPaths(root, pkg, home) {
160 const appDir = `${home}/Library/Caches/ui-variants/${pkg}-${shortHash(root)}`
161 return { appDir, stateDir: `${appDir}/.state` }
162}
163
164/** The project's built picker app (its process is found by this path). */
165function appBundle(ctx) {
166 return `${ctx.appDir}/build/macos/Build/Products/Debug/ui_variants_host.app`
167}
168
169/** The skill text with the paths filled in. */
170function fillSkill(text, ctx) {
171 return fillPlaceholders(text, {
172 VARIANTS_DIR: `${ctx.appDir}/lib/variants`,
173 REGISTRY: `${ctx.appDir}/lib/registry.dart`,
174 THEME: `${ctx.appDir}/lib/theme.dart`,
175 DECISION: `${ctx.stateDir}/decision.json`,
176 PACKAGE: ctx.pkg,
177 APP_DIR: ctx.appDir,
178 })
179}
180
181function unavailableSkill(text, ctx, reason) {
182 return `${fillSkill(text, ctx)}\n\nIMPORTANT: the picker window is not available right now (${reason}). ` +
183 'This time describe the variants in text in your reply and skip the instructions about the temporary app.'
184}
185
186/** Whether a tool call writes the variant, registry or theme files. */
187function touchesHost(e, appDir) {
188 const lib = `${appDir}/lib/`
189 const watched = path =>
190 path === `${lib}registry.dart` || path === `${lib}theme.dart` || path.startsWith(`${lib}variants/`)
191 return [e.file_path, e.notebook_path].some(path => typeof path === 'string' && watched(path))
192}
193
194async function collectStamps($, dir, prefix, parts) {
195 if (!(await $.fs.exists(dir))) return
196 for (const entry of await $.fs.list(dir)) {
197 if (entry.kind === 'dir') await collectStamps($, `${dir}/${entry.name}`, `${prefix}${entry.name}/`, parts)
198 else parts.push(`${prefix}${entry.name}:${entry.mtimeMs}:${entry.size}`)
199 }
200}
201
202/**
203 * A fingerprint of the picker files Claude writes: registry, theme and variants.
204 * Catches writes made any way (Bash, a subagent), not only Write and Edit.
205 */
206async function hostFingerprint($, ctx) {
207 const lib = `${ctx.appDir}/lib`
208 const parts = []
209 for (const name of ['registry.dart', 'theme.dart']) {
210 const path = `${lib}/${name}`
211 const stat = (await $.fs.exists(path)) ? await $.fs.stat(path) : undefined
212 parts.push(`${name}:${stat?.mtimeMs ?? 0}:${stat?.size ?? 0}`)
213 }
214 await collectStamps($, `${lib}/variants`, 'variants/', parts)
215 return parts.sort().join('|')
216}
217
218async function pluginVersion($) {
219 if (version === undefined) {
220 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
221 version = String(manifest.version ?? '0.0.0')
222 }
223 return version
224}
225
226async function logError($, ctx, text) {
227 $.ui.log(`ui-variants: ${text}`, { to: 'debug' })
228 if (ctx === null) return
229 const path = `${ctx.stateDir}/mod.log`
230 const at = new Date(await $.clock.now()).toISOString()
231 const previous = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
232 const lines = `${previous}${at} ${text}\n`.split('\n').slice(-200)
233 await $.fs.write(path, lines.join('\n'))
234}
235
236async function isAlive($, pid) {
237 if (!Number.isInteger(pid) || pid <= 0) return false
238 const probe = await $.process.run(['kill', '-0', String(pid)])
239 return probe.exitCode === 0
240}
241
242/** The PID of this project's flutter run, if it is alive. */
243async function windowPid($, ctx) {
244 const saved = await $.store.get(windowKey(ctx.root))
245 let pid = saved !== null && typeof saved === 'object' ? saved.pid : undefined
246 const pidPath = `${ctx.stateDir}/flutter.pid`
247 if (!Number.isInteger(pid) && (await $.fs.exists(pidPath))) {
248 pid = Number.parseInt((await $.fs.read(pidPath)).trim(), 10)
249 }
250 if (!(await isAlive($, pid))) return undefined
251 // The PID may have been reused: check that it is this project's flutter run
252 // (its command line contains the .state path).
253 const ps = await $.process.run(['ps', '-p', String(pid), '-o', 'command='])
254 return ps.stdout.includes(ctx.stateDir) ? pid : undefined
255}
256
257async function logTail($, ctx, from) {
258 const path = `${ctx.stateDir}/flutter.log`
259 if (!(await $.fs.exists(path))) return ''
260 const argv = from === undefined ? ['tail', '-c', '20000', path] : ['tail', '-c', `+${from + 1}`, path]
261 const read = await $.process.run(argv)
262 return read.stdout.slice(-20000)
263}
264
265async function logSize($, ctx) {
266 const path = `${ctx.stateDir}/flutter.log`
267 if (!(await $.fs.exists(path))) return 0
268 return (await $.fs.stat(path)).size
269}
270
271// ---------- Flutter and the temporary app ----------
272
273async function findFlutter($, root, home) {
274 const pinned = `${root}/.fvm/flutter_sdk/bin/flutter`
275 if (await $.fs.exists(pinned)) return pinned
276 const cached = await $.store.get(flutterKey(root))
277 if (typeof cached === 'string' && (await $.fs.exists(cached))) return cached
278 const found = await searchFlutter($, home)
279 if (found !== undefined) await $.store.set(flutterKey(root), found)
280 return found
281}
282
283async function searchFlutter($, home) {
284 const direct = await $.process.run(['/usr/bin/which', 'flutter']).catch(() => undefined)
285 const onPath = direct !== undefined && direct.exitCode === 0 ? lastLine(direct.stdout) : ''
286 if (onPath.startsWith('/')) return onPath
287 const shell = (await $.env.get('SHELL')) || '/bin/zsh'
288 const login = await $.process
289 .run([shell, '-lic', 'command -v flutter'], { timeoutMs: 20000 })
290 .catch(() => undefined)
291 const viaShell = login !== undefined && login.exitCode === 0 ? lastLine(login.stdout) : ''
292 if (viaShell.startsWith('/')) return viaShell
293 const candidates = [
294 `${home}/fvm/default/bin/flutter`,
295 `${home}/flutter/bin/flutter`,
296 `${home}/development/flutter/bin/flutter`,
297 '/opt/homebrew/bin/flutter',
298 '/usr/local/bin/flutter',
299 ]
300 for (const candidate of candidates) {
301 if (await $.fs.exists(candidate)) return candidate
302 }
303 return undefined
304}
305
306async function listFiles($, base, rel) {
307 const entries = await $.fs.list(rel === '' ? base : `${base}/${rel}`)
308 const files = []
309 for (const entry of entries) {
310 const path = rel === '' ? entry.name : `${rel}/${entry.name}`
311 if (entry.kind === 'dir') files.push(...(await listFiles($, base, path)))
312 else if (entry.kind === 'file' && entry.name !== '.DS_Store') files.push(path)
313 }
314 return files
315}
316
317async function copyTemplates($, ctx, current) {
318 const base = `${$.plugin.root}/templates`
319 for (const rel of await listFiles($, base, '')) {
320 const target = rel.replace(/\.tmpl$/, '')
321 if (target === 'pubspec.yaml' || target.startsWith('fallback/')) continue
322 const dest = `${ctx.appDir}/${target}`
323 if (PROTECTED.test(target) && (await $.fs.exists(dest))) continue
324 const source = await $.fs.read(`${base}/${rel}`)
325 const text = rel.endsWith('.tmpl')
326 ? fillPlaceholders(source, { PACKAGE: ctx.pkg, VERSION: current })
327 : source
328 await $.fs.write(dest, text)
329 }
330}
331
332async function writeHostPubspec($, ctx, projectPubspec, hostSdk, current, frames) {
333 const template = await $.fs.read(`${$.plugin.root}/templates/pubspec.yaml.tmpl`)
334 const sdk = widerSdk(hostSdk, pubspecSdk(projectPubspec)) ?? '^3.0.0'
335 const text = fillPlaceholders(template, {
336 PACKAGE: ctx.pkg,
337 PROJECT_ROOT: yamlQuote(ctx.root),
338 SDK: yamlQuote(sdk),
339 VERSION: current,
340 DEVICE_FRAME: frames === 'device_frame' ? DEVICE_FRAME_DEPENDENCY : '',
341 })
342 await $.fs.write(`${ctx.appDir}/pubspec.yaml`, text)
343 const source = await $.fs.read(`${$.plugin.root}/templates/${FRAMES_TEMPLATES[frames]}`)
344 await $.fs.write(`${ctx.appDir}/lib/_host/frames.dart`, source)
345}
346
347async function pubGet($, ctx) {
348 return $.process.run([ctx.flutter, 'pub', 'get'], { cwd: ctx.appDir, timeoutMs: LONG_MS })
349}
350
351async function disableSandbox($, ctx) {
352 for (const name of ['DebugProfile', 'Release']) {
353 const path = `${ctx.appDir}/macos/Runner/${name}.entitlements`
354 if (!(await $.fs.exists(path))) continue
355 const plist = await $.fs.read(path)
356 const patched = withoutSandbox(plist)
357 if (patched !== plist) await $.fs.write(path, patched)
358 }
359}
360
361/** Creates or updates the temporary app; returns the failure reason or undefined. */
362async function prepareHost($, ctx, projectPubspec) {
363 const current = await pluginVersion($)
364 const saved = await $.store.get(hostKey(ctx.root))
365 const host = saved !== null && typeof saved === 'object' ? saved : {}
366 let hostSdk = typeof host.sdk === 'string' ? host.sdk : undefined
367 const hasRunner = await $.fs.exists(`${ctx.appDir}/macos/Runner.xcodeproj/project.pbxproj`)
368 if (!hasRunner) {
369 const created = await $.process.run(
370 [ctx.flutter, 'create', '--platforms=macos', '--project-name', 'ui_variants_host',
371 '--org', 'dev.uivariants', '--no-pub', ctx.appDir],
372 { timeoutMs: LONG_MS },
373 )
374 if (created.exitCode !== 0) {
375 return `flutter create: ${flutterError(created.stdout + created.stderr) ?? lastLine(created.stderr || created.stdout)}`
376 }
377 hostSdk = pubspecSdk(await $.fs.read(`${ctx.appDir}/pubspec.yaml`))
378 // Leftovers of the flutter create template: the MyApp test and IDE files.
379 await $.process.run(['rm', '-rf', 'test', '.idea', 'ui_variants_host.iml', 'README.md'], { cwd: ctx.appDir })
380 }
381 const hasHost = await $.fs.exists(`${ctx.appDir}/lib/_host/version.dart`)
382 const isOutdated = !hasRunner || !hasHost || typeof host.version !== 'string' ||
383 compareVersions(host.version, current) < 0
384 if (isOutdated) {
385 await copyTemplates($, ctx, current)
386 await disableSandbox($, ctx)
387 }
388 const pubspecMtime = (await $.fs.stat(`${ctx.root}/pubspec.yaml`)).mtimeMs
389 const hasPackages = await $.fs.exists(`${ctx.appDir}/.dart_tool/package_config.json`)
390 if (isOutdated || host.pubspecMtime !== pubspecMtime || !hasPackages) {
391 await writeHostPubspec($, ctx, projectPubspec, hostSdk, current, 'device_frame')
392 let pub = await pubGet($, ctx)
393 if (pub.exitCode !== 0) {
394 // device_frame is incompatible with the project's dependencies: built-in frames.
395 const reason = flutterError(`${pub.stdout}\n${pub.stderr}`) ?? lastLine(pub.stderr || pub.stdout)
396 await logError($, ctx, `device_frame does not fit, using built-in frames: ${reason}`)
397 await writeHostPubspec($, ctx, projectPubspec, hostSdk, current, 'builtin')
398 pub = await pubGet($, ctx)
399 }
400 if (pub.exitCode !== 0) {
401 await $.store.set(hostKey(ctx.root), { version: current, sdk: hostSdk, pubspecMtime: 0 })
402 const output = `${pub.stdout}\n${pub.stderr}`
403 const solving = output.split('\n').find(line => /version solving failed|Because /.test(line))
404 return `flutter pub get: ${solving?.trim() ?? flutterError(output) ?? lastLine(output)}`
405 }
406 }
407 await $.store.set(hostKey(ctx.root), { version: current, sdk: hostSdk, pubspecMtime })
408 return undefined
409}
410
411// ---------- Activation ----------
412
413async function readDecision($, ctx) {
414 const path = `${ctx.stateDir}/decision.json`
415 if (!(await $.fs.exists(path))) return undefined
416 return parseDecision(await $.fs.read(path))
417}
418
419async function pruneStore($, now) {
420 for (const key of await $.store.keys()) {
421 if (key.startsWith('alive|') && now - Number(await $.store.get(key)) > ALIVE_TTL_MS) {
422 await $.store.delete(key)
423 continue
424 }
425 if (!key.startsWith('active|')) continue
426 const value = await $.store.get(key)
427 const at = value !== null && typeof value === 'object' ? Number(value.at) : 0
428 if (now - at > STORE_TTL_MS) {
429 await $.store.delete(key)
430 await $.store.delete(`seen|${key.slice('active|'.length)}`)
431 }
432 }
433}
434
435async function activate($, ctx) {
436 const now = await $.clock.now()
437 const isSameProject = active !== null && active.root === ctx.root
438 active = ctx
439 await $.store.set(activeKey(ctx.root, ctx.sessionId), {
440 appDir: ctx.appDir,
441 stateDir: ctx.stateDir,
442 pkg: ctx.pkg,
443 flutter: ctx.flutter,
444 at: now,
445 })
446 await $.store.set(ownerKey(ctx.root), { sessionId: ctx.sessionId, beatAt: now })
447 ownerBeatAt = now
448 await markAlive($, ctx, now)
449 const previous = await $.store.get(seenKey(ctx.root, ctx.sessionId))
450 if (!isSameProject || previous === undefined) {
451 // A decision already in the file before the skill loaded never starts a
452 // turn: choices are accepted only while the session watches the window.
453 const decision = await readDecision($, ctx)
454 seen = { choice: choiceSignature(decision), notes: notesSignature(decision) }
455 if (decision !== undefined && decision.requests.length > 0) {
456 const sent = await sentRequests($, ctx)
457 const ids = decision.requests.map(request => request.id).filter(id => !sent.includes(id))
458 await $.store.set(requestedKey(ctx.root), [...sent, ...ids].slice(-200))
459 }
460 await $.store.set(seenKey(ctx.root, ctx.sessionId), seen)
461 }
462 if (!isSameProject) lastFingerprint = await hostFingerprint($, ctx).catch(() => '')
463 await pruneStore($, now)
464 startWatch($)
465}
466
467async function deactivate($, ctx) {
468 if (active !== null && active.root === ctx.root) {
469 stopWatch()
470 resetState()
471 }
472 await $.store.delete(activeKey(ctx.root, ctx.sessionId))
473 await $.store.delete(seenKey(ctx.root, ctx.sessionId))
474 await $.store.delete(aliveKey(ctx.root, ctx.sessionId))
475 const owner = await $.store.get(ownerKey(ctx.root))
476 if (owner !== null && typeof owner === 'object' && owner.sessionId === ctx.sessionId) {
477 await $.store.delete(ownerKey(ctx.root))
478 }
479}
480
481async function restore($) {
482 const root = await $.session.root()
483 const sessionId = await $.session.id()
484 const saved = await $.store.get(activeKey(root, sessionId))
485 if (saved === null || typeof saved !== 'object' || typeof saved.appDir !== 'string') return
486 active = {
487 root,
488 sessionId,
489 appDir: saved.appDir,
490 stateDir: saved.stateDir,
491 pkg: saved.pkg,
492 flutter: saved.flutter,
493 }
494 const previous = await $.store.get(seenKey(root, sessionId))
495 if (previous !== null && typeof previous === 'object') {
496 seen = { choice: previous.choice ?? undefined, notes: previous.notes ?? '' }
497 }
498 const win = await $.store.get(windowKey(root))
499 if (win !== null && typeof win === 'object' && win.pid === undefined && Number.isInteger(win.launcherPid)) {
500 launching = { pid: win.launcherPid, startedAt: win.startedAt }
501 }
502 lastFingerprint = await hostFingerprint($, active).catch(() => '')
503 startWatch($)
504}
505
506/** Whether Claude Code runs on macOS: Windows always sets OS=Windows_NT, uname tells macOS from Linux. */
507async function isMacos($) {
508 if ((await $.env.get('OS')) === 'Windows_NT') return false
509 const uname = await $.process.run(['uname', '-s']).catch(() => undefined)
510 return uname !== undefined && uname.stdout.trim() === 'Darwin'
511}
512
513async function answerSkill($, text) {
514 const command = parseArgs(text).toLowerCase()
515 const root = await $.session.root()
516 const pubspecPath = `${root}/pubspec.yaml`
517 const pubspec = (await $.fs.exists(pubspecPath)) ? await $.fs.read(pubspecPath) : ''
518 const pkg = pubspecName(pubspec)
519 if (!isFlutterPubspec(pubspec) || pkg === undefined) return NOT_FLUTTER
520 if (!(await isMacos($))) return MACOS_ONLY
521 const home = await $.env.get('HOME')
522 if (home === undefined || home === '') throw new Error('HOME is not set')
523 const sessionId = await $.session.id()
524 const ctx = { root, sessionId, pkg, flutter: '', ...hostPaths(root, pkg, home) }
525
526 if (command === 'close') {
527 return (await closeWindow($, ctx)) ? 'The picker window is closed.' : 'The picker window was not open.'
528 }
529 if (command === 'clean') {
530 await closeWindow($, ctx)
531 await deactivate($, ctx)
532 await $.process.run(['rm', '-rf', ctx.appDir])
533 await $.store.delete(hostKey(root))
534 return 'The picker window is closed and the temporary app is deleted.'
535 }
536
537 const flutter = await findFlutter($, root, home)
538 if (flutter === undefined) return unavailableSkill(text, ctx, 'the flutter command was not found')
539 ctx.flutter = flutter
540 const failure = await prepareHost($, ctx, pubspec)
541 if (failure !== undefined) {
542 await logError($, ctx, failure)
543 return unavailableSkill(text, ctx, failure)
544 }
545 await activate($, ctx)
546 return fillSkill(text, ctx)
547}
548
549// ---------- Window ----------
550
551/** Closes the project's window and its flutter run; returns whether anything was open. */
552async function closeWindow($, ctx) {
553 const pids = new Set()
554 const saved = await $.store.get(windowKey(ctx.root))
555 if (saved !== null && typeof saved === 'object') {
556 pids.add(saved.pid)
557 pids.add(saved.launcherPid)
558 }
559 if (launching !== null && active !== null && active.root === ctx.root) pids.add(launching.pid)
560 const pidPath = `${ctx.stateDir}/flutter.pid`
561 if (await $.fs.exists(pidPath)) pids.add(Number.parseInt((await $.fs.read(pidPath)).trim(), 10))
562 const list = [...pids].filter(pid => Number.isInteger(pid) && pid > 0)
563 // The app is closed even when it outlived its flutter run.
564 const stopped = await $.process.run(['/bin/sh', '-c', STOP_SCRIPT], {
565 env: { UIV_PIDS: list.join(' '), UIV_STATE: ctx.stateDir, UIV_APP_BUNDLE: appBundle(ctx) },
566 timeoutMs: 5000,
567 })
568 await $.store.delete(windowKey(ctx.root))
569 if (active !== null && active.root === ctx.root) {
570 launching = null
571 pendingRestart = false
572 restartCheck = null
573 }
574 return lastLine(stopped.stdout) === '1'
575}
576
577/** Whether another live session watches the project (its mark is newer than OWNER_STALE_MS). */
578async function hasOtherWatchers($, ctx, now) {
579 const prefix = `alive|${ctx.root}|`
580 const mine = aliveKey(ctx.root, ctx.sessionId)
581 for (const key of await $.store.keys()) {
582 if (!key.startsWith(prefix) || key === mine) continue
583 if (now - Number(await $.store.get(key)) < OWNER_STALE_MS) return true
584 }
585 return false
586}
587
588async function launchWindow($, ctx) {
589 const stream = $.process.spawn({
590 argv: ['/bin/sh', '-c', LAUNCH_SCRIPT],
591 cwd: ctx.appDir,
592 env: {
593 UIV_FLUTTER: ctx.flutter,
594 UIV_STATE: ctx.stateDir,
595 UIV_PACKAGE: ctx.pkg,
596 UIV_APP_BUNDLE: appBundle(ctx),
597 },
598 })
599 let output = ''
600 for await (const piece of stream) {
601 if (piece.stream === 'stdout') output += piece.text
602 }
603 const pid = Number.parseInt(lastLine(output), 10)
604 if (!Number.isInteger(pid) || pid <= 0) throw new Error(`flutter run did not start: ${lastLine(output) || 'no PID'}`)
605 const now = await $.clock.now()
606 launching = { pid, startedAt: now }
607 await $.store.set(windowKey(ctx.root), { launcherPid: pid, startedAt: now })
608 $.ui.toast('Opening the variants window…')
609}
610
611async function hotRestart($, ctx, pid, isAnnounced) {
612 const offset = await logSize($, ctx)
613 const sent = await $.process.run(['kill', '-USR2', String(pid)])
614 if (sent.exitCode !== 0) return false
615 restartCheck = { offset, at: await $.clock.now() }
616 if (isAnnounced) $.ui.toast('Variants window updated')
617 return true
618}
619
620/** Opens the window or refreshes it with a hot restart. */
621async function refreshWindow($) {
622 const ctx = active
623 if (ctx === null) return
624 try {
625 const pid = await windowPid($, ctx)
626 if (pid !== undefined && (await hotRestart($, ctx, pid, true))) return
627 if (launching !== null && (await isAlive($, launching.pid))) {
628 // The window is still building: refresh it as soon as it opens.
629 pendingRestart = true
630 return
631 }
632 await launchWindow($, ctx)
633 startWatch($)
634 } catch (error) {
635 $.ui.toast(`The variants window did not open: ${message(error)}`, { timeoutMs: 10000 })
636 await logError($, ctx, `window launch: ${message(error)}`)
637 }
638}
639
640// ---------- Watching ----------
641
642/**
643 * The session's liveness mark: in $.store (other sessions use it to decide
644 * whether to close the window) and in .state/heartbeat.json (without a fresh one
645 * for over a minute the window closes itself, if the session died before
646 * session.end).
647 */
648async function markAlive($, ctx, now) {
649 await $.store.set(aliveKey(ctx.root, ctx.sessionId), now)
650 await $.fs.write(`${ctx.stateDir}/heartbeat.json`, JSON.stringify({ at: now, sessionId: ctx.sessionId }))
651}
652
653async function beat($, ctx) {
654 const now = await $.clock.now()
655 if (now - ownerBeatAt < OWNER_BEAT_MS) return
656 ownerBeatAt = now
657 await markAlive($, ctx, now)
658 const owner = await $.store.get(ownerKey(ctx.root))
659 const isFree = owner === null || typeof owner !== 'object' ||
660 owner.sessionId === ctx.sessionId || now - Number(owner.beatAt ?? 0) > OWNER_STALE_MS
661 if (isFree) await $.store.set(ownerKey(ctx.root), { sessionId: ctx.sessionId, beatAt: now })
662}
663
664async function trackLaunch($, ctx) {
665 if (launching === null) return
666 const pidPath = `${ctx.stateDir}/flutter.pid`
667 if (await $.fs.exists(pidPath)) {
668 const pid = Number.parseInt((await $.fs.read(pidPath)).trim(), 10)
669 if (Number.isInteger(pid) && pid > 0) {
670 const startedAt = launching.startedAt
671 launching = null
672 await $.store.set(windowKey(ctx.root), { pid, startedAt })
673 $.ui.toast('Variants window is open')
674 if (pendingRestart) {
675 pendingRestart = false
676 await hotRestart($, ctx, pid, false)
677 }
678 return
679 }
680 }
681 if (await isAlive($, launching.pid)) return
682 launching = null
683 pendingRestart = false
684 await $.store.delete(windowKey(ctx.root))
685 const log = await logTail($, ctx)
686 const reason = flutterError(log) ?? (lastLine(log) || 'flutter run exited')
687 $.ui.toast(`The variants window did not open: ${reason}`, { timeoutMs: 10000 })
688 await logError($, ctx, `flutter run: ${reason} (full output: ${ctx.stateDir}/flutter.log)`)
689}
690
691async function checkRestart($, ctx) {
692 if (restartCheck === null) return
693 const now = await $.clock.now()
694 if (now - restartCheck.at < RESTART_CHECK_MS) return
695 const log = await logTail($, ctx, restartCheck.offset)
696 const error = flutterError(log)
697 if (error !== undefined) {
698 restartCheck = null
699 $.ui.toast(`Variants error: ${error}`, { timeoutMs: 10000 })
700 await logError($, ctx, `hot restart: ${error}`)
701 return
702 }
703 if (/Restarted application|Hot restart performed/i.test(log) || now - restartCheck.at > RESTART_GIVE_UP_MS) {
704 restartCheck = null
705 }
706}
707
708/** The answer to the window: the choice was sent, or was sent before. */
709async function writeAck($, ctx, decision, status) {
710 const at = new Date(await $.clock.now()).toISOString()
711 const ack = { round: decision.round, chosen: decision.chosen, chosenAt: decision.chosenAt, status, at }
712 await $.fs.write(`${ctx.stateDir}/ack.json`, JSON.stringify(ack))
713}
714
715/** This session owns the project: it sends choices and revisions. */
716async function isOwner($, ctx) {
717 const now = await $.clock.now()
718 const owner = await $.store.get(ownerKey(ctx.root))
719 return owner === null || typeof owner !== 'object' ||
720 owner.sessionId === ctx.sessionId || now - Number(owner.beatAt ?? 0) > OWNER_STALE_MS
721}
722
723async function sentRequests($, ctx) {
724 const value = await $.store.get(requestedKey(ctx.root))
725 return Array.isArray(value) ? value.filter(id => typeof id === 'string') : []
726}
727
728/**
729 * Variant revisions from the window: new ones go to Claude at once, in one
730 * message, without a choice. The window learns they were sent from
731 * requests-ack.json.
732 */
733async function submitRevisions($, ctx, decision) {
734 if (decision.requests.length === 0) return
735 const sent = await sentRequests($, ctx)
736 const fresh = decision.requests.filter(request => !sent.includes(request.id))
737 if (fresh.length === 0 || !(await isOwner($, ctx))) return
738 const all = [...sent, ...fresh.map(request => request.id)].slice(-200)
739 await $.store.set(requestedKey(ctx.root), all)
740 $.ui.toast(fresh.length === 1
741 ? `Revision sent to Claude: ${noteLine(fresh[0], 80)}`
742 : `Revisions sent to Claude: ${fresh.length}`)
743 const text = revisionPrompt(fresh, `${ctx.appDir}/lib/variants`)
744 $.prompt.submit({ text }).catch(error => void logError($, ctx, `sending a revision: ${message(error)}`))
745 const ids = decision.requests.map(request => request.id).filter(id => all.includes(id))
746 await $.fs.write(`${ctx.stateDir}/requests-ack.json`, JSON.stringify({ sent: ids }))
747}
748
749async function submitChoice($, ctx, decision) {
750 if (!(await isOwner($, ctx))) return
751 // Several sessions may watch the project and Choose may be pressed again:
752 // the same choice is sent once.
753 const signature = sendSignature(decision)
754 if ((await $.store.get(submittedKey(ctx.root))) === signature) {
755 $.ui.toast('This choice was already sent')
756 await writeAck($, ctx, decision, 'duplicate')
757 } else {
758 await $.store.set(submittedKey(ctx.root), signature)
759 const many = decisionChoices(decision).length > 1
760 $.ui.toast(`${many ? 'Chosen variants' : 'Chosen variant'}: ${decision.chosenTitle || decision.chosen}`)
761 const text = choicePrompt(decision, `${ctx.stateDir}/decision.json`)
762 $.prompt.submit({ text }).catch(error => void logError($, ctx, `sending the choice: ${message(error)}`))
763 await writeAck($, ctx, decision, 'sent')
764 }
765 // The choice is made, so the window has no reason to stay. The pause lets it
766 // show that Claude got the choice. New variants open the window again.
767 if (closeTimer !== null) closeTimer.cancel()
768 closeTimer = $.clock.after(CLOSE_AFTER_CHOICE_MS, () => void closeAfterChoice($, ctx))
769}
770
771async function closeAfterChoice($, ctx) {
772 closeTimer = null
773 if (active === null || active.root !== ctx.root) return
774 try {
775 await closeWindow($, ctx)
776 } catch (error) {
777 await logError($, ctx, `closing the window after the choice: ${message(error)}`)
778 }
779}
780
781async function checkDecision($, ctx) {
782 const path = `${ctx.stateDir}/decision.json`
783 if (!(await $.fs.exists(path))) return
784 const stat = await $.fs.stat(path).catch(() => undefined)
785 if (stat === undefined || stat.mtimeMs === decisionMtime) return
786 const decision = parseDecision(await $.fs.read(path).catch(() => ''))
787 if (decision === undefined) return
788 decisionMtime = stat.mtimeMs
789 await submitRevisions($, ctx, decision)
790 const choice = choiceSignature(decision)
791 const notes = notesSignature(decision)
792 if (choice !== undefined && choice !== seen.choice) {
793 seen = { choice, notes }
794 await $.store.set(seenKey(ctx.root, ctx.sessionId), seen)
795 await submitChoice($, ctx, decision)
796 return
797 }
798 if (notes !== seen.notes) {
799 seen = { choice: seen.choice, notes }
800 await $.store.set(seenKey(ctx.root, ctx.sessionId), seen)
801 const last = decision.notes[decision.notes.length - 1]
802 if (last !== undefined) $.ui.toast(`Note saved: ${noteLine(last, 100)}`)
803 }
804}
805
806async function tick($) {
807 const ctx = active
808 if (ctx === null || isTicking) return
809 isTicking = true
810 try {
811 await beat($, ctx)
812 await trackLaunch($, ctx)
813 await checkRestart($, ctx)
814 await checkDecision($, ctx)
815 } catch (error) {
816 await logError($, ctx, `watching: ${message(error)}`).catch(() => undefined)
817 } finally {
818 isTicking = false
819 }
820}
821
822// ---------- Hooks ----------
823
824/** @type {import('claude-code').Register} */
825export const register = on => {
826 on('session.start', async ($, e, next) => {
827 // After a mod reload the old timers are cancelled and activity is restored
828 // if the skill was already loaded in this session.
829 stopWatch()
830 resetState()
831 const started = await next(e)
832 await restore($).catch(() => undefined)
833 return started
834 })
835
836 on('session.end', async ($, e, next) => {
837 if (active === null) return next(e)
838 const ctx = active
839 stopWatch()
840 try {
841 const now = await $.clock.now()
842 await $.store.delete(aliveKey(ctx.root, ctx.sessionId))
843 const owner = await $.store.get(ownerKey(ctx.root))
844 if (owner !== null && typeof owner === 'object' && owner.sessionId === ctx.sessionId) {
845 await $.store.delete(ownerKey(ctx.root))
846 }
847 if (e.reason === 'clear') await $.store.delete(activeKey(ctx.root, ctx.sessionId))
848 // The window closes with the session (and on /clear) unless another live
849 // session watches the project: then the window stays for it.
850 if (!(await hasOtherWatchers($, ctx, now))) await closeWindow($, ctx)
851 } catch {
852 // The session is ending anyway: errors here do not matter.
853 }
854 resetState()
855 return next(e)
856 })
857
858 on('skill.prompt', { skill: ['ui-variants', 'ui-variants:ui-variants'] }, async ($, e, next) => {
859 const expanded = await next(e)
860 try {
861 return { text: await answerSkill($, expanded.text) }
862 } catch (error) {
863 $.ui.log(`ui-variants: ${message(error)}`, { to: 'debug' })
864 return {
865 text: `${expanded.text}\n\nIMPORTANT: the picker window is not available right now (${message(error)}). ` +
866 'Describe the variants in text in your reply.',
867 }
868 }
869 })
870
871 on('tool.call', { tool: ['Write', 'Edit', 'NotebookEdit'] }, async ($, e, next) => {
872 if (active === null) return next(e)
873 const ctx = active
874 const result = await next(e)
875 if (result.deny === undefined && result.isError !== true && touchesHost(e, ctx.appDir)) {
876 needsRefresh = true
877 }
878 return result
879 }).catch(($, e, next) => next(e))
880
881 on('turn.complete', async ($, e, next) => {
882 if (active === null || e.agentId !== undefined) return next(e)
883 const fingerprint = await hostFingerprint($, active).catch(() => lastFingerprint)
884 if (!needsRefresh && fingerprint === lastFingerprint) return next(e)
885 needsRefresh = false
886 lastFingerprint = fingerprint
887 $.clock.after(0, () => void refreshWindow($))
888 startWatch($)
889 return next(e)
890 })
891}
892hooks/util.js 276 lines1// Pure functions of the ui-variants mod: no `$`, no side effects.
2
3/** The skill name in the event: `ui-variants` or with the plugin prefix. */
4export const SKILL_NAMES = ['ui-variants', 'ui-variants:ui-variants']
5
6/** The skill line the mod reads arguments from ($ARGUMENTS already substituted). */
7const ARGS_LINE = /^Show variants for:[ \t]*(.*)$/m
8
9/** Skill arguments: the text after "Show variants for:", trimmed. */
10export function parseArgs(text) {
11 const found = ARGS_LINE.exec(text)
12 return found === null ? '' : found[1].trim()
13}
14
15/** A top-level YAML block (`dependencies:` and so on): the lines up to the next key. */
16export function yamlBlock(yaml, key) {
17 const lines = yaml.split(/\r?\n/)
18 const start = lines.findIndex(line => new RegExp(`^${key}:\\s*(#.*)?$`).test(line))
19 if (start < 0) return ''
20 const body = []
21 for (const line of lines.slice(start + 1)) {
22 if (/^\S/.test(line) && !line.startsWith('#')) break
23 body.push(line)
24 }
25 return body.join('\n')
26}
27
28/** Whether pubspec.yaml depends on `flutter: { sdk: flutter }`. */
29export function isFlutterPubspec(yaml) {
30 const deps = yamlBlock(yaml, 'dependencies')
31 return /^\s+flutter:\s*\n\s+sdk:\s*['"]?flutter['"]?\s*(#.*)?$/m.test(deps) ||
32 /^\s+flutter:\s*\{\s*sdk:\s*['"]?flutter['"]?\s*\}/m.test(deps)
33}
34
35/** The `name:` field of pubspec.yaml, or undefined. */
36export function pubspecName(yaml) {
37 const found = /^name:\s*['"]?([a-z_][a-z0-9_]*)['"]?\s*(#.*)?$/m.exec(yaml)
38 return found === null ? undefined : found[1]
39}
40
41/** The `environment.sdk` constraint of pubspec.yaml as a string, or undefined. */
42export function pubspecSdk(yaml) {
43 const env = yamlBlock(yaml, 'environment')
44 const found = /^\s+sdk:\s*(.+?)\s*(#.*)?$/m.exec(env)
45 if (found === null) return undefined
46 return found[1].replace(/^['"]|['"]$/g, '').trim()
47}
48
49/** An x.y.z version as an array of numbers (a pre-release is dropped). */
50function versionParts(text) {
51 const found = /^(\d+)\.(\d+)\.(\d+)/.exec(text)
52 return found === null ? undefined : [Number(found[1]), Number(found[2]), Number(found[3])]
53}
54
55/** Compares x.y.z versions: -1, 0 or 1. */
56export function compareVersions(a, b) {
57 const left = versionParts(String(a)) ?? [0, 0, 0]
58 const right = versionParts(String(b)) ?? [0, 0, 0]
59 for (let i = 0; i < 3; i += 1) {
60 if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1
61 }
62 return 0
63}
64
65/** An SDK constraint as { min, max } (version strings; a missing bound is undefined). */
66export function parseConstraint(text) {
67 const value = String(text ?? '').trim()
68 if (value === '' || value === 'any') return { min: undefined, max: undefined }
69 const caret = /^\^(\d+\.\d+\.\d+\S*)$/.exec(value)
70 if (caret !== null) {
71 const [major, minor] = versionParts(caret[1])
72 const max = major > 0 ? `${major + 1}.0.0` : `0.${minor + 1}.0`
73 return { min: caret[1], max }
74 }
75 let min
76 let max
77 for (const token of value.split(/\s+/)) {
78 const found = /^(>=|>|<=|<)(\d+\.\d+\.\d+\S*)$/.exec(token)
79 if (found === null) return undefined
80 if (found[1].startsWith('>')) min = found[2]
81 else max = found[2]
82 }
83 return { min, max }
84}
85
86/**
87 * The wider of two SDK constraints: the lower of the lower bounds and the higher
88 * of the upper ones. If either does not parse, the project's constraint is used.
89 */
90export function widerSdk(host, project) {
91 if (project === undefined) return host
92 if (host === undefined) return project
93 const a = parseConstraint(host)
94 const b = parseConstraint(project)
95 if (a === undefined || b === undefined) return project
96 const min = a.min === undefined || b.min === undefined
97 ? undefined
98 : compareVersions(a.min, b.min) <= 0 ? a.min : b.min
99 const max = a.max === undefined || b.max === undefined
100 ? undefined
101 : compareVersions(a.max, b.max) >= 0 ? a.max : b.max
102 const parts = []
103 if (min !== undefined) parts.push(`>=${min}`)
104 if (max !== undefined) parts.push(`<${max}`)
105 return parts.length === 0 ? 'any' : parts.join(' ')
106}
107
108/** A short hash of a string (FNV-1a, 32 bits, 8 hex digits). */
109export function shortHash(text) {
110 let hash = 0x811c9dc5
111 for (const char of new TextEncoder().encode(text)) {
112 hash ^= char
113 hash = Math.imul(hash, 0x01000193) >>> 0
114 }
115 return hash.toString(16).padStart(8, '0')
116}
117
118/** A string in YAML single quotes. */
119export function yamlQuote(text) {
120 return `'${String(text).replaceAll("'", "''")}'`
121}
122
123/** Fills values into {{NAME}} placeholders. */
124export function fillPlaceholders(text, values) {
125 return text.replace(/\{\{([A-Z_]+)\}\}/g, (whole, name) =>
126 Object.prototype.hasOwnProperty.call(values, name) ? values[name] : whole)
127}
128
129/** Turns App Sandbox off in an .entitlements file. */
130export function withoutSandbox(plist) {
131 return plist.replace(
132 /(<key>com\.apple\.security\.app-sandbox<\/key>\s*)<true\s*\/>/,
133 '$1<false/>',
134 )
135}
136
137/** The decision from decision.json, or undefined if the JSON is broken or of the wrong shape. */
138export function parseDecision(text) {
139 let value
140 try {
141 value = JSON.parse(text)
142 } catch {
143 return undefined
144 }
145 if (value === null || typeof value !== 'object') return undefined
146 const notes = Array.isArray(value.notes) ? value.notes.filter(isNote) : []
147 const choices = Array.isArray(value.choices) ? value.choices.filter(isChoice).map(choice => ({
148 group: typeof choice.group === 'string' ? choice.group : '',
149 variantId: choice.variantId,
150 title: typeof choice.title === 'string' ? choice.title : choice.variantId,
151 })) : []
152 const requests = Array.isArray(value.requests) ? value.requests.filter(isRequest).map(request => ({
153 id: request.id,
154 variantId: typeof request.variantId === 'string' ? request.variantId : '',
155 title: typeof request.title === 'string' ? request.title : '',
156 target: typeof request.target === 'string' ? request.target : '',
157 text: request.text,
158 })) : []
159 return {
160 round: typeof value.round === 'string' ? value.round : '',
161 requests,
162 feature: typeof value.feature === 'string' ? value.feature : '',
163 chosen: typeof value.chosen === 'string' && value.chosen !== '' ? value.chosen : undefined,
164 chosenTitle: typeof value.chosenTitle === 'string' ? value.chosenTitle : undefined,
165 chosenAt: typeof value.chosenAt === 'string' ? value.chosenAt : '',
166 choices,
167 notes,
168 }
169}
170
171function isRequest(request) {
172 return request !== null && typeof request === 'object' &&
173 typeof request.id === 'string' && typeof request.text === 'string'
174}
175
176function isChoice(choice) {
177 return choice !== null && typeof choice === 'object' && typeof choice.variantId === 'string'
178}
179
180/** Choices per question: from choices, or a single chosen in the older decision format. */
181export function decisionChoices(decision) {
182 if (decision.choices.length > 0) return decision.choices
183 return decision.chosen === undefined
184 ? []
185 : [{ group: '', variantId: decision.chosen, title: decision.chosenTitle ?? decision.chosen }]
186}
187
188function isNote(note) {
189 return note !== null && typeof note === 'object' && typeof note.text === 'string'
190}
191
192/** The choice signature: changes on every press of Choose. */
193export function choiceSignature(decision) {
194 if (decision === undefined || decision.chosen === undefined) return undefined
195 return `${decision.chosen}|${decision.chosenAt}`
196}
197
198/**
199 * The send signature: the round, the choices and the notes. Pressing Choose
200 * again on the same variant without new notes gives the same signature.
201 */
202export function sendSignature(decision) {
203 if (decision === undefined || decision.chosen === undefined) return undefined
204 const choices = decisionChoices(decision).map(choice => `${choice.group}=${choice.variantId}`).join(';')
205 return `${decision.round}|${choices}|${notesSignature(decision)}`
206}
207
208/** The notes signature: their count and the time of the last one. */
209export function notesSignature(decision) {
210 if (decision === undefined || decision.notes.length === 0) return ''
211 const last = decision.notes[decision.notes.length - 1]
212 return `${decision.notes.length}|${last.at ?? ''}`
213}
214
215function clip(text, limit) {
216 const flat = String(text).replace(/\s+/g, ' ').trim()
217 return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat
218}
219
220/** One note line: "[variant → element] text". */
221export function noteLine(note, limit = 160) {
222 const where = [note.variantId, note.target].filter(part => typeof part === 'string' && part !== '')
223 const head = where.length === 0 ? '' : `[${where.join(' → ')}] `
224 return `${head}${clip(note.text, limit)}`
225}
226
227/** The text of the message that starts Claude's next turn. */
228export function choicePrompt(decision, decisionPath) {
229 const notes = decision.notes
230 const shown = notes.slice(0, 6).map(note => noteLine(note)).join('; ')
231 const more = notes.length > 6 ? `; and ${notes.length - 6} more` : ''
232 const comments = notes.length === 0 ? 'none' : `${shown}${more}`
233 const feature = decision.feature === '' ? 'the current task' : `"${decision.feature}"`
234 const choices = decisionChoices(decision)
235 if (choices.length > 1) {
236 const list = choices.map(choice => `${choice.group} — ${choice.variantId}`).join('; ')
237 return `Chosen variants for ${feature}: ${list}. ` +
238 `Notes: ${comments}. Full details: ${decisionPath}. ` +
239 'Implement the chosen variants in the project.'
240 }
241 return `Chosen variant ${decision.chosen} for ${feature}. ` +
242 `Notes: ${comments}. Full details: ${decisionPath}. ` +
243 'Implement the chosen variant in the project.'
244}
245
246/** The last non-empty line of a command's output (a short failure reason). */
247export function lastLine(text, limit = 200) {
248 const lines = String(text ?? '').split(/\r?\n/).map(line => line.trim()).filter(Boolean)
249 return lines.length === 0 ? '' : clip(lines[lines.length - 1], limit)
250}
251
252/** The first error line in flutter output (compile, build). */
253export function flutterError(text, limit = 200) {
254 const lines = String(text ?? '').split(/\r?\n/)
255 const found = lines.find(line =>
256 /(^|\s)Error:|error:|Compilation failed|Hot restart failed|Restart failed|Exception:|Could not build|Build failed/i.test(line))
257 return found === undefined ? undefined : clip(found, limit)
258}
259
260/**
261 * The message with variant revisions from the window: Claude changes only those
262 * variants, the window refreshes by itself, no choice has been made yet.
263 */
264export function revisionPrompt(requests, variantsDir) {
265 const line = request => {
266 const name = request.title === '' ? request.variantId : `${request.variantId} ("${request.title}")`
267 const where = request.target === '' ? '' : `, element "${clip(request.target, 80)}"`
268 return `${name}${where}: ${clip(request.text, 400)}`
269 }
270 const head = requests.length === 1
271 ? `Variant revision from the picker window — ${line(requests[0])}.`
272 : `Variant revisions from the picker window: ${requests.map((request, i) => `${i + 1}) ${line(request)}`).join('; ')}.`
273 return `${head} Change only ${requests.length === 1 ? 'this variant' : 'these variants'} in ${variantsDir}: ` +
274 'keep the id, the class and the registration, and do not touch the project. No choice has been made yet: after the revision reply briefly and end the turn, the window refreshes by itself.'
275}
276