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

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

One Claude Code plugin that adds:
#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.
sudo xcrun mcp-server enable
xcrun mcp-server status
xcode: claude mcp add --scope user xcode -- xcrun mcpbridge
~/.claude/settings.json. Without this rule, every build or refresh asks for permission: {
"permissions": {
"allow": ["mcp__xcode"]
}
}
/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.
| Command | What it does |
|---|---|
/xcode | Open the Xcode pane |
/build | Build the active scheme |
/tests [filter] | Run all tests, or only those whose Target/identifier contains the filter |
/run | Build and run on the active destination, then stream the console |
/stop | Stop 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. |
The band above the prompt works like Xcode's toolbar:
Sandbox ▸ iPhone 17 Pro │ ✓ Build Succeeded 1.4s · 14:02 │ ⚠1 ✗2
◐ Building 3s · Compile ContentView.swift, ◐ Testing, ◐ Launching, ● Running Sandbox), or the last result with its time (✓ Build Succeeded, ✗ Tests Failed, ■ Sandbox stopped).⛔ errors, ⚠ warnings, ✗ failed tests. Click one to open the matching tab.The band also shows Claude's own builds, test runs and launches.

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 errorsTests Passed · 12, Tests Failed · 2 failedRun FailedPreview ready: /preview to show, when the window is too narrow to open the Canvas pane by itself
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 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

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

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.
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.mcp__xcode allow rule from Setup.o to open it.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.
Artem Novichkov, https://artemnovichkov.com/
The project is available under the MIT license. See the LICENSE file for more info.
hooks/register.tsx 10 lines1import 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}
10hooks/build.tsx 1033 lines1import { 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}
1033hooks/canvas.tsx 318 lines1import { 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}
318types/index.d.ts 117 lines1export 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