SLOPSHOPPER

ui-variants

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…

newguardpromptprocesstimer
★ 2v0.4.1MITupdated 2026-10-10DemeoStep/ui-variants
A shopper browsing a rack in a slop shop
README

ui-variants

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.

Three layout variants of a "Today" screen side by side in iPhone 17 frames

What the window does

  • Side-by-side comparison. All variants of the current question are visible at once, each in the frame of the device you pick. The menu lists iOS and Android phones and tablets, including iPhone 17, 17 Pro, 17 Pro Max, Air and 16e, plus a "no frame" mode. Landscape and dark theme are one click away.
  • Several questions at once. When a show has independent things to choose, such as a layout and a color, you mark a variant in each question and send everything in one message.
  • Revisions on the fly. "Revise variant" sends a request to Claude right away: "make the button blue", "increase the padding", "swap the blocks". Claude changes only that variant, and the window refreshes with a hot restart.
  • Notes for the choice. A note on a variant, or a general note, reaches Claude together with your choice.
  • The window does not linger. It closes after you choose and when the session ends. If the session dies, the window closes itself in about a minute.
Several questionsDevice frames
The Colors question with two color variantsThe device menu with the newest iPhones
Ask for a revisionClaude is revising
The revise dialog on a variantA card shows that Claude is revising the variant

Requirements

  • macOS with Xcode and the Flutter SDK. If the project has .fvm/flutter_sdk, that Flutter is used.
  • Claude Code 2.1.287 or newer: the mod is built on the mods API (a hooks module).

Install

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

Recommended settings

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/**)"]
  }
}

Usage

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.

How it works

  1. The skill skills/ui-variants/SKILL.md tells Claude when and how to show variants. Only its description stays in context, about 90 tokens.
  2. The mod 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.
  3. The window. When a Claude turn changes the variants, the mod starts flutter run -d macos or hot restarts the running window.
  4. Feedback. The window writes choices, notes and revisions to .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.

Limitations

  • macOS only. On Linux and Windows the skill tells Claude to describe the variants in text instead. See Porting to Linux or Windows.
  • Project plugins without macOS support do not work in the window.
  • The window does not open in claude -p: the process exits before the mod can start it.

Porting to Linux or Windows

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:

WhatWheremacOS nowLinuxWindows
OS checkisMacosuname -s is Darwinaccept LinuxOS is Windows_NT
Temporary app folderhostPaths~/Library/Caches/ui-variants$XDG_CACHE_HOME/ui-variants or ~/.cache/ui-variants%LOCALAPPDATA%\ui-variants; HOME is usually not set
App creationprepareHost, disableSandboxflutter create --platforms=macos, sandbox off in the entitlements--platforms=linux--platforms=windows
Window title, raising the windowtemplates/macos/Runner/MainFlutterWindow.swift.tmpl, lib/_host/window.dartSwift and the ui_variants/window channellinux/runner/my_application.ccwindows/runner/main.cpp
Built app, found by its path to close itappBundlebuild/macos/Build/Products/Debug/ui_variants_host.appbuild/linux/<arch>/debug/bundle/ui_variants_hostbuild\windows\x64\runner\Debug\ui_variants_host.exe
Detached launchLAUNCH_SCRIPTsh, nohup, perl with setsid, flutter run -d macos-d linux; setsid from util-linux if perl is missingno sh or perl
Hot restarthotRestartkill -USR2 to flutter runthe sameno signals
Closing the windowSTOP_SCRIPT, isAlive, windowPidps, kill, pkillthe sametasklist, taskkill
Reading the flutter run loglogTailtail -cthe sameread it with $.fs
Deleting filesprepareHost, clean in answerSkillrm -rfthe sameno rm
Finding FlutterfindFlutter, searchFlutterwhich, the login shell, Homebrew pathsadd the snap pathwhere flutter, flutter.bat
ToolchainXcodeclang, cmake, ninja-build, pkg-config, libgtk-3-devVisual 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.

Development

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.

License

MIT © 2026 Oleksii Horiainov

Source 2 files
hooks/register.js 892 lines
1// 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}
892
hooks/util.js 276 lines
1// 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