Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
{
"name": "mission-control",
"description": "/mission opens a live map of the main agent, its subagents and every tool call, plus a code map of the files they touch.",
"version": "0.1.5",
"version": "0.1.6",
"source": "./mods/mission-control",
"category": "See what's happening",
"tags": [
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Install it like any mod, restart, and type `/mission` (or `/mission code` to ope
- 🟠 **Orange** while it writes
- 🟢 **Green** when done, with one line on what changed

> The Code view runs on macOS and Linux and needs Google Chrome or Chromium and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
> The Code view runs on macOS and Linux and needs Google Chrome or Chromium and a terminal that shows images (Ghostty, kitty, iTerm2). On Windows it lists the map as lines, and `b` opens the drawn map in your browser. The Who view works everywhere.

## 📚 More

Expand Down
1 change: 1 addition & 0 deletions docs/mods.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Shows every Claude Code subagent, tool call and touched file in a live pane.
- `/mission code` opens straight to the code map, `/mission who` to the agents
- Headless Chrome draws the code map as an image, in a throwaway profile. It uses the first of `/Applications/Google Chrome.app` (macOS), `google-chrome-stable`, `google-chrome`, `chromium-browser` or `chromium` in `/usr/bin` (Linux)
- The Code view runs on macOS and Linux and needs a terminal that shows images (Ghostty, kitty, iTerm2)
- On Windows, whose terminals show no images, the Code view lists the map as lines, and `b` opens the drawn map in your browser, which follows each change
- A file glows blue while Claude reads it, orange while it edits it, and turns green with a check once changed
- One Haiku call after each turn writes the line under each changed file

Expand Down
2 changes: 1 addition & 1 deletion mods/mission-control/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mission-control",
"version": "0.1.5",
"version": "0.1.6",
"description": "/mission opens a live map of the main agent, its subagents and every tool call, plus a code map of the files they touch.",
"author": {
"name": "Hamza Zafar",
Expand Down
30 changes: 28 additions & 2 deletions mods/mission-control/hooks/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ const esc = (s: string) =>
export type Look = { width: number; height: number; at: number; turn: number }

// The whole picture: boxes per file, arrows per import, a glow on what is being touched now.
const SANS = `-apple-system, 'SF Pro Text', 'Helvetica Neue', sans-serif`
const MONO = `'SF Mono', Menlo, monospace`
const SANS = `-apple-system, 'SF Pro Text', 'Helvetica Neue', 'Segoe UI', sans-serif`
const MONO = `'SF Mono', Menlo, Consolas, monospace`
const C = {
bg: '#0d1015', grid: '#1a1f28', card: '#151922', cardLive: '#1b2130', line: '#2a3140', text: '#e8ebf2',
dim: '#7d8698', faint: '#4b5263', edge: '#3a4252', edgeHot: '#6b7a96',
Expand Down Expand Up @@ -246,6 +246,32 @@ export function svg(files: MapFile[], look: Look): string {
return o.join('\n')
}

export type MapLine = { mark: string; tone: 'read' | 'edit' | 'changed' | 'dim'; title: string; imports: string[]; why?: string }

// The map as lines, for a terminal that draws no pictures: newest first, each file's
// state, the files it imports and the one-line why.
export function textMap(files: MapFile[], look: { at: number; turn: number }): MapLine[] {
const all = files.map(f => f.path)
const es = edges(files)
return files.map(f => {
const isLive = look.at - f.at < 5000
const isChanged = f.changedTurn === look.turn && look.turn > 0
const isReading = f.act === 'read' || f.act === 'search'
return {
mark: isLive ? (isReading ? '●' : '✎') : isChanged ? '✓' : '·',
tone: isLive ? (isReading ? 'read' : 'edit') : isChanged ? 'changed' : 'dim',
title: titleOf(f.path, all),
imports: es.filter(e => e.from === f.path).map(e => titleOf(e.to, all)),
why: f.why,
}
})
}

// The picture as a page that reloads itself, so a browser keeps showing the latest.
export function page(picture: string) {
return `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="2"><title>Code map</title><style>html,body{margin:0;height:100%;background:${C.bg}}svg{display:block;width:100%;height:100%}</style>\n${picture}\n`
}

// A file's name, or with several of that name the nearest folder that tells them apart:
// three `hooks/register.tsx` read as `merge-gate › register.tsx`, `snake › register.tsx`.
export function titleOf(path: string, all: string[]) {
Expand Down
69 changes: 62 additions & 7 deletions mods/mission-control/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register, Timer } from 'claude-code'

import type { MapFile, MissionNode } from '../types'
import { importsOf, svg } from './map'
import { importsOf, page, svg, textMap } from './map'
import { label, lines, summary } from './tree'
import { bandsFile, parseOrder, stackBands } from './bands'

Expand Down Expand Up @@ -37,9 +37,11 @@ const turn = atom({ plugin: 'mission-control', key: 'turn' } as const, 0)
const now = atom({ plugin: 'mission-control', key: 'now' } as const, 0)

// Drawing bookkeeping; a reload starts it over.
const draw = { size: { columns: 100, rows: 30 }, pending: false, dirty: false, n: 0, error: '' }
const draw = { size: { columns: 100, rows: 30 }, pending: false, dirty: false, n: 0, error: '', isPageOpen: false }
const changes = new Map<string, string[]>() // path → this turn's edits, for the one-line "why"
const MAX_CHANGES = 5 // the first edit and the latest few per file are enough for the "why"
// No Windows terminal draws pictures: there the map is lines in the pane and a page in the browser.
let isWindows = false

// Under the cap, the oldest tool calls go first; main and the agents always stay.
export function cap(list: MissionNode[]) {
Expand All @@ -65,6 +67,7 @@ export const register: Register = on => {

on('session.start', async ($, e, next) => {
const r = await next(e)
isWindows = (await $.env.get('OS')) === 'Windows_NT'
await $.command.register({ name: 'mission', description: 'Open the live map of agents and tool calls; /mission code opens the code map', argumentHint: '[who|code]' }).catch(() => {})
timer?.cancel()
timer = $.clock.every(1000, () => {
Expand Down Expand Up @@ -112,7 +115,8 @@ export const register: Register = on => {
const parent = e.agentId && list.some(n => n.id === e.agentId) ? e.agentId : 'main'
return cap([...list, { id, parent, kind: 'tool', label: text, family, status: 'running', start: Date.now() }])
})
const path = typeof args.file_path === 'string' ? args.file_path : undefined
const raw = typeof args.file_path === 'string' ? args.file_path : undefined
const path = raw && isWindows ? raw.replace(/\\/g, '/') : raw // one separator, so names and imports resolve
if (path) await touch($, path, e.tool === 'Read' ? 'read' : e.tool === 'Write' ? 'write' : e.tool === 'Edit' ? 'edit' : 'search', false)

let failed = true
Expand Down Expand Up @@ -170,6 +174,7 @@ export const register: Register = on => {
<Box flexDirection="row" gap={1}>
<Button key="who" label="Who" hotkey="w" variant={v === 'who' ? 'primary' : undefined} onPress={() => update($, view, () => 'who')} />
<Button key="code" label="Code" hotkey="c" variant={v === 'code' ? 'primary' : undefined} onPress={() => void setCode($).catch(() => {})} />
{v === 'code' && isWindows && <Button key="open" label="Browser" hotkey="b" onPress={() => void openPage($).catch(() => {})} />}
<Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
</Box>
)
Expand All @@ -183,6 +188,27 @@ export const register: Register = on => {
</Box>
)
}
if (isWindows) {
const t = await read($, turn)
const list = await read($, files)
const ls = textMap(list, { at: Date.now(), turn: t })
const changed = list.filter(f => f.changedTurn === t && t > 0).length
return (
<Box flexDirection="column">
{head}
<Text dimColor>{`${list.length} files · ${changed} changed · b: the drawn map in your browser`}</Text>
{draw.error !== '' && <Text color="red">{draw.error}</Text>}
{ls.length === 0 && <Text dimColor>Files Claude reads or edits show up here.</Text>}
{ls.slice(0, rows - 1).map(l => (
<Text wrap="truncate-end">
<Text color={l.tone === 'read' ? 'blue' : l.tone === 'edit' ? 'yellow' : l.tone === 'changed' ? 'green' : undefined} dimColor={l.tone === 'dim'}>{`${l.mark} `}</Text>
<Text bold={l.tone !== 'dim'}>{l.title}</Text>
{(l.imports.length > 0 || l.why) && <Text dimColor>{`${l.imports.length ? ` → ${l.imports.join(', ')}` : ''}${l.why ? ` ${l.why}` : ''}`}</Text>}
</Text>
))}
</Box>
)
}
const { Image } = $.ui.resolve(e)
// A fixed 16:10 shape from the pane's width: lines coming and going above the prompt
// change the pane's height, and must not resize (and jolt) the picture.
Expand Down Expand Up @@ -311,13 +337,17 @@ async function renderSoon($: EngineInterface) {
draw.dirty = true // drawing now: draw once more when it is done
return
}
if ((await read($, view)) !== 'code') return
const isUp = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
if (!isUp) return
if (isWindows) {
if (!draw.isPageOpen) return // the browser page is the picture, kept fresh once opened
} else {
if ((await read($, view)) !== 'code') return
const isUp = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
if (!isUp) return
}
draw.pending = true
draw.dirty = false
$.clock.after(700, () => {
void renderMap($)
void (isWindows ? renderPage($) : renderMap($))
.catch(() => {})
.finally(() => {
draw.pending = false // only now: two Chromes must never share the profile
Expand Down Expand Up @@ -362,3 +392,28 @@ async function renderMap($: EngineInterface) {
draw.error = `${chrome} could not draw the code map.`
await update($, now, () => Date.now()) // redraw the pane with the message
}

async function pagePath($: EngineInterface) {
const tmp = ((await $.env.get('TEMP')) ?? 'C:\\Windows\\Temp').replace(/\\$/, '')
return `${tmp}\\mission-control-map.html` // right in TEMP, which always exists: the engine makes no folders
}

// The drawn map as a page the browser reloads: no Chrome to run, the browser draws the SVG.
async function renderPage($: EngineInterface) {
await $.fs.write(await pagePath($), page(svg(await read($, files), { width: 1400, height: 820, at: Date.now(), turn: await read($, turn) })))
}

// Opens the drawn map in the default browser; from then on each change redraws it.
async function openPage($: EngineInterface) {
try {
await renderPage($)
} catch {
draw.isPageOpen = false
draw.error = `Could not write ${await pagePath($)}.`
await update($, now, () => Date.now()) // redraw the pane with the message
return
}
draw.isPageOpen = true
draw.error = ''
await $.process.run(['explorer.exe', await pagePath($)]).catch(() => {}) // explorer exits 1 even when it opened the page
}
55 changes: 50 additions & 5 deletions mods/mission-control/tests/mission-control.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ const PANE = { component: 'Pane', requestId: 'mission-control', props: { title:
const BAND = { component: 'AbovePrompt', props: { hasSurvey: false, isWorking: true, maxRows: 10, bodyColumns: 120 } }

// Stands for the engine beneath the mod.
function engine(on: any, opts: { noChrome?: boolean; chromeAt?: string; renderFails?: boolean; bands?: string; beneath?: unknown } = {}) {
function engine(on: any, opts: { noChrome?: boolean; chromeAt?: string; renderFails?: boolean; bands?: string; beneath?: unknown; env?: Record<string, string>; pageFails?: boolean } = {}) {
const runs: string[][] = []
resetBandOrder()
const writes: Record<string, string> = {}
// A path as the mod wrote it: on Windows the engine hands `/tmp/x` on as `C:\tmp\x`.
const posix = (p: string) => p.replace(/^[A-Za-z]:/, '').replace(/\\/g, '/')
on('session.start', (_$: any, e: any) => ({ sessionId: 's', cwd: e.cwd }))
on('command.register', () => ({ value: undefined }))
const clock = mock.clock(on)
Expand All @@ -21,10 +23,13 @@ function engine(on: any, opts: { noChrome?: boolean; chromeAt?: string; renderFa
on('turn.complete', () => ({ text: '' }))
on('agent.spawn', () => ({ model: 'sonnet', agentId: 'ag1' }))
on('tool.call', () => ({ result: {}, text: 'ok' }))
on('fs.read', (_$: any, e: any) => ({ value: e.path.endsWith('login.tsx') ? "import { start } from '../auth/session'\n" : e.path.endsWith('/bands.json') ? opts.bands : '' }))
on('fs.write', (_$: any, e: any) => ((writes[e.path] = e.text), { value: undefined }))
on('fs.exists', (_$: any, e: any) => ({ value: e.path.endsWith('/bands.json') ? opts.bands !== undefined : opts.chromeAt !== undefined ? e.path === opts.chromeAt : !opts.noChrome }))
on('env.get', () => ({ value: '/tmp/' }))
on('fs.read', (_$: any, e: any) => ({ value: e.path.endsWith('login.tsx') ? "import { start } from '../auth/session'\n" : posix(e.path).endsWith('/bands.json') ? opts.bands : '' }))
on('fs.write', (_$: any, e: any) => {
if (opts.pageFails && e.path.endsWith('.html')) throw new Error('EACCES')
return (writes[posix(e.path)] = e.text), { value: undefined }
})
on('fs.exists', (_$: any, e: any) => ({ value: posix(e.path).endsWith('/bands.json') ? opts.bands !== undefined : opts.chromeAt !== undefined ? e.path === opts.chromeAt : !opts.noChrome }))
on('env.get', (_$: any, e: any) => ({ value: opts.env?.[e.name] ?? '/tmp/' }))
on('process.run', (_$: any, e: any) => (runs.push(e.argv), { value: { exitCode: opts.renderFails && e.argv[0] === 'sh' ? 1 : 0, stdout: '', stderr: '' } }))
on('ui.open', () => ({ value: { isPlaced: true } }))
on('ui.panes', () => ({ value: [{ id: 'mission-control', title: 'Mission Control', isShown: true, isFocused: true, isPlaced: true }] }))
Expand Down Expand Up @@ -214,6 +219,46 @@ describe('mission-control', () => {
expect(summary([]).tools).toBe(0)
})

test('on Windows the code view is lines, and b opens the drawn map in a browser', async ($, on) => {
const { runs, writes, clock } = engine(on, { env: { OS: 'Windows_NT', TEMP: 'C:\\Users\\me\\AppData\\Local\\Temp' } })
const html = () => Object.entries(writes).find(([p]) => p.endsWith('/mission-control-map.html'))?.[1] ?? ''
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: 'C:\\repo' } as any)
await $.turn.start({ text: 'fix the login bug', turnId: 't1' } as any)
await $.tool.call({ tool: 'Read', file_path: 'C:\\repo\\src\\pages\\login.tsx' } as any)
await $.tool.call({ tool: 'Edit', file_path: 'C:\\repo\\src\\auth\\session.ts', old_string: 'a', new_string: 'b' } as any)
await $.command.run({ command: 'mission', args: 'code' } as any)
const pane = await $.ui.mount({ plugin: 'mission-control', surface: 'terminal', ...PANE } as any)
expect(await pane.find({ type: 'Text', text: /2 files · 1 changed/ })).toBeDefined()
expect(await pane.find({ type: 'Text', text: /^login\.tsx$/ })).toBeDefined() // the name, not the whole C:\ path
expect(await pane.find({ type: 'Text', text: /→ session\.ts/ })).toBeDefined() // the import across backslash paths
expect(await pane.find({ type: 'Image' })).toBeUndefined()
await clock.advance(800)
expect(runs).toHaveLength(0) // no Chrome, and no page until it is asked for

await pane.press({ key: 'open' })
expect(html()).toContain('http-equiv="refresh"')
expect(html()).toContain('session.ts')
expect(runs.some(a => a[0] === 'explorer.exe' && a[1] === 'C:\\Users\\me\\AppData\\Local\\Temp\\mission-control-map.html')).toBe(true) // right in TEMP: no folder to make
// Once open, the page follows each change.
await $.tool.call({ tool: 'Read', file_path: 'C:\\repo\\README.md' } as any)
await clock.advance(800)
expect(html()).toContain('README.md')
await pane.unmount()
})

test('on Windows a page that cannot be written says so, and opens nothing', async ($, on) => {
const { runs } = engine(on, { env: { OS: 'Windows_NT', TEMP: 'C:\\Temp' }, pageFails: true })
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: 'C:\\repo' } as any)
await $.command.run({ command: 'mission', args: 'code' } as any)
const pane = await $.ui.mount({ plugin: 'mission-control', surface: 'terminal', ...PANE } as any)
await pane.press({ key: 'open' })
expect(runs.some(a => a[0] === 'explorer.exe')).toBe(false)
await pane.unmount()
const again = await $.ui.mount({ plugin: 'mission-control', surface: 'terminal', ...PANE } as any)
expect(await again.find({ type: 'Text', text: /Could not write C:\\Temp\\mission-control-map\.html/ })).toBeDefined()
await again.unmount()
})

test('a second session.start keeps one clock, not two', async ($, on) => {
const { clock } = engine(on)
let ticks = 0 // each tick with work running writes now once
Expand Down
Loading
Loading