SLOPSHOPPER

xcode-mods

Xcode in Claude Code: build, run and tests pane, scheme/destination band, notifications and SwiftUI previews over headless Xcode MCP

newpanebandguardcommandtoast
★ 17v0.1.0MITupdated 2026-10-02artemnovichkov/xcode-mods/mods/xcode-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xcode-mods
│ ┃ Xcode ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Build │ 2: Run │ 3: Tests │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ ▶ Run (r) ] ⎿ Read 6 lines │ ┃ [ Errors only (e) ] [ Clear (k) ] ⏺ Update(src/auth.ts) │ ┃ Not running. Press r or /run to build and ⎿ Added 2 lines, removed 1 line │ ┃ run the app. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /build │ ⎿ xcode-mods: Building… │ │ undefined is not an object (evaluating 'res.content.find') ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
undefined is not an object (evaluating 'res.content.find')
Pane · Xcode
1: Build │ 2: Run │ 3: Tests [ ▶ Run (r) ] [ Errors only (e) ] [ Clear (k) ] Not running. Press r or /run to build and run the app.
Pane · Canvas
No preview yet. /preview [file.swift] undefined is not an object (evaluating 'res.content.find')
README

xcode-mods

Xcode's build, tests, console and SwiftUI previews inside Claude Code, built on the headless Xcode MCP server.

Claude fixes a build error while the Xcode pane shows the result

One Claude Code plugin that adds:

  • a toolbar band above the prompt (scheme ▸ destination, activity, problem counters);
  • an Xcode pane with Build · Run · Tests tabs;
  • a Canvas pane that shows SwiftUI #Preview snapshots.

It picks up Claude's own Xcode tool calls too. When Claude builds, runs tests, launches the app or renders a preview, the result appears in the pane.

Requirements

  • Xcode 27 or later with the MCP server in headless mode
  • Claude Code 2.1.286 or later (mods support)
  • To see previews in the terminal: a terminal with the kitty graphics protocol, such as Ghostty. The Claude desktop app works too.

Setup

  1. Turn on Xcode's headless MCP server (once):
   sudo xcrun mcp-server enable
   xcrun mcp-server status
  1. Add the server to Claude Code. The mods expect it to be named xcode:
   claude mcp add --scope user xcode -- xcrun mcpbridge
  1. Allow the mods' Xcode calls in ~/.claude/settings.json. Without this rule, every build or refresh asks for permission:
   {
     "permissions": {
       "allow": ["mcp__xcode"]
     }
   }
  1. Install the plugin:
   /plugin marketplace add artemnovichkov/xcode-mods
   /plugin install xcode-mods@xcode-mods

Open Claude Code in a folder with an .xcodeproj or .xcworkspace (up to 3 levels deep). The band appears above the prompt. In other folders the plugin stays hidden and doesn't register any commands.

Usage

CommandWhat it does
/xcodeOpen the Xcode pane
/buildBuild the active scheme
/tests [filter]Run all tests, or only those whose Target/identifier contains the filter
/runBuild and run on the active destination, then stream the console
/stopStop the running app
/preview [file.swift]Render a #Preview into the Canvas pane. Without an argument, uses ContentView.swift, or the first file with #Preview.

Toolbar band

The band above the prompt works like Xcode's toolbar:

 Sandbox ▸ iPhone 17 Pro │ ✓ Build Succeeded 1.4s · 14:02 │ ⚠1 ✗2
  • Scheme ▸ destination. Pickers; a change switches them in Xcode too.
  • Activity. What runs now (◐ Building 3s · Compile ContentView.swift, ◐ Testing, ◐ Launching, ● Running Sandbox), or the last result with its time (✓ Build Succeeded, ✗ Tests Failed, ■ Sandbox stopped).
  • Counters. ⛔ errors, ⚠ warnings, ✗ failed tests. Click one to open the matching tab.

The band also shows Claude's own builds, test runs and launches.

Notifications

Toast after a test run

A toast pops up in the top right corner when an action ends, whether you or Claude started it:

  • Build Succeeded · 2 warnings, Build Failed · 3 errors
  • Tests Passed · 12, Tests Failed · 2 failed
  • Run Failed
  • Preview ready: /preview to show, when the window is too narrow to open the Canvas pane by itself

Build & Issues

Build tab

Live progress while the build runs, then errors and warnings. Click a file name to open that line in Xcode.

Keys: b build · x send the issues to Claude to fix

Tests

Tests tab

Tests are grouped by target and suite, with failures first and their messages shown inline. Click a test to run only that test.

Keys: t run all · f rerun failed · l reload the list · x ask Claude to fix the failures

Run & Console

Run tab

The app's stdout and OSLog stream in while it runs.

Keys: r run · s stop · e errors only · k clear · x ask Claude about the console

SwiftUI Preview

Canvas pane

Keeps the last 10 snapshots. Once you have rendered a preview, it re-renders after Claude edits a Swift file that contains #Preview.

Keys: r re-render · h/l previous/next · o open the PNG · x ask Claude to review the layout

Switch tabs with 1 2 3.

Troubleshooting

  • The band says Xcode MCP server "xcode" not connected. Run /mcp and check that the xcode server is connected. Check that xcrun mcp-server status reports it as enabled.
  • Every action asks for permission. Add the mcp__xcode allow rule from Setup.
  • The first preview takes a minute or more. Xcode builds the preview host on the first render. Later renders are fast.
  • The preview always renders on the same simulator. Xcode picks the preview device itself, and the MCP server doesn't let you choose it yet.
  • No image in the Canvas pane. Your terminal doesn't support the kitty graphics protocol. The pane shows the PNG path instead; press o to open it.

Development

mods/xcode-mods/hooks/register.tsx   # entry: wires both modules
mods/xcode-mods/hooks/build.tsx      # toolbar band + Build · Run · Tests pane
mods/xcode-mods/hooks/canvas.tsx     # SwiftUI preview pane
Sandbox/                             # test iOS app (XcodeGen: project.yml)
.mcp.json                            # xcode MCP server for this repo

To run the plugin from source with hot reload:

claude --plugin-dir mods/xcode-mods

Claude Code writes the mod API types into mods/xcode-mods/.claude-plugin/types/ (git-ignored) when it loads the plugin with --plugin-dir, so start a session that way once before type-checking with tsc -p mods/xcode-mods.

To check and test the plugin:

claude plugin validate mods/xcode-mods
claude plugin test mods/xcode-mods

Sandbox is a SwiftUI counter app with Swift Testing tests. Playground.swift holds an intentional warning; edit it to break the build. After editing project.yml, regenerate the project with cd Sandbox && xcodegen.

Author

Artem Novichkov, https://artemnovichkov.com/

License

The project is available under the MIT license. See the LICENSE file for more info.

Source 4 files
hooks/register.tsx 10 lines
1import type { Register } from 'claude-code'
2import { register as registerBuild } from './build'
3import { register as registerCanvas } from './canvas'
4
5// Build · Run · Tests pane + band, and the Canvas pane: one plugin, one module.
6export const register: Register = (on, options) => {
7  registerBuild(on, options)
8  registerCanvas(on, options)
9}
10
hooks/build.tsx 1033 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { XcodeBuild, XcodeConsoleLine, XcodeIssue, XcodeRun, XcodeTab, XcodeTarget, XcodeTest, XcodeTests, XcodeTestState } from '../types'
5
6const SERVER = 'xcode'
7const PANE = 'xcode'
8const POLL_MS = 500
9const CONSOLE_MS = 1000
10const CONSOLE_TAIL = 300
11
12const IDLE: XcodeBuild = { status: 'idle', startedAt: 0, tasks: 0, issues: [] }
13const NO_RUN: XcodeRun = { status: 'idle', lines: [], total: 0, clearedAt: 0, isErrorsOnly: false }
14const NO_TESTS: XcodeTests = { status: 'idle', startedAt: 0, tests: [] }
15
16const build = atom({ plugin: 'xcode-mods', key: 'build' } as const, IDLE)
17const target = atom({ plugin: 'xcode-mods', key: 'target' } as const, null)
18const tab = atom({ plugin: 'xcode-mods', key: 'tab' } as const, 'build')
19const tests = atom({ plugin: 'xcode-mods', key: 'tests' } as const, NO_TESTS)
20const run = atom({ plugin: 'xcode-mods', key: 'run' } as const, NO_RUN)
21const error = atom({ plugin: 'xcode-mods', key: 'error' } as const, null)
22
23type BuildLog = {
24  buildIsRunning?: boolean
25  buildResult?: string
26  totalFound?: number
27  buildLogEntries?: { buildTask?: string; emittedIssues: XcodeIssue[] }[]
28}
29
30type BuildResult = {
31  buildResult?: string
32  elapsedTime?: number
33  errors?: { classification: string; message: string; filePath?: string; lineNumber?: number }[]
34}
35
36type TestList = {
37  truncated?: boolean
38  fullTestListPath?: string
39  tests?: { targetName: string; identifier: string; displayName: string; isEnabled: boolean }[]
40}
41
42type TestRun = {
43  summary?: string
44  counts?: { failed: number }
45  results?: { targetName: string; identifier: string; displayName: string; state: string; errorMessages: string[] }[]
46}
47
48type RunResult = {
49  runResult?: string
50  buildErrors?: { classification: string; message: string }[]
51  launchSessionReference?: string
52  processIdentifier?: number
53}
54
55type ConsoleOutput = {
56  launchSessionInfo?: string
57  totalCount?: number
58  units?: { content: string; kind: string; severity?: string; timestamp: number }[]
59}
60
61type TestSpec = { targetName: string; testIdentifier: string }
62
63async function call<T>($: EngineInterface, tool: string, args: Record<string, unknown> = {}, isRetry = false): Promise<T> {
64  const res = await $.mcp.call(SERVER, tool, args)
65  if (res.isError) {
66    const msg = res.content.map(c => c.text ?? '').join(' ') || `${tool} failed`
67    // A restarted mcpbridge forgets workspace ids: resolve a fresh one and retry once.
68    if (!isRetry && typeof args.workspaceIdentifier === 'string' && /unknown workspace/i.test(msg)) {
69      const t = await refreshTarget($)
70      if (t) return call<T>($, tool, { ...args, workspaceIdentifier: t.workspace }, true)
71    }
72    throw new Error(msg)
73  }
74  if (res.structuredContent) return res.structuredContent as T
75  const text = res.content.find(c => c.type === 'text')?.text ?? '{}'
76  return JSON.parse(text) as T
77}
78
79const contains = (dir: string, path: string) => path === dir || path.startsWith(`${dir}/`)
80const dirOf = (path: string) => path.replace(/\/[^/]+$/, '')
81
82async function findProject($: EngineInterface, cwd: string): Promise<string | null> {
83  const found = await $.process.run([
84    'find', cwd, '-maxdepth', '3', '(', '-name', '*.xcworkspace', '-o', '-name', '*.xcodeproj', ')',
85    '-not', '-path', '*/.build/*', '-not', '-path', '*.xcodeproj/*', '-not', '-path', '*/Pods/*',
86  ])
87  // Shallowest first; at the same depth a workspace wraps its project (CocoaPods).
88  const rank = (p: string) => p.split('/').length * 2 - (p.endsWith('.xcworkspace') ? 1 : 0)
89  return found.stdout.split('\n').filter(Boolean).sort((a, b) => rank(a) - rank(b))[0] ?? null
90}
91
92// Workspace id comes from XcodeListWorkspaces (abs path not accepted). Only a workspace around or under cwd counts;
93// otherwise opens the project found under cwd. Null: not an Xcode project, stay quiet.
94async function resolveWorkspace($: EngineInterface): Promise<{ id: string; path: string } | null> {
95  const cwd = await $.session.cwd()
96  const local = await findProject($, cwd)
97  let open: { id: string; path: string }[]
98  try {
99    const { message } = await call<{ message: string }>($, 'XcodeListWorkspaces')
100    open = [...message.matchAll(/workspaceIdentifier: (\S+), workspacePath: (.+)/g)].map(m => ({
101      id: (m[1] ?? '').replace(/,$/, ''),
102      path: (m[2] ?? '').trim(),
103    }))
104  } catch (err) {
105    if (!local) return null
106    throw err
107  }
108  const mine =
109    open.find(w => w.path === local) ??
110    open.find(w => contains(cwd, w.path)) ??
111    open.find(w => contains(dirOf(w.path), cwd))
112  if (mine) return mine
113  if (!local) return null
114  const opened = await call<{ workspaceIdentifier: string }>($, 'XcodeOpenWorkspace', { path: local })
115  return { id: opened.workspaceIdentifier, path: local }
116}
117
118const CONNECT_TRIES = 20
119const CONNECT_MS = 1500
120
121const isNotConnected = (msg: string | null) => msg !== null && /no connected MCP/i.test(msg)
122
123async function refreshTarget($: EngineInterface): Promise<XcodeTarget | null> {
124  try {
125    const ws = await resolveWorkspace($)
126    if (!ws) {
127      await update($, target, () => null)
128      await update($, error, () => null)
129      return null
130    }
131    const [schemes, dests] = await Promise.all([
132      call<{ activeSchemeName?: string; schemes?: { disambiguatedName: string }[] }>($, 'XcodeListSchemes', {
133        workspaceIdentifier: ws.id,
134      }),
135      call<{ activeDestinationDisplayTitle?: string; destinations?: { displayTitle: string }[] }>(
136        $,
137        'XcodeListRunDestinations',
138        { workspaceIdentifier: ws.id },
139        true,
140      ),
141    ])
142    const next: XcodeTarget = {
143      workspace: ws.id,
144      path: ws.path,
145      scheme: schemes.activeSchemeName ?? undefined,
146      destination: dests.activeDestinationDisplayTitle ?? undefined,
147      schemes: (schemes.schemes ?? []).map(s => s.disambiguatedName),
148      destinations: (dests.destinations ?? []).map(d => d.displayTitle),
149    }
150    await update($, target, () => next)
151    await update($, error, () => null)
152    return next
153  } catch (err) {
154    await update($, error, () => String((err as Error).message ?? err))
155    return null
156  }
157}
158
159// Errors land in the band; refreshTarget clears them on success.
160async function switchTo($: EngineInterface, tool: string, args: Record<string, unknown>) {
161  try {
162    await call($, tool, args)
163    await refreshTarget($)
164  } catch (err) {
165    await update($, error, () => `Switch failed: ${String((err as Error).message ?? err)}`)
166  }
167}
168
169// A command typed before the xcode server connects waits for it.
170async function workspaceId($: EngineInterface): Promise<string | null> {
171  const known = await read($, target)
172  if (known) return known.workspace
173  for (let attempt = 1; ; attempt++) {
174    const t = await refreshTarget($)
175    if (t || attempt >= CONNECT_TRIES || !isNotConnected(await read($, error))) return t?.workspace ?? null
176    await new Promise<void>(resolve => $.clock.after(CONNECT_MS, resolve))
177  }
178}
179
180const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
181
182function issuesOf(log: BuildLog): XcodeIssue[] {
183  return (log.buildLogEntries ?? [])
184    .flatMap(entry => entry.emittedIssues)
185    .filter(i => i.severity === 'error' || i.severity === 'warning')
186}
187
188let poll: { cancel: () => void } | null = null
189let isPolling = false
190
191async function pollOnce($: EngineInterface, ws: string) {
192  if (isPolling) return
193  isPolling = true
194  try {
195    const log = await call<BuildLog>($, 'GetBuildLog', {
196      workspaceIdentifier: ws,
197      severity: 'remark',
198      pattern: '.',
199    })
200    if (!log.buildIsRunning) return
201    const entries = log.buildLogEntries ?? []
202    await update($, build, b =>
203      b.status === 'running'
204        ? { ...b, task: entries.at(-1)?.buildTask, tasks: log.totalFound ?? entries.length, issues: issuesOf(log) }
205        : b,
206    )
207  } catch {
208    // build log not ready yet
209  } finally {
210    isPolling = false
211  }
212}
213
214async function start($: EngineInterface, ws: string) {
215  const now = await $.clock.now()
216  await update($, build, (): XcodeBuild => ({ status: 'running', startedAt: now, tasks: 0, issues: [] }))
217  poll?.cancel()
218  poll = $.clock.every(POLL_MS, () => void pollOnce($, ws))
219}
220
221async function finish($: EngineInterface, ws: string, result: BuildResult | null, failure?: string) {
222  poll?.cancel()
223  poll = null
224  let issues: XcodeIssue[] = []
225  let tasks: number | undefined
226  try {
227    const log = await call<BuildLog>($, 'GetBuildLog', { workspaceIdentifier: ws, severity: 'remark', pattern: '.' })
228    issues = issuesOf(log)
229    tasks = log.totalFound ?? log.buildLogEntries?.length
230  } catch {
231    issues = (result?.errors ?? []).map(e => ({
232      severity: e.classification,
233      message: e.message,
234      path: e.filePath,
235      line: e.lineNumber,
236    }))
237  }
238  const errors = issues.filter(i => i.severity === 'error').length
239  const isOk = !failure && errors === 0 && !/fail/i.test(result?.buildResult ?? '')
240  const now = await $.clock.now()
241  await update($, build, (b): XcodeBuild => ({
242    ...b,
243    status: isOk ? 'succeeded' : 'failed',
244    finishedAt: now,
245    elapsed: result?.elapsedTime ?? (now - b.startedAt) / 1000,
246    task: undefined,
247    tasks: tasks ?? b.tasks,
248    issues,
249    result: failure ?? result?.buildResult,
250  }))
251  const warnings = issues.length - errors
252  $.ui.toast(
253    isOk
254      ? `Build Succeeded${warnings ? ` · ${warnings} warning${warnings > 1 ? 's' : ''}` : ''}`
255      : `Build Failed · ${errors} error${errors === 1 ? '' : 's'}`,
256  )
257  if (!isOk) await update($, tab, () => 'build')
258}
259
260async function runBuild($: EngineInterface): Promise<string> {
261  if ((await read($, build)).status === 'running') return 'Build already running.'
262  const ws = await workspaceId($)
263  if (!ws) return 'No Xcode workspace.'
264  await start($, ws)
265  try {
266    const result = await call<BuildResult>($, 'BuildProject', { workspaceIdentifier: ws })
267    await finish($, ws, result)
268    return result.buildResult ?? 'Build finished.'
269  } catch (err) {
270    const msg = String((err as Error).message ?? err)
271    await finish($, ws, null, msg)
272    return msg
273  }
274}
275
276const testKey = (t: { target: string; id: string }) => `${t.target}/${t.id}`
277
278function stateOf(state: string): XcodeTestState {
279  if (/expected/i.test(state) || /pass/i.test(state)) return 'passed'
280  if (/fail/i.test(state)) return 'failed'
281  return 'skipped'
282}
283
284// GetTestList caps `tests` at 100; the full list is a TEST_TARGET/TEST_IDENTIFIER/... text file.
285function parseTestFile(text: string): XcodeTest[] {
286  return text.split(/^-{20,}$/m).flatMap(block => {
287    const field = (k: string) => new RegExp(`^${k}: (.*)$`, 'm').exec(block)?.[1]?.trim()
288    const target = field('TEST_TARGET')
289    const id = field('TEST_IDENTIFIER')
290    if (!target || !id || field('TEST_ENABLED') === 'false') return []
291    return [{ target, id, name: field('TEST_DISPLAY_NAME') ?? id, state: 'idle' as const, errors: [] }]
292  })
293}
294
295async function loadTests($: EngineInterface): Promise<XcodeTest[]> {
296  const ws = await workspaceId($)
297  if (!ws) throw new Error('No Xcode workspace.')
298  const list = await call<TestList>($, 'GetTestList', { workspaceIdentifier: ws })
299  let found: XcodeTest[] = (list.tests ?? [])
300    .filter(t => t.isEnabled)
301    .map(t => ({ target: t.targetName, id: t.identifier, name: t.displayName, state: 'idle', errors: [] }))
302  if (list.truncated && list.fullTestListPath) {
303    try {
304      found = parseTestFile(await $.fs.read(list.fullTestListPath))
305    } catch {
306      // keep the first 100
307    }
308  }
309  // Keep known results for tests that still exist.
310  const prev = new Map((await read($, tests)).tests.map(t => [testKey(t), t]))
311  const merged = found.map(t => {
312    const old = prev.get(testKey(t))
313    return old ? { ...t, state: old.state, errors: old.errors } : t
314  })
315  await update($, tests, (v): XcodeTests => ({ ...v, tests: merged }))
316  return merged
317}
318
319// List errors land in the Tests tab instead of rejecting.
320async function reloadTests($: EngineInterface) {
321  await update($, tests, (v): XcodeTests => (v.status === 'idle' ? { ...v, status: 'loading', error: undefined } : v))
322  try {
323    await loadTests($)
324    await update($, tests, (v): XcodeTests => (v.status === 'loading' ? { ...v, status: 'idle' } : v))
325  } catch (err) {
326    await update($, tests, (v): XcodeTests => ({ ...v, status: 'idle', error: String((err as Error).message ?? err) }))
327  }
328}
329
330// Renders can't write state, so the Tests tab loads its list when picked.
331async function selectTab($: EngineInterface, v: XcodeTab) {
332  await update($, tab, () => v)
333  const ts = await read($, tests)
334  if (v === 'tests' && ts.tests.length === 0 && ts.status === 'idle') await reloadTests($)
335}
336
337let testTick: { cancel: () => void } | null = null
338
339async function startTests($: EngineInterface, only: TestSpec[] | null) {
340  // No progress to poll: tick so the band's timer moves.
341  testTick?.cancel()
342  testTick = $.clock.every(1000, () => void update($, tests, (v): XcodeTests => ({ ...v })))
343  const now = await $.clock.now()
344  const picked = only && new Set(only.map(s => `${s.targetName}/${s.testIdentifier}`))
345  await update($, tests, (v): XcodeTests => ({
346    ...v,
347    status: 'running',
348    startedAt: now,
349    elapsed: undefined,
350    error: undefined,
351    tests: v.tests.map(t => (!picked || picked.has(testKey(t)) ? { ...t, state: 'running', errors: [] } : t)),
352  }))
353}
354
355async function finishTests($: EngineInterface, run: TestRun | null, failure?: string) {
356  testTick?.cancel()
357  testTick = null
358  const now = await $.clock.now()
359  const results = new Map((run?.results ?? []).map(r => [`${r.targetName}/${r.identifier}`, r]))
360  const v = await update($, tests, (v): XcodeTests => {
361    const known = new Set(v.tests.map(testKey))
362    const added: XcodeTest[] = [...results.values()]
363      .filter(r => !known.has(`${r.targetName}/${r.identifier}`))
364      .map(r => ({ target: r.targetName, id: r.identifier, name: r.displayName, state: 'idle', errors: [] }))
365    const list = [...v.tests, ...added].map(t => {
366      const r = results.get(testKey(t))
367      if (r) return { ...t, state: stateOf(r.state), errors: r.errorMessages }
368      // Results are capped at 100, failures first: an unlisted running test passed.
369      return t.state === 'running' ? { ...t, state: failure ? 'idle' : 'passed', errors: [] } : t
370    }) as XcodeTest[]
371    const failed = list.some(t => t.state === 'failed')
372    return {
373      ...v,
374      status: failure || failed ? 'failed' : 'passed',
375      finishedAt: now,
376      elapsed: (now - v.startedAt) / 1000,
377      tests: list,
378      summary: run?.summary,
379      error: failure,
380    }
381  })
382  const failed = v.tests.filter(t => t.state === 'failed').length
383  const passed = v.tests.filter(t => t.state === 'passed').length
384  $.ui.toast(failure ? `Tests failed to run: ${failure}` : failed ? `Tests Failed · ${failed} failed` : `Tests Passed · ${passed}`)
385}
386
387async function runTests($: EngineInterface, only: TestSpec[] | null): Promise<string> {
388  if ((await read($, tests)).status === 'running') return 'Tests already running.'
389  const ws = await workspaceId($)
390  if (!ws) return 'No Xcode workspace.'
391  await startTests($, only)
392  try {
393    const run = only
394      ? await call<TestRun>($, 'RunSomeTests', { workspaceIdentifier: ws, tests: only })
395      : await call<TestRun>($, 'RunAllTests', { workspaceIdentifier: ws })
396    await finishTests($, run)
397    return run.summary ?? 'Tests finished.'
398  } catch (err) {
399    const msg = String((err as Error).message ?? err)
400    await finishTests($, null, msg)
401    return msg
402  }
403}
404
405const specOf = (t: XcodeTest): TestSpec => ({ targetName: t.target, testIdentifier: t.id })
406
407async function rerunFailed($: EngineInterface) {
408  const failed = (await read($, tests)).tests.filter(t => t.state === 'failed')
409  if (failed.length) await runTests($, failed.map(specOf))
410}
411
412let consolePoll: { cancel: () => void } | null = null
413let isReadingConsole = false
414
415// OSLog lines start with '2026-10-02 11:42:44.239440+0500 [pid:tid] '.
416function lineOf(u: { content: string; kind: string; severity?: string; timestamp: number }): XcodeConsoleLine {
417  const m = /^\d{4}-\d\d-\d\d (\d\d:\d\d:\d\d)\.\d+[+-]\d{4} (?:\[\d+:\d+\] )?/.exec(u.content)
418  return {
419    kind: u.kind,
420    severity: u.severity,
421    time: m?.[1],
422    text: (m ? u.content.slice(m[0].length) : u.content).replace(/\n$/, ''),
423    timestamp: u.timestamp,
424  }
425}
426
427async function readConsole($: EngineInterface, ws: string) {
428  if (isReadingConsole) return
429  isReadingConsole = true
430  try {
431    const r = await read($, run)
432    const out = await call<ConsoleOutput>($, 'GetConsoleOutput', {
433      workspaceIdentifier: ws,
434      tailLimit: CONSOLE_TAIL,
435      ...(r.session ? { launchSessionReference: r.session } : {}),
436    })
437    // 'Launch Session: Sandbox, ref: 751189f000, PID: 85121, State: started'
438    const info = out.launchSessionInfo ?? ''
439    const isAlive = /State: (started|running)/i.test(info)
440    const now = await $.clock.now()
441    await update($, run, (v): XcodeRun => ({
442      ...v,
443      app: /Launch Session: ([^,]+)/.exec(info)?.[1] ?? v.app,
444      lines: (out.units ?? []).map(lineOf),
445      total: out.totalCount ?? v.total,
446      ...(v.status === 'running' && !isAlive ? { status: 'stopped' as const, finishedAt: now } : {}),
447    }))
448    if (!isAlive) {
449      consolePoll?.cancel()
450      consolePoll = null
451    }
452  } catch {
453    // session not ready yet
454  } finally {
455    isReadingConsole = false
456  }
457}
458
459async function launched($: EngineInterface, ws: string, result: RunResult | null, failure?: string) {
460  const errors = (result?.buildErrors ?? []).filter(e => e.classification === 'error')
461  if (failure || errors.length || !result?.launchSessionReference) {
462    const now = await $.clock.now()
463    await update($, run, (v): XcodeRun => ({
464      ...v,
465      status: 'failed',
466      finishedAt: now,
467      error: failure ?? (errors.map(e => e.message).join('\n') || result?.runResult || 'Run failed'),
468    }))
469    $.ui.toast('Run Failed')
470    return
471  }
472  await update($, run, (v): XcodeRun => ({
473    ...v,
474    status: 'running',
475    session: result.launchSessionReference,
476    pid: result.processIdentifier,
477    lines: [],
478    total: 0,
479    clearedAt: 0,
480    error: undefined,
481  }))
482  consolePoll?.cancel()
483  consolePoll = $.clock.every(CONSOLE_MS, () => void readConsole($, ws))
484  void readConsole($, ws)
485}
486
487async function launchApp($: EngineInterface): Promise<string> {
488  const status = (await read($, run)).status
489  if (status === 'launching') return 'Already launching.'
490  const ws = await workspaceId($)
491  if (!ws) return 'No Xcode workspace.'
492  await update($, run, (v): XcodeRun => ({ ...v, status: 'launching', error: undefined }))
493  try {
494    const result = await call<RunResult>($, 'RunProject', { workspaceIdentifier: ws })
495    await launched($, ws, result)
496    return result.runResult ?? 'Launched.'
497  } catch (err) {
498    const msg = String((err as Error).message ?? err)
499    await launched($, ws, null, msg)
500    return msg
501  }
502}
503
504async function stopApp($: EngineInterface): Promise<string> {
505  if ((await read($, run)).status !== 'running') return 'Nothing running.'
506  const ws = await workspaceId($)
507  if (!ws) return 'No Xcode workspace.'
508  try {
509    const res = await call<{ stopResult?: string }>($, 'StopProject', { workspaceIdentifier: ws })
510    await stopped($, ws)
511    return res.stopResult ?? 'Stopped.'
512  } catch (err) {
513    return String((err as Error).message ?? err)
514  }
515}
516
517async function stopped($: EngineInterface, ws: string) {
518  consolePoll?.cancel()
519  consolePoll = null
520  await readConsole($, ws)
521  const now = await $.clock.now()
522  await update($, run, (v): XcodeRun => (v.status === 'running' ? { ...v, status: 'stopped', finishedAt: now } : v))
523}
524
525const CONSOLE_ASK_LINES = 50
526
527async function askToFixIssues($: EngineInterface) {
528  const b = await read($, build)
529  const errors = b.issues.filter(i => i.severity === 'error')
530  const list = errors.length ? errors : b.issues
531  if (list.length === 0) return
532  const lines = list.map(i => `- ${i.path ?? '?'}${i.line ? `:${i.line}` : ''}: ${i.severity}: ${i.message}`)
533  await $.prompt.submit({
534    text: `Fix these Xcode build ${errors.length ? 'errors' : 'warnings'}, then rebuild with BuildProject:\n${lines.join('\n')}`,
535  })
536}
537
538async function askToFixTests($: EngineInterface) {
539  const failed = (await read($, tests)).tests.filter(t => t.state === 'failed')
540  if (failed.length === 0) return
541  const lines = failed.map(t => `- ${t.target}/${t.id}\n${t.errors.map(m => `  ${m.replace(/\n/g, '\n  ')}`).join('\n')}`)
542  await $.prompt.submit({
543    text: `These tests fail. Find the cause and fix it (code or test, whichever is wrong), then re-run them with RunSomeTests:\n${lines.join('\n')}`,
544  })
545}
546
547async function askAboutConsole($: EngineInterface) {
548  const r = await read($, run)
549  const visible = r.lines.filter(
550    l => l.timestamp > r.clearedAt && (!r.isErrorsOnly || l.severity === 'error' || l.severity === 'fault'),
551  )
552  if (visible.length === 0) return
553  const lines = visible
554    .slice(-CONSOLE_ASK_LINES)
555    .map(l => `${l.time ?? ''} ${l.severity ? `[${l.severity}] ` : ''}${l.text}`.trim())
556  await $.prompt.submit({
557    text: `Console output of ${r.app ?? 'the app'} (last ${lines.length} lines). Explain what's going on and whether anything here points to a bug in our code; ignore system noise:\n\`\`\`\n${lines.join('\n')}\n\`\`\``,
558  })
559}
560
561const pad = (n: number) => String(n).padStart(2, '0')
562const clockTime = (ms: number) => {
563  const d = new Date(ms)
564  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
565}
566
567// The band's one activity slot: what runs now, else the running app, else the latest result.
568function activity(b: XcodeBuild, ts: XcodeTests, r: XcodeRun, now: number): { text: string; color?: string } | null {
569  const secs = (from: number) => Math.floor((now - from) / 1000)
570  if (b.status === 'running') return { text: `◐ Building ${secs(b.startedAt)}s${b.task ? ` · ${b.task}` : ''}`, color: 'yellow' }
571  if (ts.status === 'running') return { text: `◐ Testing ${secs(ts.startedAt)}s`, color: 'yellow' }
572  if (r.status === 'launching') return { text: '◐ Launching', color: 'yellow' }
573  if (r.status === 'running') return { text: `● Running ${r.app ?? 'app'}`, color: 'green' }
574  const done = [
575    b.finishedAt !== undefined && {
576      at: b.finishedAt,
577      text: b.status === 'succeeded' ? `✓ Build Succeeded ${b.elapsed?.toFixed(1)}s` : '✗ Build Failed',
578      color: b.status === 'succeeded' ? 'green' : 'red',
579    },
580    ts.finishedAt !== undefined && {
581      at: ts.finishedAt,
582      text: ts.status === 'passed' ? '✓ Tests Passed' : '✗ Tests Failed',
583      color: ts.status === 'passed' ? 'green' : 'red',
584    },
585    r.finishedAt !== undefined && {
586      at: r.finishedAt,
587      text: r.status === 'failed' ? '✗ Run Failed' : `■ ${r.app ?? 'App'} stopped`,
588      color: r.status === 'failed' ? 'red' : undefined,
589    },
590  ].filter(x => x !== false) as { at: number; text: string; color?: string }[]
591  const last = done.sort((x, y) => y.at - x.at)[0]
592  return last ? { text: `${last.text} · ${clockTime(last.at)}`, color: last.color } : null
593}
594
595// Commands only where cwd holds an Xcode project, so other sessions stay untouched.
596// Commands before the first prompt; the rest waits for the xcode server.
597async function setup($: EngineInterface): Promise<boolean> {
598  if (!(await findProject($, await $.session.cwd()))) return false
599  await $.command.register({ name: 'build', description: 'Build the active Xcode scheme', immediate: true })
600  await $.command.register({
601    name: 'tests',
602    description: 'Run tests of the active scheme (all, or those matching a filter)',
603    argumentHint: '[filter]',
604    immediate: true,
605  })
606  // 'run' may clash with a built-in skill; the command.run hook below serves it either way.
607  try {
608    await $.command.register({ name: 'run', description: 'Build and run the active Xcode scheme', immediate: true })
609  } catch {
610    // name taken: /run still reaches our hook first
611  }
612  await $.command.register({ name: 'stop', description: 'Stop the app launched from Xcode', immediate: true })
613  await $.command.register({ name: 'xcode', description: 'Open the Xcode pane (Build · Run · Tests)' })
614  await $.command.register({
615    name: 'preview',
616    description: 'Render a SwiftUI #Preview into the Canvas pane',
617    argumentHint: '[file.swift]',
618    immediate: true,
619  })
620  // Timers die with the old module on reload; resume console polling.
621  if ((await read($, run)).status === 'running') {
622    const ws = await workspaceId($)
623    if (ws) consolePoll = $.clock.every(CONSOLE_MS, () => void readConsole($, ws))
624  }
625  return true
626}
627
628
629// The xcode server connects after session.start: retry quietly, then say what's missing.
630async function awaitServer($: EngineInterface, attempt = 1) {
631  const t = await refreshTarget($)
632  if (t || !isNotConnected(await read($, error))) return
633  if (attempt < CONNECT_TRIES) {
634    $.clock.after(CONNECT_MS, () => void awaitServer($, attempt + 1))
635  } else {
636    await update($, error, () => 'Xcode MCP server "xcode" not connected: claude mcp add xcode -- xcrun mcpbridge')
637  }
638}
639
640// An agent's MCP call: structuredContent when core keeps it, else the JSON the tool printed.
641function outputOf<T>(ran: { result?: unknown; text?: string }): T | null {
642  const sc = (ran.result as { structuredContent?: T } | undefined)?.structuredContent
643  if (sc) return sc
644  const brace = ran.text?.indexOf('{') ?? -1
645  if (!ran.text || brace < 0) return null
646  try {
647    return JSON.parse(ran.text.slice(brace)) as T
648  } catch {
649    return null
650  }
651}
652
653// Agent work shows up like the user's own: pane open on the matching tab (seats from 144 columns when unasked).
654function showTab($: EngineInterface, v: XcodeTab) {
655  void update($, tab, () => v)
656  void $.ui.open({ id: PANE, title: 'Xcode' })
657}
658
659export const register: Register = on => {
660  on('session.start', async ($, e, next) => {
661    if (await setup($)) void awaitServer($)
662    return next(e)
663  })
664
665  on('command.run', { command: 'build' }, async $ => {
666    void $.ui.open({ id: PANE, title: 'Xcode' })
667    await update($, tab, () => 'build')
668    void runBuild($)
669    return { text: 'Building…' }
670  })
671
672  on('command.run', { command: 'tests' }, async ($, e) => {
673    void $.ui.open({ id: PANE, title: 'Xcode' })
674    await update($, tab, () => 'tests')
675    const filter = e.args.trim().toLowerCase()
676    if (!filter) {
677      void runTests($, null)
678      return { text: 'Testing…' }
679    }
680    let list = (await read($, tests)).tests
681    try {
682      if (list.length === 0) list = await loadTests($)
683    } catch (err) {
684      return { text: String((err as Error).message ?? err) }
685    }
686    const picked = list.filter(t => `${t.target}/${t.id}`.toLowerCase().includes(filter))
687    if (picked.length === 0) return { text: `No tests match "${filter}".` }
688    void runTests($, picked.map(specOf))
689    return { text: `Testing ${plural(picked.length, 'test')}…` }
690  })
691
692  on('command.run', { command: 'run' }, async $ => {
693    void $.ui.open({ id: PANE, title: 'Xcode' })
694    await update($, tab, () => 'run')
695    void launchApp($)
696    return { text: 'Launching…' }
697  })
698
699  on('command.run', { command: 'stop' }, async $ => ({ text: await stopApp($) }))
700
701  on('command.run', { command: 'xcode' }, async $ => {
702    await $.ui.open({ id: PANE, title: 'Xcode' })
703    void refreshTarget($)
704    return { text: 'Xcode pane opened.' }
705  })
706
707  // Agent-started builds: same live tracking.
708  on('tool.call', { tool: 'mcp__xcode__BuildProject' }, async ($, e, next) => {
709    const ws = (e.workspaceIdentifier as string | undefined) ?? (await workspaceId($))
710    if (!ws) return next(e)
711    showTab($, 'build')
712    await start($, ws)
713    const ran = await next(e)
714    const sc = outputOf<BuildResult>(ran)
715    await finish($, ws, sc, ran.deny ?? (ran.isError ? 'Build tool failed' : undefined))
716    return ran
717  })
718
719  // Agent-started test runs land in the Tests tab too.
720  on('tool.call', { tool: ['mcp__xcode__RunAllTests', 'mcp__xcode__RunSomeTests'] }, async ($, e, next) => {
721    showTab($, 'tests')
722    if ((await read($, tests)).tests.length === 0) {
723      try {
724        await loadTests($)
725      } catch {
726        // results still fill the list
727      }
728    }
729    const only = Array.isArray(e.tests) ? (e.tests as TestSpec[]) : null
730    await startTests($, only)
731    const ran = await next(e)
732    const sc = outputOf<TestRun>(ran)
733    await finishTests($, sc, ran.deny ?? (ran.isError ? 'Test tool failed' : undefined))
734    return ran
735  })
736
737  // Agent-started runs stream into the Console tab too.
738  on('tool.call', { tool: 'mcp__xcode__RunProject' }, async ($, e, next) => {
739    const ws = (e.workspaceIdentifier as string | undefined) ?? (await workspaceId($))
740    if (!ws) return next(e)
741    showTab($, 'run')
742    await update($, run, (v): XcodeRun => ({ ...v, status: 'launching', error: undefined }))
743    const ran = await next(e)
744    const sc = outputOf<RunResult>(ran)
745    await launched($, ws, sc, ran.deny ?? (ran.isError ? 'Run tool failed' : undefined))
746    return ran
747  })
748
749  on('tool.call', { tool: 'mcp__xcode__StopProject' }, async ($, e, next) => {
750    const ran = await next(e)
751    const ws = (e.workspaceIdentifier as string | undefined) ?? (await workspaceId($))
752    if (ws && ran.deny === undefined) await stopped($, ws)
753    return ran
754  })
755
756  on('session.end', async ($, e, next) => {
757    consolePoll?.cancel()
758    poll?.cancel()
759    testTick?.cancel()
760    return next(e)
761  })
762
763  on('tool.call', { tool: ['mcp__xcode__XcodeSwitchScheme', 'mcp__xcode__XcodeSwitchRunDestination', 'mcp__xcode__XcodeSwitchTestPlan'] }, async ($, e, next) => {
764    const ran = await next(e)
765    void refreshTarget($)
766    return ran
767  })
768
769  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
770    if (e.props.hasSurvey || e.surface === 'mobile') return next(e)
771    const t = await read($, target)
772    const b = await read($, build)
773    const err = await read($, error)
774    const ts = await read($, tests)
775    const r = await read($, run)
776    // Still connecting: awaitServer reports if it never does.
777    if (!t && (!err || isNotConnected(err))) return next(e)
778    const { Box, Text, Select, Button } = $.ui.resolve(e)
779    const now = await $.clock.now()
780    const errors = b.issues.filter(i => i.severity === 'error').length
781    const warnings = b.issues.length - errors
782    const failedTests = ts.tests.filter(x => x.state === 'failed').length
783    const act = activity(b, ts, r, now)
784    const openTab = (v: XcodeTab) => async () => {
785      await $.ui.open({ id: PANE, title: 'Xcode' })
786      await selectTab($, v)
787    }
788
789    return (
790      <Box flexDirection="row" gap={1}>
791        <Text dimColor></Text>
792        {err && <Text color="red">{err}</Text>}
793        {t && (
794          <Select
795            key="scheme"
796            value={t.scheme}
797            options={(t.schemes.length ? t.schemes : [t.scheme ?? '—']).map(s => ({ value: s }))}
798            onSelect={(v: string) => void switchTo($, 'XcodeSwitchScheme', { workspaceIdentifier: t.workspace, schemeName: v })}
799          />
800        )}
801        {t && <Text dimColor>▸</Text>}
802        {t && (
803          <Select
804            key="destination"
805            value={t.destination}
806            options={(t.destinations.length ? t.destinations : [t.destination ?? '—']).map(d => ({ value: d }))}
807            onSelect={(v: string) =>
808              void switchTo($, 'XcodeSwitchRunDestination', { workspaceIdentifier: t.workspace, displayTitle: v })
809            }
810          />
811        )}
812        {act && <Text dimColor>│</Text>}
813        {act && <Text color={act.color} dimColor={act.color === undefined}>{act.text}</Text>}
814        {(errors > 0 || warnings > 0 || failedTests > 0) && <Text dimColor>│</Text>}
815        {errors > 0 && <Button key="band-errors" plain label={`⛔${errors}`} onPress={openTab('build')} />}
816        {warnings > 0 && <Button key="band-warnings" plain label={`⚠${warnings}`} onPress={openTab('build')} />}
817        {failedTests > 0 && <Button key="band-tests" plain label={`✗${failedTests}`} onPress={openTab('tests')} />}
818      </Box>
819    )
820  })
821
822  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
823    const { Box, Text, Button } = $.ui.resolve(e)
824    const b = await read($, build)
825    const ts = await read($, tests)
826    const r = await read($, run)
827    const t = await read($, target)
828    const stored = await read($, tab)
829    // 'issues' was a tab before Build absorbed it.
830    const current: XcodeTab = stored === 'tests' ? 'tests' : (stored as string) === 'console' || stored === 'run' ? 'run' : 'build'
831    const now = await $.clock.now()
832    const room = Math.max(3, (e.viewport?.rows ?? 24) - 8)
833    const width = e.props.bodyColumns
834    const setTab = (v: XcodeTab) => () => void selectTab($, v)
835    const errors = b.issues.filter(i => i.severity === 'error')
836    const warnings = b.issues.filter(i => i.severity !== 'error')
837    const failedTests = ts.tests.filter(t => t.state === 'failed').length
838
839    const badges = [errors.length && `⛔${errors.length}`, warnings.length && `⚠${warnings.length}`].filter(Boolean).join(' ')
840    const tabs = (
841      <Box flexDirection="row" gap={1}>
842        <Button key="tab-build" plain label={`Build${badges ? ` ${badges}` : ''}`} hotkey="1"
843          dimColor={current !== 'build'} onPress={setTab('build')} />
844        <Text dimColor>│</Text>
845        <Button key="tab-run" plain label={`Run${r.status === 'running' ? ' ●' : ''}`} hotkey="2"
846          dimColor={current !== 'run'} onPress={setTab('run')} />
847        <Text dimColor>│</Text>
848        <Button key="tab-tests" plain label={`Tests${failedTests ? ` ✗${failedTests}` : ''}`} hotkey="3"
849          dimColor={current !== 'tests'} onPress={setTab('tests')} />
850      </Box>
851    )
852
853    let actions
854    let moreActions
855    let body
856    if (current === 'tests') {
857      const icon: Record<XcodeTestState, [string, string | undefined]> = {
858        idle: ['◇', undefined],
859        running: ['◐', 'yellow'],
860        passed: ['✓', 'green'],
861        failed: ['✗', 'red'],
862        skipped: ['⊘', 'gray'],
863      }
864      // Failed first, then grouped by target and suite (identifier up to the last '/').
865      const rows: ({ kind: 'group'; label: string } | { kind: 'test'; test: XcodeTest })[] = []
866      let group = ''
867      const order = (t: XcodeTest) => (t.state === 'failed' ? 0 : 1)
868      for (const t of [...ts.tests].sort((a, b) => order(a) - order(b))) {
869        const suite = `${t.target} › ${t.id.split('/').slice(0, -1).join('/') || '—'}`
870        if (suite !== group) rows.push({ kind: 'group', label: (group = suite) })
871        rows.push({ kind: 'test', test: t })
872      }
873      const counts = (['passed', 'failed', 'skipped'] as const)
874        .map(c => `${ts.tests.filter(t => t.state === c).length} ${c}`)
875        .join(' · ')
876      actions = (
877        <Box flexDirection="row" gap={1}>
878          <Button key="test-all" label="▶ Test (t)" hotkey="t" variant="primary" onPress={() => void runTests($, null)} />
879          {failedTests > 0 && <Button key="test-failed" label="↻ Failed (f)" hotkey="f" onPress={() => void rerunFailed($)} />}
880          <Button key="test-reload" label="Reload (l)" hotkey="l" onPress={() => void reloadTests($)} />
881        </Box>
882      )
883      moreActions = failedTests > 0 && (
884        <Box flexDirection="row" gap={1}>
885          <Button key="fix-tests" label="✦ Fix with Claude (x)" hotkey="x" onPress={() => void askToFixTests($)} />
886        </Box>
887      )
888      body = (
889        <Box flexDirection="column">
890          {ts.status === 'loading' && <Text dimColor>Loading tests…</Text>}
891          {(ts.status === 'passed' || ts.status === 'failed') && (
892            <Text dimColor>{counts} · {ts.elapsed?.toFixed(1)}s</Text>
893          )}
894          {ts.error && <Text color="red" wrap="wrap">{ts.error}</Text>}
895          {ts.status !== 'loading' && ts.tests.length === 0 && <Text dimColor>No tests. Press t or /tests.</Text>}
896          {rows.slice(0, room).map((row, i) =>
897            row.kind === 'group' ? (
898              <Text key={`g-${i}`} dimColor wrap="truncate-end">
899                {row.label}
900              </Text>
901            ) : (
902              <Box key={`t-${i}`} flexDirection="column">
903                <Box flexDirection="row" gap={1}>
904                  <Text color={icon[row.test.state][1]}>  {icon[row.test.state][0]}</Text>
905                  <Button key={`run-${testKey(row.test)}`} plain label={row.test.name}
906                    onPress={() => void runTests($, [specOf(row.test)])} />
907                </Box>
908                {row.test.errors.map((m, j) => (
909                  <Box key={`e-${i}-${j}`} flexDirection="column" paddingLeft={4}>
910                    {m.split('\n').map((line, k) => (
911                      <Text key={`l-${k}`} color="red" wrap="wrap">
912                        {line.replace(`${row.test.id}: `, '')}
913                      </Text>
914                    ))}
915                  </Box>
916                ))}
917              </Box>
918            ),
919          )}
920          {rows.length > room && <Text dimColor>…{rows.length - room} more</Text>}
921        </Box>
922      )
923    } else if (current === 'run') {
924      const visible = r.lines.filter(
925        l => l.timestamp > r.clearedAt && (!r.isErrorsOnly || l.severity === 'error' || l.severity === 'fault'),
926      )
927      const color = (l: XcodeConsoleLine) =>
928        l.severity === 'error' || l.severity === 'fault' ? 'red' : l.kind === 'stdio' ? undefined : 'gray'
929      actions = (
930        <Box flexDirection="row" gap={1}>
931          {r.status === 'running' ? (
932            <Button key="app-stop" label="■ Stop (s)" hotkey="s" variant="primary" onPress={() => void stopApp($)} />
933          ) : (
934            <Button key="app-run" label="▶ Run (r)" hotkey="r" variant="primary" onPress={() => void launchApp($)} />
935          )}
936          {visible.length > 0 && <Button key="ask-console" label="✦ Ask Claude (x)" hotkey="x" onPress={() => void askAboutConsole($)} />}
937        </Box>
938      )
939      moreActions = (
940        <Box flexDirection="row" gap={1}>
941          <Button key="console-errors" label={r.isErrorsOnly ? 'Errors only ✓ (e)' : 'Errors only (e)'} hotkey="e"
942            onPress={() => void update($, run, (v): XcodeRun => ({ ...v, isErrorsOnly: !v.isErrorsOnly }))} />
943          <Button key="console-clear" label="Clear (k)" hotkey="k"
944            onPress={() => void update($, run, (v): XcodeRun => ({ ...v, clearedAt: v.lines.at(-1)?.timestamp ?? v.clearedAt }))} />
945        </Box>
946      )
947      body = (
948        <Box flexDirection="column">
949          {r.status !== 'running' && r.status !== 'launching' && (
950            <Text dimColor wrap="wrap">
951              Not running. Press r or /run to build and run {t?.scheme ?? 'the app'}
952              {t?.destination ? ` on ${t.destination}` : ''}.
953            </Text>
954          )}
955          {r.status === 'running' && (
956            <Text dimColor wrap="truncate-end">
957              {r.app ?? t?.scheme ?? 'App'}
958              {t?.destination ? ` on ${t.destination}` : ''}
959              {r.pid !== undefined ? ` · PID ${r.pid}` : ''}
960            </Text>
961          )}
962          {r.status === 'failed' && <Text color="red" wrap="wrap">{r.error}</Text>}
963          {visible.length === 0 && (r.status === 'running' || r.status === 'stopped') && <Text dimColor>No output.</Text>}
964          {visible.length > room && <Text dimColor>…{visible.length - room} earlier</Text>}
965          {visible.slice(-room).map((l, i) => (
966            <Text key={`c-${i}`} color={color(l)} wrap="truncate-end">
967              {l.time ? `${l.time} ` : ''}
968              {l.text}
969            </Text>
970          ))}
971        </Box>
972      )
973    } else {
974      const list = [...errors, ...warnings]
975      actions = (
976        <Box flexDirection="row" gap={1}>
977          <Button key="run-build" label="▶ Build (b)" hotkey="b" variant="primary" onPress={() => void runBuild($)} />
978          {list.length > 0 && <Button key="fix-issues" label="✦ Fix with Claude (x)" hotkey="x" onPress={() => void askToFixIssues($)} />}
979        </Box>
980      )
981      const elapsed = b.status === 'running' ? (now - b.startedAt) / 1000 : b.elapsed
982      body = (
983        <Box flexDirection="column">
984          {b.status === 'idle' && <Text dimColor>No build yet. Press b or /build.</Text>}
985          {b.status !== 'idle' && (
986            <Text dimColor>
987              {plural(b.tasks, 'task')} · {elapsed?.toFixed(1)}s
988            </Text>
989          )}
990          {/* BuildProject's result text is written for the model; issues below say it better. */}
991          {b.status === 'failed' && b.result && errors.length === 0 && <Text color="red" wrap="wrap">{b.result}</Text>}
992          {b.task && (
993            <Text wrap="truncate-end">
994              {'› '}
995              {b.task.slice(0, Math.max(10, width - 4))}
996            </Text>
997          )}
998          {list.slice(0, room).map((issue, i) => {
999            const file = issue.path ? issue.path.split('/').at(-1) : undefined
1000            return (
1001              <Box key={`issue-${i}`} flexDirection="column" marginTop={i === 0 ? 1 : 0}>
1002                <Box flexDirection="row" gap={1}>
1003                  <Text color={issue.severity === 'error' ? 'red' : 'yellow'}>
1004                    {issue.severity === 'error' ? '⛔' : '⚠'}
1005                  </Text>
1006                  {file && issue.path ? (
1007                    <Button key={`open-${i}`} plain label={`${file}${issue.line ? `:${issue.line}` : ''}`}
1008                      onPress={() =>
1009                        void $.process.run(issue.line ? ['xed', '--line', String(issue.line), issue.path!] : ['xed', issue.path!])
1010                      } />
1011                  ) : null}
1012                </Box>
1013                <Text wrap="wrap">  {issue.message}</Text>
1014              </Box>
1015            )
1016          })}
1017          {list.length > room && <Text dimColor>…{list.length - room} more</Text>}
1018        </Box>
1019      )
1020    }
1021
1022    return (
1023      <Box flexDirection="column">
1024        {tabs}
1025        <Box marginTop={1}>{actions}</Box>
1026        {/* A second button row sits here, not nested in actions: nested rows lose mouse presses. */}
1027        {moreActions}
1028        {body}
1029      </Box>
1030    )
1031  })
1032}
1033
hooks/canvas.tsx 318 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CanvasPreview, CanvasSnapshot } from '../types'
5
6const SERVER = 'xcode'
7const PANE = 'xcode-canvas'
8const HISTORY = 10
9const RENDER_DEBOUNCE_MS = 1500
10// Svg markup is capped at 131072 chars; leave room for the wrapper.
11const SVG_BUDGET = 128_000
12const DESKTOP_WIDTH = 320
13
14const preview = atom({ plugin: 'xcode-mods', key: 'preview' } as const, {
15  history: [],
16  index: 0,
17  rendering: null,
18  error: null,
19  generation: 0,
20})
21type RenderOutput = {
22  previewSnapshotPath?: string
23  displayName?: string
24  errors?: { message: string }[]
25  renderedDestination?: { deviceModelName?: string; platformName?: string; systemVersion?: string }
26}
27
28let renderTimer: { cancel: () => void } | null = null
29
30function parseOutput(structured: unknown, text: string | undefined): RenderOutput | null {
31  if (structured && typeof structured === 'object') return structured as RenderOutput
32  if (text === undefined) return null
33  const start = text.indexOf('{')
34  if (start < 0) return null
35  try {
36    return JSON.parse(text.slice(start)) as RenderOutput
37  } catch {
38    return null
39  }
40}
41
42// Xcode errors arrive as '{"type":"error","data":"..."}'.
43function errorText(text: string): string {
44  try {
45    const parsed = JSON.parse(text) as { data?: unknown }
46    if (typeof parsed.data === 'string') return parsed.data
47  } catch {
48    // plain text
49  }
50  return text
51}
52
53async function call<T>($: EngineInterface, tool: string, args: Record<string, unknown> = {}): Promise<T> {
54  const res = await $.mcp.call(SERVER, tool, args)
55  if (res.isError) throw new Error(errorText(res.content.map(c => c.text ?? '').join(' ')) || `${tool} failed`)
56  if (res.structuredContent) return res.structuredContent as T
57  return JSON.parse(res.content.find(c => c.type === 'text')?.text ?? '{}') as T
58}
59
60async function workspaceFor($: EngineInterface, file: string): Promise<string | undefined> {
61  const { message } = await call<{ message: string }>($, 'XcodeListWorkspaces')
62  const open = [...message.matchAll(/workspaceIdentifier: (\S+), workspacePath: (.+)/g)].map(m => ({
63    id: (m[1] ?? '').replace(/,$/, ''),
64    dir: (m[2] ?? '').trim().replace(/\/[^/]+\.(xcodeproj|xcworkspace)$/, ''),
65  }))
66  // Deepest project dir holding the file; a bare name matches projects around or under cwd. Never an unrelated one.
67  if (file.startsWith('/')) {
68    return open.filter(w => file.startsWith(`${w.dir}/`)).sort((a, b) => b.dir.length - a.dir.length)[0]?.id
69  }
70  const cwd = await $.session.cwd()
71  return open.find(w => w.dir === cwd || w.dir.startsWith(`${cwd}/`) || cwd.startsWith(`${w.dir}/`))?.id
72}
73
74async function pixelSize($: EngineInterface, path: string) {
75  const { exitCode, stdout } = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path])
76  if (exitCode !== 0) return { width: 0, height: 0 }
77  return {
78    width: Number(/pixelWidth:\s*(\d+)/.exec(stdout)?.[1] ?? 0),
79    height: Number(/pixelHeight:\s*(\d+)/.exec(stdout)?.[1] ?? 0),
80  }
81}
82
83// Desktop has no Image element: embed a JPEG small enough for an Svg data URI.
84async function makeJpeg($: EngineInterface, path: string): Promise<string | null> {
85  const out = `${path}.canvas.jpg`
86  for (const [side, quality] of [[900, 70], [700, 55], [500, 45]] as const) {
87    const { exitCode } = await $.process.run([
88      'sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', String(quality), '-Z', String(side), path, '--out', out,
89    ])
90    if (exitCode !== 0) return null
91    const { base64 } = await $.fs.read(out, { as: 'bytes' })
92    if (base64.length <= SVG_BUDGET) return base64
93  }
94  return null
95}
96
97async function addSnapshot($: EngineInterface, output: RenderOutput, source: string | undefined) {
98  const path = output.previewSnapshotPath
99  if (!path) {
100    const msg = output.errors?.map(e => e.message).join('\n') || 'RenderPreview returned no snapshot'
101    await update($, preview, (p): CanvasPreview => ({ ...p, rendering: null, error: msg }))
102    return
103  }
104  const [size, jpeg] = await Promise.all([pixelSize($, path), makeJpeg($, path)])
105  const dest = output.renderedDestination
106  const device = [dest?.deviceModelName, dest?.platformName && `${dest.platformName} ${dest.systemVersion ?? ''}`.trim()]
107    .filter(Boolean)
108    .join(', ')
109  const snap: CanvasSnapshot = { path, name: output.displayName ?? 'Preview', source, device, ...size, jpeg }
110  await update($, preview, (p): CanvasPreview => {
111    const history = [...p.history.map(s => ({ ...s, jpeg: null })), snap].slice(-HISTORY)
112    return {
113      history,
114      index: history.length - 1,
115      rendering: null,
116      error: output.errors?.length ? output.errors.map(e => e.message).join('\n') : null,
117      generation: p.generation + 1,
118    }
119  })
120}
121
122// RenderPreview wants a path in the Xcode project organization ('App/Sources/View.swift'), not on disk.
123// Match by file name (case-insensitive), then by the longest common tail of path components.
124async function toProjectPath($: EngineInterface, ws: string | undefined, file: string): Promise<string> {
125  const parts = file.toLowerCase().split('/')
126  const name = parts.at(-1) ?? ''
127  const { matches = [] } = await call<{ matches?: string[] }>($, 'XcodeGlob', {
128    pattern: '**/*.swift',
129    ...(ws ? { workspaceIdentifier: ws } : {}),
130  })
131  const score = (m: string) => {
132    const mp = m.toLowerCase().split('/')
133    let n = 0
134    while (n < mp.length && mp[mp.length - 1 - n] === parts[parts.length - 1 - n]) n++
135    return n
136  }
137  const best = matches.filter(m => m.toLowerCase().endsWith(`/${name}`) || m.toLowerCase() === name)
138    .sort((a, b) => score(b) - score(a))[0]
139  if (!best) throw new Error(`${file.split('/').at(-1)} is not in the Xcode project`)
140  return best
141}
142
143const CONNECT_TRIES = 20
144const CONNECT_MS = 1500
145
146// The xcode server connects after session.start; a /preview typed right away waits for it.
147async function untilConnected<T>($: EngineInterface, work: () => Promise<T>): Promise<T> {
148  for (let attempt = 1; ; attempt++) {
149    try {
150      return await work()
151    } catch (err) {
152      if (attempt >= CONNECT_TRIES || !/no connected MCP/i.test(String((err as Error).message ?? err))) throw err
153      await new Promise<void>(resolve => $.clock.after(CONNECT_MS, resolve))
154    }
155  }
156}
157
158async function renderPreview($: EngineInterface, file: string) {
159  await update($, preview, (p): CanvasPreview => ({ ...p, rendering: file, error: null }))
160  try {
161    await untilConnected($, () => call($, 'XcodeListWorkspaces'))
162    const ws = await workspaceFor($, file)
163    const source = await toProjectPath($, ws, file)
164    const output = await call<RenderOutput>($, 'RenderPreview', {
165      sourceFilePath: source,
166      ...(ws ? { workspaceIdentifier: ws } : {}),
167    })
168    await addSnapshot($, output, source)
169  } catch (err) {
170    const msg = String((err as Error).message ?? err)
171    await update($, preview, (p): CanvasPreview => ({ ...p, rendering: null, error: msg }))
172  }
173}
174
175async function askAboutSnapshot($: EngineInterface, snap: CanvasSnapshot) {
176  await $.prompt.submit({
177    text: `Look at this SwiftUI preview of ${snap.name}${snap.source ? ` (${snap.source})` : ''}${snap.device ? ` on ${snap.device}` : ''}: ${snap.path}\nRead the image and review the layout: spacing, alignment, sizing, contrast, anything that looks off. Suggest concrete fixes in the view code.`,
178  })
179}
180
181async function findPreviewFile($: EngineInterface): Promise<string | null> {
182  const cwd = await $.session.cwd()
183  const { stdout } = await $.process.run([
184    'grep', '-rl', '--include=*.swift', '--exclude-dir=.build', '--exclude-dir=DerivedData',
185    '--exclude-dir=.git', '--exclude-dir=Pods', '--exclude-dir=Carthage', '--exclude-dir=node_modules', '#Preview', cwd,
186  ])
187  const files = stdout.split('\n').filter(Boolean)
188  return files.find(f => f.endsWith('/ContentView.swift')) ?? files[0] ?? null
189}
190
191async function openPane($: EngineInterface) {
192  await $.ui.open({ id: PANE, title: 'Canvas' })
193}
194
195function fitImage(width: number, height: number, columns: number, rows: number) {
196  const ratio = width > 0 && height > 0 ? height / width : 2
197  let c = Math.max(4, columns)
198  // A cell is about twice as tall as it is wide.
199  let r = Math.round((c * ratio) / 2)
200  if (r > rows) {
201    r = Math.max(4, rows)
202    c = Math.max(4, Math.round((r * 2) / ratio))
203  }
204  return { columns: c, rows: Math.min(255, r) }
205}
206
207// /preview is registered in build.tsx setup, only where cwd holds an Xcode project.
208export const register: Register = on => {
209  on('command.run', { command: 'preview' }, async ($, e) => {
210    // Accept '@'-mentions, cwd-relative and bare names; relative paths become absolute for workspaceFor.
211    const arg = e.args.trim().replace(/^@/, '')
212    const cwd = await $.session.cwd()
213    const file = (arg.includes('/') && !arg.startsWith('/') ? `${cwd}/${arg}` : arg) || (await findPreviewFile($))
214    if (!file) return { text: 'No Swift file with #Preview found.' }
215    await openPane($)
216    void renderPreview($, file)
217    return { text: `Rendering ${file.split('/').at(-1)}…` }
218  })
219
220  // Agent-rendered previews land in the history too.
221  on('tool.call', { tool: 'mcp__xcode__RenderPreview' }, async ($, e, next) => {
222    const ran = await next(e)
223    if (ran.deny !== undefined || ran.isError === true) return ran
224    const output = parseOutput((ran.result as { structuredContent?: unknown } | undefined)?.structuredContent, ran.text)
225    if (output) {
226      await addSnapshot($, output, e.sourceFilePath as string | undefined)
227      const opened = await $.ui.open({ id: PANE, title: 'Canvas' })
228      if (!opened.isPlaced) $.ui.toast('Preview ready: /preview to show')
229    }
230    return ran
231  })
232
233  // Re-render after the model edits a Swift file with #Preview, once previews are in use.
234  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
235    const ran = await next(e)
236    const file = (e as { file_path?: string }).file_path
237    if (ran.deny !== undefined || ran.isError === true || !file?.endsWith('.swift')) return ran
238    if ((await read($, preview)).history.length === 0) return ran
239    try {
240      if (!(await $.fs.read(file)).includes('#Preview')) return ran
241    } catch {
242      return ran
243    }
244    renderTimer?.cancel()
245    renderTimer = $.clock.after(RENDER_DEBOUNCE_MS, () => void renderPreview($, file))
246    return ran
247  })
248
249  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
250    const { Box, Text, Button } = $.ui.resolve(e)
251    const p = await read($, preview)
252    const columns = Math.max(10, e.props.bodyColumns - 2)
253    const rows = Math.max(6, (e.viewport?.rows ?? 40) - 9)
254
255    const snap = p.history[p.index]
256    const status = p.rendering ? (
257      <Text color="yellow">◐ Rendering {p.rendering.split('/').at(-1)}…</Text>
258    ) : null
259    if (!snap) {
260      return (
261        <Box flexDirection="column">
262          {status ?? <Text dimColor>No preview yet. /preview [file.swift]</Text>}
263          {p.error && <Text color="red">{p.error}</Text>}
264        </Box>
265      )
266    }
267    const caption = (
268      <Text dimColor wrap="truncate-end">
269        {snap.name}{snap.device ? ` · ${snap.device}` : ''} · {p.index + 1}/{p.history.length}
270      </Text>
271    )
272    const controls = (
273      // Two rows, not flexWrap: wrapped rows lose mouse hit-testing.
274      <Box flexDirection="column">
275        <Box flexDirection="row" gap={1}>
276          {snap.source && <Button key="rerender" label="↻ Re-render (r)" hotkey="r" variant="primary" onPress={() => void renderPreview($, snap.source!)} />}
277          <Button key="ask" label="✦ Ask Claude (x)" hotkey="x" onPress={() => void askAboutSnapshot($, snap)} />
278        </Box>
279        <Box flexDirection="row" gap={1}>
280          <Button key="prev" label="◀ (h)" hotkey="h" onPress={() => void update($, preview, (v): CanvasPreview => ({ ...v, index: Math.max(0, v.index - 1) }))} />
281          <Button key="next" label="▶ (l)" hotkey="l" onPress={() => void update($, preview, (v): CanvasPreview => ({ ...v, index: Math.min(v.history.length - 1, v.index + 1) }))} />
282          <Button key="open" label="Open (o)" hotkey="o" onPress={() => void $.process.run(['open', snap.path])} />
283        </Box>
284      </Box>
285    )
286
287    let image
288    if (e.surface === 'terminal') {
289      const { Image } = $.ui.resolve(e)
290      const box = fitImage(snap.width, snap.height, columns, rows)
291      image = (
292        <Image key="snapshot" source={{ file: snap.path, format: 'png', generation: p.generation * 100 + p.index }}
293          columns={box.columns} rows={box.rows} alt={`${snap.name} preview (needs kitty/Ghostty): ${snap.path}`} />
294      )
295    } else if (e.surface !== 'mobile' && snap.jpeg) {
296      const { Svg } = $.ui.resolve(e)
297      const width = DESKTOP_WIDTH
298      const height = snap.width > 0 ? Math.round((width * snap.height) / snap.width) : Math.round(width * 2)
299      const svg =
300        `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
301        `<image href="data:image/jpeg;base64,${snap.jpeg}" width="${width}" height="${height}"/></svg>`
302      image = <Svg source={svg} alt={`${snap.name} preview`} width={width} height={height} />
303    } else {
304      image = <Text dimColor>{snap.path}</Text>
305    }
306
307    return (
308      <Box flexDirection="column">
309        {caption}
310        {controls}
311        {status}
312        {p.error && <Text color="red">{p.error}</Text>}
313        {image}
314      </Box>
315    )
316  })
317}
318
types/index.d.ts 117 lines
1export type XcodeIssue = {
2  severity: string
3  message: string
4  path?: string
5  line?: number
6}
7
8export type XcodeBuild = {
9  status: 'idle' | 'running' | 'succeeded' | 'failed'
10  startedAt: number
11  elapsed?: number
12  /** Clock time the last build ended. */
13  finishedAt?: number
14  task?: string
15  tasks: number
16  issues: XcodeIssue[]
17  result?: string
18}
19
20export type XcodeTarget = {
21  workspace: string
22  path: string
23  scheme?: string
24  destination?: string
25  schemes: string[]
26  destinations: string[]
27}
28
29export type XcodeTestState = 'idle' | 'running' | 'passed' | 'failed' | 'skipped'
30
31export type XcodeTest = {
32  target: string
33  /** XCTestIdentifier: `CounterTests/increments()`. */
34  id: string
35  name: string
36  state: XcodeTestState
37  errors: string[]
38}
39
40export type XcodeTests = {
41  status: 'idle' | 'loading' | 'running' | 'passed' | 'failed'
42  startedAt: number
43  elapsed?: number
44  finishedAt?: number
45  tests: XcodeTest[]
46  summary?: string
47  error?: string
48}
49
50export type XcodeConsoleLine = {
51  kind: string
52  /** OSLog severity: error, fault, info, debug, default. */
53  severity?: string
54  /** `HH:MM:SS` from the log line, when it has one. */
55  time?: string
56  text: string
57  timestamp: number
58}
59
60export type XcodeRun = {
61  status: 'idle' | 'launching' | 'running' | 'stopped' | 'failed'
62  app?: string
63  pid?: number
64  session?: string
65  lines: XcodeConsoleLine[]
66  total: number
67  /** Lines at or before this timestamp are hidden (Clear). */
68  clearedAt: number
69  isErrorsOnly: boolean
70  /** Clock time the app stopped or failed to launch. */
71  finishedAt?: number
72  error?: string
73}
74
75export type XcodeTab = 'build' | 'run' | 'tests'
76
77export type CanvasSnapshot = {
78  /** Absolute path of the PNG Xcode wrote. */
79  path: string
80  /** Preview display name (`ContentView`). */
81  name: string
82  /** Swift file the preview lives in. */
83  source?: string
84  /** Device the preview rendered on (`iPhone Duo, iOS 27.1`). */
85  device: string
86  /** PNG pixel size; 0 when unknown. */
87  width: number
88  height: number
89  /** Downscaled JPEG, base64, for surfaces without `Image`; latest snapshot only. */
90  jpeg: string | null
91}
92
93export type CanvasPreview = {
94  history: CanvasSnapshot[]
95  /** Index into history being shown. */
96  index: number
97  /** File being rendered now. */
98  rendering: string | null
99  error: string | null
100  /** Bumps on every new snapshot so the terminal re-reads files. */
101  generation: number
102}
103
104declare module 'claude-code' {
105  interface PluginState {
106    'xcode-mods': {
107      build: XcodeBuild
108      target: XcodeTarget | null
109      tests: XcodeTests
110      run: XcodeRun
111      tab: XcodeTab
112      error: string | null
113      preview: CanvasPreview
114    }
115  }
116}
117