A live side pane that maps a long Claude Code session: goal, current path, possible detours, decisions, open questions, checkpoints, working files and what to…

A live map of your Claude Code session: where you started, where you are, and how to get back.
<video src="https://github.com/user-attachments/assets/112dd1f4-972a-4970-9733-741a9e0cf5d2" width="720" controls muted></video>
Tame your Claude's conversation history. Long Claude Code sessions drift: a quick tangent turns into an hour, a decision made early gets lost in scrollback, and after a break nobody remembers what "next" was. Conversation Atlas is a mod that hooks into your chats and adds a side pane that detects and helps you manage your goals, decisions and even detours while you work:
Observation never becomes intent without you. Atlas can suggest a goal, a detour or a return. Only you turn a suggestion into intent, with a button press or an
/atlascommand.
From the marketplace (once v0.1.0 is on main):
claude plugin marketplace add NeelAPatel/Claude-Mod-ConversationAtlas
claude plugin install conversation-atlas@neel-cc-mods
Requires Claude Code 2.1.287+ (terminal or desktop Code tab). To dock the pane beside the transcript, use /tui fullscreen in a terminal at least 144 columns wide; otherwise open it from the footer button or with /atlas.
Try it from source (the repository root is the plugin):
git clone https://github.com/NeelAPatel/Claude-Mod-ConversationAtlas.git
cd Claude-Mod-ConversationAtlas
claude --plugin-dir .
Install for your user (Windows): .\scripts\install-atlas.ps1 validates the plugin and copies it to ~/.claude/skills/conversation-atlas. Run /reload-plugins in a session that is already open. Don't combine this with --plugin-dir in the same session, or two copies load.
On first launch the pane asks how Atlas should observe. Nothing costs Claude usage until you choose.
Change it any time with /atlas observer [claude|engine].
/atlas goal ship the login refactor sets the goal. Atlas also shows the goal it detected; Use this as my goal makes it yours./atlas detour check the flaky test snapshots where you left off and starts a side trip. Decisions made during it are kept as its outcomes./atlas return ends the detour and sends Claude one short packet: the goal, topic, next step and settled decisions from the moment you left. /atlas promote makes the detour the new goal.| Tab | Key | What it shows |
|---|---|---|
| Map | m | Goal, current topic path, detours, live activity, working set, resume next |
| Trail | t | Topic tree and a timeline of prompts, shifts, decisions, detours, checkpoints |
| Open | o | Everything waiting on you: suggestions, decisions to settle, open questions |
| Evidence | e | Checkpoints, settled-decision ledger, past detours, files, earlier sessions |
In the terminal the bottom bar holds Legend (l), decisions (d), open questions (q) and + Mark (k). On the desktop, Legend and + Mark sit next to the tabs and scroll with the pane. Legend’s How to use starts collapsed; expand it for instructions, Observer mode and count meanings. Chat ⇒ on any item puts an [Atlas #n: …] chip in your draft; only chips still there when you send reach Claude. Details: docs/REFERENCE.md.
Open decisions use Confirm / Drop (detour findings keep Keep / Exclude). Open questions take Confirm (answered) and Drop; resolved questions keep Reopen. Claude can also report them answered. Press an expanded row to collapse it; its other actions wrap on the left.
Decisions show ! for major choices and a dim · for minor ones after their icon. Atlas assigns weight from deterministic text cues; expand a decision and press Make minor or Make major to override it. The detail shows auto or you. Weight changes preserve confirmation status and add no Trail event or intent.
Atlas can pick up a chat it wasn't watching, or one you left yesterday.
| Situation | What happens | Cost |
|---|---|---|
| Atlas installed or reloaded mid-chat, or you resume an old chat | Atlas replays the earlier messages: your prompts, files read and edited, test and commit checkpoints, a goal suggestion, and a question left open | Free |
| You want topics and decisions for that history | /atlas scan (or Map earlier conversation in Trail) asks Claude once over the session's own transcript. Results are observations; confirm what is true in the Open tab | One mostly cached request |
| A new session in the same project | Atlas offers to Resume the last goal | Free |
| Older sessions | /atlas recover lists them; /atlas recover 2 resumes number 2. Evidence → Earlier sessions does the same with buttons | Free |
Two ways to resume an earlier session:
/atlas recover <n> full confirm.Nothing is adopted automatically; every resume is an explicit press or command. Set scanOnLaunch to claude to also run the one-request scan whenever Atlas joins a chat that already has history.
Everything is under /atlas (/atlas help lists them).
| Command | Description | ||
|---|---|---|---|
/atlas | Open the pane | ||
/atlas goal <text> | Set your goal | ||
/atlas next <step> | Pin the next step | ||
/atlas mark [name] | Add a marked checkpoint | ||
/atlas decision <text> [--reason <why>] | Record a settled decision | ||
/atlas detour <reason> | Take a detour | ||
/atlas outcome <finding> / /atlas exclude <material> | Keep or set aside a finding made during a detour | ||
/atlas return | End the detour and send the return packet | ||
/atlas promote | Promote the detour to the goal | ||
/atlas scan | Map the conversation so far (one cached Claude request) | ||
/atlas recover [n] / /atlas recover <n> full [confirm] | List earlier sessions, resume one, or replace the map with one | ||
| `/atlas observer [claude\ | engine]` | Show or change the observer mode | |
| `/atlas hide <b\ | h> / show <b\ | h> / filters` | Hide, show or list report-backs (b) and hand-offs (h) in Trail |
/atlas setup | Show the setup screen again | ||
/atlas reset | Clear this session's map (saved files are kept) |
Settings live in /config → conversation-atlas, or pluginConfigs in your settings:
| Setting | Values | Default | Effect |
|---|---|---|---|
observer | claude and engine, engine only | claude and engine | Preselected choice on the setup screen. After setup, your stored choice wins |
scanOnLaunch | engine, claude, off | engine | What Atlas does when it joins a chat that already has history |
<project>/.claude/atlas/ (add that to .gitignore if you don't want session maps committed).observe tool call on turns where something changed. Each call re-reads the cached context, so the cost grows with chat length: in one long-session measurement it came to roughly 10% of that session's tokens. That is a known rough edge, and some hooks and updates will be optimized in future releases to bring it down. Until then, engine-only mode is the free option, and the observer is worth it when topic and decision tracking matters to you./atlas scan is always an explicit action: one forked, mostly cached request.observe.Ideas that build directly on what Atlas already captures:
Contributions are welcome: bug reports, ideas and pull requests. Read CONTRIBUTING.md first; architecture and invariants live in docs/ARCHITECTURE.md and AGENTS.md, and security reports go through SECURITY.md. Everyone participating follows the Code of Conduct.
This is my first open-source project, so ideas, feedback and discussions are very welcome.
MIT © 2026 NeelAPatel.
hooks/register.tsx 1162 lines1// Conversation Atlas: a self-updating map of a long Claude Code session in a docked pane.
2//
3// Three feeds write OBSERVATIONS, none of them intent:
4// engine events tool calls, files, tests, commits, subagents, questions (no tokens)
5// your prompts the first request, side-trip / return / decision wording (no tokens)
6// Claude one short `observe` call when the topic moves or a decision lands
7// INTENT (goal, detour, return, next step, marks) changes only when you press a pane
8// button or type /atlas. See types/index.d.ts and hooks/model.ts.
9
10import { type EngineInterface, type Register, type Timer, update } from 'claude-code'
11
12import type { AtlasMode, AtlasScanState, AtlasSnapshot, AtlasTrailView, AtlasView } from '../types'
13import { askedQuestions, classify, planTitle, posix } from './activity'
14import type { LiveRow } from './live'
15import { expectedReportPath, handoffStart, parseHandoffReport, reportChanged, reportFingerprint } from './delegation'
16import {
17 addCheckpoint,
18 addQuestion,
19 clearFresh,
20 closeUnverifiedHandoffs,
21 clip,
22 confirmSuggestion,
23 dismissSuggestion,
24 emptySnapshot,
25 endActivity,
26 hydrate,
27 intentLine,
28 mark,
29 observe,
30 type ObserveReport,
31 offerResume,
32 openQuestions,
33 promoteDetour,
34 reportHandoff,
35 resolveQuestions,
36 reportMarker,
37 returnFromDetour,
38 sentences,
39 setGoal,
40 setItemStatus,
41 toggleDecisionWeight,
42 setHandoffTaskId,
43 setNextStep,
44 startActivity,
45 startHandoff,
46 startDetour,
47 startTurn,
48 summary,
49 takeContext,
50 upgrade,
51 touchFile,
52 addDecision,
53 addDetourFinding,
54 adoptRecall,
55 hasReplaceableContent,
56 recoverFull,
57 recordDecision,
58 setRecall,
59} from './model'
60import { applyMap, MAP_PROMPT, parseMap, replay, type ScanRow } from './scan'
61import {
62 ATLAS_DIR,
63 fromAtlasFile,
64 fromAtlasFullFile,
65 atlasFullFileError,
66 mergeRecall,
67 pickRecallEntries,
68 safeName,
69 saveFile,
70 sessionsToDelete,
71 type AtlasFullRecall,
72} from './recall'
73import { type Action, oneLine, pane, TONES } from './view'
74import type { Surface } from './ui/shared'
75
76const PLUGIN = 'conversation-atlas'
77const SNAP = { plugin: 'conversation-atlas', key: 'snapshot' } as const
78const VIEW = { plugin: 'conversation-atlas', key: 'view' } as const
79const SCAN = { plugin: 'conversation-atlas', key: 'scanning' } as const
80const PANE = 'atlas'
81const TOOL = 'mcp__conversation-atlas__observe'
82const TRAIL_VIEW_STORE = 'trail-view'
83const FLASH_MS = 4_000
84const SAVE_MS = 2_000
85const KEEP_SESSIONS = 12
86const SCAN_RESULT_MS = 4_000
87const MODE = { plugin: 'conversation-atlas', key: 'mode' } as const
88const DEFAULT_VIEW: AtlasViewState = {
89 setup: false,
90 tab: 'map',
91 refs: {},
92 nextRef: 1,
93 editingGoal: false,
94 legend: false,
95 legendHowTo: false,
96 popup: null,
97 popupScroll: 0,
98 legendScroll: 0,
99 scroll: 0,
100 trailNewest: true,
101 trailView: 'story',
102 expanded: null,
103 expandedScroll: 0,
104 fullConfirm: null,
105 hidden: [],
106 settingsPage: 0,
107}
108
109type AtlasViewState = AtlasView & { hidden: string[]; settingsPage: number }
110
111const RULES = `# Conversation Atlas
112 A side pane maps this session for the user. Keep it accurate with ${TOOL}: at the end of a turn where the topic moved, a decision was reached,
113 a question opened or closed, or a milestone landed, call it once with only the fields that changed (short phrases, at most 6 words for a topic).
114 Call ${TOOL} in parallel with another tool call already needed in this response; never alone in a final round.
115 If none is needed, skip it and report it on the next tool-using turn.
116 shift: "same" (refining the current topic), "subtopic" (going deeper), "sibling" (next part of the same work),
117 "possible-detour" (a side trip away from the user's goal), "return" (back to earlier work; name that topic). Skip it on trivial turns.
118 Report goal whenever the user's apparent overall aim changes; Atlas shows that as an observation, never as a confirmed goal.
119 It records observations only: never say the user's confirmed goal changed and never treat a detour as accepted;
120 the user confirms goals, detours and returns in the pane.
121 Do not mention the atlas to the user.`
122
123type Raw = Record<string, unknown>
124
125function taskIdOf(value: unknown): string | null {
126 if (!value || typeof value !== 'object') return null
127 const record = value as Raw
128 for (const key of ['taskId', 'task_id', 'task-id']) {
129 if (typeof record[key] === 'string' && record[key]) return record[key] as string
130 }
131 for (const key of ['result', 'task', 'metadata']) {
132 const nested = taskIdOf(record[key])
133 if (nested) return nested
134 }
135 return null
136}
137
138// A Client's props must be plain JSON: no undefined fields.
139function plain(rows: LiveRow[]): LiveRow[] {
140 const clean = (segs: LiveRow['segs']) => segs.map(s => Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined && v !== false)) as LiveRow['segs'][number])
141 return rows.map(r => (r.right?.length ? { segs: clean(r.segs), right: clean(r.right) } : { segs: clean(r.segs) }))
142}
143
144let sid = ''
145let root = ''
146let flashTimer: Timer | null = null
147let lastSaved: AtlasSnapshot | undefined
148let observedThisTurn = false
149let activitySeq = 0
150type ScrollBounds = {
151 bodyRows: number
152 maxScroll: number
153 maxPopupScroll: number
154 maxExpandedScroll: number
155 maxLegendScroll: number
156}
157const EMPTY_BOUNDS: ScrollBounds = {
158 bodyRows: 0, maxScroll: 0, maxPopupScroll: 0, maxExpandedScroll: 0, maxLegendScroll: 0,
159}
160// Each surface owns the bounds measured by its last draw.
161const surfaceBounds = new Map<Surface, ScrollBounds>()
162const drawnSurfaces = new Set<Surface>()
163
164function wheelBounds(bodyRows: number): ScrollBounds {
165 const bounds = [...surfaceBounds.values()]
166 const matches = bounds.filter(bound => bound.bodyRows === bodyRows)
167 if (matches.length === 1) return matches[0] ?? EMPTY_BOUNDS
168 // Wheel events have no surface identity. Ambiguous sizes must not impose
169 // the smaller surface's bounds on the larger one; each draw still clamps.
170 return bounds.reduce((largest, bound) => ({
171 bodyRows,
172 maxScroll: Math.max(largest.maxScroll, bound.maxScroll),
173 maxPopupScroll: Math.max(largest.maxPopupScroll, bound.maxPopupScroll),
174 maxExpandedScroll: Math.max(largest.maxExpandedScroll, bound.maxExpandedScroll),
175 maxLegendScroll: Math.max(largest.maxLegendScroll, bound.maxLegendScroll),
176 }), EMPTY_BOUNDS)
177}
178let scanOnLaunch: 'off' | 'engine' | 'claude' = 'engine'
179let configuredMode: AtlasMode = 'claude'
180let observerToolRegistered = false
181const agentNames = new Map<string, string>()
182
183// ------------------------------------------------------------------ state
184
185async function snap($: EngineInterface): Promise<AtlasSnapshot | undefined> {
186 const value = (await $.state.get(SNAP)).value
187 return value ? upgrade(value) : value
188}
189
190function isScanState(value: unknown): value is AtlasScanState {
191 if (!value || typeof value !== 'object') return false
192 const scan = value as Partial<AtlasScanState>
193 return typeof scan.active === 'boolean' && typeof scan.startedAt === 'number' && (typeof scan.result === 'string' || scan.result === null) && typeof scan.resultAt === 'number'
194}
195
196async function scanState($: EngineInterface): Promise<AtlasScanState> {
197 const value = (await $.state.get(SCAN)).value
198 return isScanState(value) ? value : EMPTY_SCAN
199}
200
201async function ensureScanState($: EngineInterface): Promise<void> {
202 await update($, SCAN, cur => isScanState(cur) ? cur : EMPTY_SCAN)
203}
204
205async function identity($: EngineInterface): Promise<{ sid: string; root: string }> {
206 const [id, r] = await Promise.all([$.session.id(), $.session.root()])
207 return { sid: id, root: posix(r) }
208}
209
210async function edit($: EngineInterface, fn: (s: AtlasSnapshot, now: number) => AtlasSnapshot): Promise<AtlasSnapshot> {
211 const now = await $.clock.now()
212 const before = await snap($)
213 const out = await update($, SNAP, cur => fn(cur && cur.sessionId === sid ? upgrade(cur) : emptySnapshot(sid, root, now), now))
214 if (out.fresh.length && out.fresh.join() !== (before?.fresh ?? []).join()) scheduleUnflash($)
215 return out
216}
217
218function scheduleUnflash($: EngineInterface): void {
219 flashTimer?.cancel()
220 flashTimer = $.clock.after(FLASH_MS, () => {
221 flashTimer = null
222 void edit($, s => clearFresh(s))
223 })
224}
225
226async function setView($: EngineInterface, fn: (v: AtlasViewState) => AtlasViewState): Promise<void> {
227 await update($, VIEW, cur => fn({ ...DEFAULT_VIEW, ...(cur ?? {}) }))
228}
229
230async function trailViewChoice($: EngineInterface): Promise<AtlasTrailView> {
231 const value = await $.store.get(TRAIL_VIEW_STORE)
232 if (!value || typeof value !== 'object') return 'story'
233 const view = (value as { view?: unknown }).view
234 return view === 'log' || view === 'story' ? view : 'story'
235}
236
237async function isLegendOpen($: EngineInterface): Promise<boolean> {
238 const value = (await $.state.get(VIEW)).value
239 return Boolean(value && typeof value === 'object' && (value as Partial<AtlasView>).legend)
240}
241
242type SetupChoice = { observer: AtlasMode; at: number }
243
244function isMode(value: unknown): value is AtlasMode {
245 return value === 'claude' || value === 'engine'
246}
247
248async function setupChoice($: EngineInterface): Promise<SetupChoice | undefined> {
249 const value = await $.store.get('setup')
250 if (!value || typeof value !== 'object') return undefined
251 const raw = value as Raw
252 return isMode(raw.observer) && typeof raw.at === 'number' ? { observer: raw.observer, at: raw.at } : undefined
253}
254
255// Before consent, engine-only is always the effective mode. The userConfig value
256// is only the setup screen's default; a stored choice is authoritative thereafter.
257async function syncMode($: EngineInterface): Promise<AtlasMode> {
258 const choice = await setupChoice($)
259 const mode: AtlasMode = choice?.observer ?? 'engine'
260 await $.state.set(MODE, mode)
261 const trailView = await trailViewChoice($)
262 const savedView = await $.store.get(TRAIL_VIEW_STORE)
263 const hidden = savedView && typeof savedView === 'object' && Array.isArray((savedView as Raw).hidden)
264 ? (savedView as { hidden: unknown[] }).hidden.filter((type): type is string => type === 'b' || type === 'h')
265 : []
266 await setView($, v => ({ ...v, setup: !choice, trailView, hidden }))
267 return mode
268}
269
270async function modeOf($: EngineInterface): Promise<AtlasMode> {
271 const value = (await $.state.get(MODE)).value
272 if (isMode(value)) return value
273 const choice = await setupChoice($)
274 return choice?.observer ?? 'engine'
275}
276
277const OBSERVER_TOOL = {
278 name: 'observe',
279 description: 'Record what moved in this session on the Conversation Atlas pane: topic and shift, decisions, open or resolved questions, the next step, a milestone. Observations only; the user confirms intent.',
280 inputSchema: {
281 type: 'object',
282 properties: {
283 topic: { type: 'string', description: 'What the conversation is about now, at most 6 words' },
284 shift: { type: 'string', enum: ['same', 'subtopic', 'sibling', 'possible-detour', 'return'] },
285 why: { type: 'string', description: 'One line, for possible-detour or return' },
286 decisions: { type: 'array', items: { type: 'string' }, description: 'Conclusions reached this turn' },
287 questions: { type: 'array', items: { type: 'string' }, description: 'Questions left open for the user' },
288 resolved: { type: 'array', items: { type: 'string' }, description: 'Earlier open questions now answered' },
289 next: { type: 'string', description: 'The most useful next step to resume from' },
290 checkpoint: { type: 'string', description: 'A milestone just reached, a few words' },
291 goal: { type: 'string', description: 'The apparent overall aim when it changes; Atlas records it as an observation and never treats it as confirmed' },
292 },
293 },
294} as const
295
296async function registerObserverTool($: EngineInterface): Promise<void> {
297 if (observerToolRegistered) return
298 await $.tool.register(OBSERVER_TOOL)
299 observerToolRegistered = true
300}
301
302async function chooseObserver($: EngineInterface, mode: AtlasMode): Promise<void> {
303 const at = await $.clock.now()
304 await $.store.set('setup', { observer: mode, at })
305 await $.state.set(MODE, mode)
306 await setView($, v => ({ ...v, setup: false, popup: null, popupScroll: 0, scroll: 0 }))
307 if (mode === 'claude') await registerObserverTool($)
308}
309
310// Binds the snapshot to the live session: same id keeps it (a hot reload), a saved one is
311// restored (a resume), otherwise a fresh map that offers the project's last goal.
312async function bind($: EngineInterface): Promise<void> {
313 const who = await identity($)
314 sid = who.sid
315 root = who.root
316 await ensureScanState($)
317 const mode = await syncMode($)
318 const cur = await snap($)
319 if (cur?.sessionId === sid) return
320 const now = await $.clock.now()
321 const saved = await $.store.get(`session:${sid}`)
322 let next = saved ? hydrate(saved, sid, root, now) : emptySnapshot(sid, root, now)
323 if (!saved) {
324 const previous = (await $.store.get(`project:${root}`)) as (ReturnType<typeof summary> & { sessionId?: string }) | undefined
325 if (previous && previous.sessionId !== sid) next = offerResume(next, previous, now)
326 }
327 // Joined a thread that already has history (installed or reloaded mid-session, resumed
328 // without a save): replay it for free so the map starts where the conversation is.
329 const rows = scanOnLaunch === 'off' || next.scanned !== 'none' ? [] : await history($)
330 if (rows.length) next = { ...replay(next, rows, now), scanned: 'engine' }
331 await $.state.set(SNAP, next)
332 if (rows.length > 2 && scanOnLaunch === 'claude' && mode === 'claude') void mapWithClaude($).catch(() => undefined)
333 lastSaved = saved ? next : undefined
334 void loadRecall($).catch(() => undefined)
335}
336
337async function history($: EngineInterface): Promise<ScanRow[]> {
338 try {
339 const rows = await $.session.messages()
340 return Array.isArray(rows) ? (rows as ScanRow[]) : []
341 } catch {
342 return []
343 }
344}
345
346// One forked request over the session's own transcript (served mostly from the prompt
347// cache): topics, decisions, open questions and a next step, added as observations.
348async function mapWithClaude($: EngineInterface): Promise<string> {
349 const current = await scanState($)
350 if (current.active) return 'A scan is already running.'
351 const startedAt = await $.clock.now()
352 await update($, SCAN, cur => {
353 const previous = isScanState(cur) ? cur : EMPTY_SCAN
354 return previous.active ? previous : { active: true, startedAt, result: null, resultAt: 0 }
355 })
356 scanResultTimer?.cancel()
357 scanResultTimer = null
358 let outcome = ''
359 try {
360 $.ui.toast('Atlas: mapping the conversation so far…')
361 const reply = await $.model.fork({ prompt: MAP_PROMPT })
362 if (!reply.isAnswered) outcome = `Atlas could not map the conversation: ${reply.reason}`
363 else {
364 const map = parseMap(reply.text)
365 if (!map) outcome = 'Atlas could not read the map Claude returned.'
366 else {
367 await edit($, (s, now) => ({ ...applyMap(s, map, now), scanned: 'claude' }))
368 outcome = `Mapped ${map.topics.length} topics, ${map.rest.decisions?.length ?? 0} decisions, ${map.rest.questions?.length ?? 0} open questions. All are observations: confirm what is true in the Open tab.`
369 }
370 }
371 } catch (err) {
372 outcome = `Atlas could not map the conversation: ${err instanceof Error ? err.message : String(err)}`
373 } finally {
374 const result = outcome || 'Atlas could not map the conversation.'
375 const resultAt = await $.clock.now()
376 await update($, SCAN, cur => {
377 const previous = isScanState(cur) ? cur : EMPTY_SCAN
378 return { ...previous, active: false, startedAt, result, resultAt }
379 })
380 scanResultTimer = $.clock.after(SCAN_RESULT_MS, () => {
381 scanResultTimer = null
382 void update($, SCAN, cur => {
383 if (!isScanState(cur) || cur.resultAt !== resultAt) return isScanState(cur) ? cur : EMPTY_SCAN
384 return { ...cur, result: null, resultAt: 0 }
385 }).catch(() => undefined)
386 })
387 $.ui.toast(result)
388 }
389 return outcome || 'Atlas could not map the conversation.'
390}
391
392// Earlier Atlas save files from this project's .claude folder.
393async function loadRecall($: EngineInterface): Promise<number> {
394 const found: Parameters<typeof mergeRecall>[0] = []
395 const path = `${root}/${ATLAS_DIR}`
396 let entries: { name: string; kind: string }[] = []
397 try {
398 entries = await $.fs.list(path)
399 } catch {
400 entries = []
401 }
402 const candidates = entries.filter(x => x.kind === 'file' && x.name.endsWith('.json'))
403 const dated = await Promise.all(candidates.map(async entry => {
404 try {
405 const stat = await $.fs.stat(`${path}/${entry.name}`)
406 return { name: entry.name, mtimeMs: stat.mtimeMs }
407 } catch {
408 return { name: entry.name }
409 }
410 }))
411 for (const entry of pickRecallEntries(dated, 80)) {
412 try {
413 const text = await $.fs.read(`${path}/${entry.name}`)
414 const hit = typeof text === 'string' ? fromAtlasFile(text, root) : null
415 if (hit) found.push(hit)
416 } catch {
417 continue
418 }
419 }
420 const recall = mergeRecall(found)
421 await edit($, s => setRecall(s, recall))
422 return recall.filter(r => r.sessionId !== sid).length
423}
424
425async function readFullRecall(
426 $: EngineInterface,
427 recall: { source: string; sessionId: string },
428): Promise<{ source: AtlasFullRecall | null; error: string | null }> {
429 if (recall.source !== 'atlas') return { source: null, error: 'full recovery is available only for Atlas save files' }
430 try {
431 const text = await $.fs.read(`${root}/${ATLAS_DIR}/${safeName(recall.sessionId)}.json`)
432 if (typeof text !== 'string') return { source: null, error: 'save file is not text' }
433 const source = fromAtlasFullFile(text, root)
434 if (source) return { source, error: null }
435 return { source: null, error: atlasFullFileError(text, root) ?? 'invalid Atlas save' }
436 } catch {
437 return { source: null, error: 'could not read the save file' }
438 }
439}
440
441// External agents cannot send a turn notification back through the engine. A
442// report file is the deliberately visible bridge; malformed or still-written
443// files are ignored until a later turn/timer check.
444async function checkHandoffReports($: EngineInterface): Promise<void> {
445 const current = await snap($)
446 const open = current?.handoffs.filter(h => h.status === 'open' && h.reportPath) ?? []
447 if (!open.length) return
448 for (const handoff of open) {
449 let raw: unknown
450 try {
451 raw = await $.fs.read(handoff.reportPath as string)
452 } catch {
453 continue
454 }
455 if (!reportChanged(handoff.reportFingerprint, raw)) continue
456 const report = parseHandoffReport(raw)
457 if (!report) continue
458 await edit($, (s, now) => {
459 let next = reportHandoff(s, report.summary, now, handoff.agent, handoff.id, report.tests, report.files)
460 for (const file of report.files) next = touchFile(next, file, 'write', now, handoff.agent)
461 return next
462 })
463 }
464}
465
466async function ensureBound($: EngineInterface): Promise<void> {
467 await setup($)
468 if (!sid || (await $.session.id()) !== sid) await bind($)
469}
470
471async function save($: EngineInterface): Promise<void> {
472 const s = await snap($)
473 if (!s || s === lastSaved || s.sessionId !== sid) return
474 lastSaved = s
475 const savedAt = await $.clock.now()
476 await $.store.set(`session:${s.sessionId}`, { ...s, fresh: [], pendingContext: [], savedAt })
477 await $.store.set(`project:${s.root}`, { ...summary(s), sessionId: s.sessionId })
478 // The project-local copy, so a later session (or another tool) finds this trail on disk.
479 if (s.goal || s.topics.length || s.decisions.length) await $.fs.write(`${s.root}/${ATLAS_DIR}/${safeName(s.sessionId)}.json`, saveFile(s, savedAt))
480 const keys = (await $.store.keys()).filter(k => k.startsWith('session:'))
481 const entries = await Promise.all(keys.map(async key => ({ key, value: await $.store.get(key) })))
482 const stored = entries.map(entry => {
483 const value = entry.value
484 const savedAt = value && typeof value === 'object' && typeof (value as { savedAt?: unknown }).savedAt === 'number'
485 ? (value as { savedAt: number }).savedAt
486 : 0
487 return { key: entry.key, savedAt }
488 })
489 for (const old of sessionsToDelete(stored, `session:${s.sessionId}`, KEEP_SESSIONS)) await $.store.delete(old)
490}
491
492async function openPane($: EngineInterface, focus: boolean): Promise<boolean> {
493 const opened = await $.ui.open({ id: PANE, title: 'Atlas', columns: 46, ...(focus ? { focus: true } : {}) })
494 return opened.isPlaced
495}
496
497// ------------------------------------------------------------------ actions (the only intent writers)
498
499async function act($: EngineInterface, a: Action, surface: Surface): Promise<void> {
500 const { maxScroll, maxPopupScroll, maxExpandedScroll, maxLegendScroll } = surfaceBounds.get(surface) ?? EMPTY_BOUNDS
501 switch (a.type) {
502 case 'tab':
503 return setView($, v => ({ ...v, tab: a.tab, scroll: 0, legend: false, popup: null, popupScroll: 0, expanded: null, expandedScroll: 0 }))
504 case 'legend':
505 return setView($, v => ({ ...v, legend: !v.legend, legendScroll: 0, popup: null, popupScroll: 0 }))
506 case 'legend-howto':
507 return setView($, v => ({ ...v, legendHowTo: !v.legendHowTo, legendScroll: 0, popup: null, popupScroll: 0 }))
508 case 'dismiss-goal-alt':
509 // Dismissals belong to the session view and survive changes to the confirmed goal.
510 return setView($, v => ({
511 ...v,
512 dismissedGoalAlts: [...(v.dismissedGoalAlts ?? []).filter(text => text !== clip(a.text, 140)), clip(a.text, 140)].slice(-20),
513 popup: null,
514 popupScroll: 0,
515 }))
516 case 'open-setup':
517 return setView($, v => ({ ...v, setup: true, legend: false, popup: null, popupScroll: 0, scroll: 0 }))
518 case 'set-observer':
519 await chooseObserver($, a.mode)
520 return
521 case 'toggle-observer': {
522 const current = await modeOf($)
523 await chooseObserver($, current === 'claude' ? 'engine' : 'claude')
524 return
525 }
526 case 'popup':
527 return setView($, v => ({
528 ...v,
529 legend: false,
530 expanded: null,
531 expandedScroll: 0,
532 popup: v.popup?.kind === a.popup.kind && v.popup.id === a.popup.id ? null : a.popup,
533 popupScroll: 0,
534 settingsPage: a.popup.kind === 'trail-view' ? 0 : v.settingsPage,
535 }))
536 case 'trail-settings-page':
537 return setView($, v => ({ ...v, settingsPage: Math.max(0, Math.min(2, a.page)), popupScroll: 0 }))
538 case 'trail-filter': {
539 const current = (await $.state.get(VIEW)).value as AtlasViewState | undefined
540 const hidden = (current?.hidden ?? []).includes(a.filter)
541 ? (current?.hidden ?? []).filter(type => type !== a.filter)
542 : [...(current?.hidden ?? []), a.filter]
543 await $.store.set(TRAIL_VIEW_STORE, { view: current?.trailView ?? 'story', hidden, at: await $.clock.now() })
544 return setView($, v => ({ ...v, hidden }))
545 }
546 case 'popup-scroll':
547 return setView($, v => ({ ...v, popupScroll: Math.max(0, Math.min(maxPopupScroll, v.popupScroll + a.by)) }))
548 case 'expanded-scroll':
549 return setView($, v => ({ ...v, expandedScroll: Math.max(0, Math.min(maxExpandedScroll, v.expandedScroll + a.by)) }))
550 case 'scroll':
551 if (await isLegendOpen($)) return setView($, v => ({ ...v, legendScroll: Math.max(0, Math.min(maxLegendScroll, v.legendScroll + a.by)) }))
552 return setView($, v => ({ ...v, scroll: Math.max(0, Math.min(maxScroll, v.scroll + a.by)), popup: null, popupScroll: 0 }))
553 case 'scroll-to':
554 return setView($, v => ({ ...v, scroll: Math.max(0, Math.min(maxScroll, a.at)), popup: null, popupScroll: 0 }))
555 case 'trail-sort':
556 return setView($, v => ({ ...v, trailNewest: !v.trailNewest, popupScroll: 0, expanded: null, expandedScroll: 0 }))
557 case 'trail-view-set':
558 case 'trail-view': {
559 const at = await $.clock.now()
560 const current = await $.store.get(TRAIL_VIEW_STORE)
561 const hidden = current && typeof current === 'object' && Array.isArray((current as Raw).hidden)
562 ? (current as { hidden: string[] }).hidden
563 : []
564 await $.store.set(TRAIL_VIEW_STORE, { view: a.view, hidden, at })
565 return setView($, v => ({
566 ...v,
567 trailView: a.view,
568 ...(a.type === 'trail-view' ? { popup: null } : {}),
569 popupScroll: 0,
570 expanded: null,
571 expandedScroll: 0,
572 }))
573 }
574 case 'expand':
575 return setView($, v => ({ ...v, expanded: v.expanded === a.id ? null : a.id, expandedScroll: 0, popup: null, popupScroll: 0 }))
576 case 'exclude':
577 await edit($, (s, now) => setItemStatus(s, a.id, 'excluded', now))
578 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
579 return
580 case 'scan': {
581 const rows = await history($)
582 if ((await snap($))?.scanned === 'none' && rows.length) await edit($, (s, now) => ({ ...replay(s, rows, now), scanned: 'engine' }))
583 await mapWithClaude($)
584 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
585 return
586 }
587 case 'adopt':
588 await edit($, (s, now) => adoptRecall(s, a.id, now))
589 await setView($, v => ({ ...v, popup: null, popupScroll: 0, fullConfirm: null }))
590 $.ui.toast('Resumed: the earlier goal, next step and decisions are back')
591 return
592 case 'adopt-full': {
593 const current = await snap($)
594 const recall = current?.recall.find(candidate => candidate.id === a.id)
595 if (!current || !recall || recall.source !== 'atlas') return
596 const view = (await $.state.get(VIEW)).value as AtlasView | undefined
597 if (hasReplaceableContent(current) && view?.fullConfirm !== a.id) {
598 await setView($, v => ({ ...v, fullConfirm: a.id, popup: null, popupScroll: 0 }))
599 return
600 }
601 const loaded = await readFullRecall($, recall)
602 const source = loaded.source
603 if (!source) {
604 $.ui.toast(`Can't load that save: ${loaded.error ?? 'invalid Atlas save'}`)
605 return
606 }
607 await edit($, (s, now) => recoverFull(s, source, now))
608 await setView($, v => ({ ...v, popup: null, popupScroll: 0, fullConfirm: null }))
609 $.ui.toast('Loaded the full saved map; the earlier session is now this map')
610 return
611 }
612 case 'attach': {
613 // A chip in the draft is the only way Atlas text reaches Claude from the pane.
614 let n = 1
615 await setView($, v => {
616 n = v.nextRef
617 return { ...v, refs: { ...v.refs, [String(n)]: a.ref }, nextRef: n + 1, popup: null, popupScroll: 0 }
618 })
619 const chip = ` [Atlas #${n}: ${a.ref.kind.toLowerCase()} "${clip(a.ref.text, 28)}"] `
620 const filled = await $.prompt.fill({ text: chip, mode: 'insert' })
621 $.ui.toast(filled.isFilled ? `Added to your message as Atlas #${n}; delete the chip to not send it` : 'Atlas: could not add to the message (a dialog is open?)')
622 return
623 }
624 case 'edit-goal':
625 return setView($, v => ({ ...v, editingGoal: !v.editingGoal, popup: null, popupScroll: 0 }))
626 case 'goal': {
627 const s = await edit($, (cur, now) => setGoal(cur, a.text, 'person', now))
628 await setView($, v => ({ ...v, editingGoal: false, popup: null, popupScroll: 0 }))
629 if (s.detour) $.ui.toast('Return from the detour (or make it the goal) before changing the goal')
630 return
631 }
632 case 'pin':
633 await edit($, (s, now) => setNextStep(s, a.text, now))
634 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
635 return
636 case 'confirm': {
637 const before = await snap($)
638 const kind = before?.suggestions.find(x => x.id === a.id)?.kind
639 await edit($, (s, now) => confirmSuggestion(s, a.id, now))
640 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
641 if (kind === 'return') $.ui.toast('Return packet goes to Claude with your next message')
642 if (kind === 'detour') $.ui.toast('Detour started; the atlas remembers where to come back to')
643 return
644 }
645 case 'dismiss':
646 await edit($, (s, now) => dismissSuggestion(s, a.id, now))
647 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
648 return
649 case 'weight':
650 await edit($, s => toggleDecisionWeight(s, a.id))
651 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
652 return
653 case 'settle':
654 await edit($, (s, now) => setItemStatus(s, a.id, 'settled', now))
655 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
656 return
657 case 'drop':
658 await edit($, (s, now) => setItemStatus(s, a.id, 'drop', now))
659 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
660 return
661 case 'restore':
662 await edit($, (s, now) => setItemStatus(s, a.id, 'observed', now))
663 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
664 return
665 case 'resolve':
666 await edit($, (s, now) => setItemStatus(s, a.id, 'resolved', now))
667 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
668 return
669 case 'reopen':
670 await edit($, (s, now) => setItemStatus(s, a.id, 'open', now))
671 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
672 return
673 case 'return': {
674 await edit($, (s, now) => returnFromDetour(s, now))
675 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
676 $.ui.toast('Return packet goes to Claude with your next message')
677 return
678 }
679 case 'promote':
680 await edit($, (s, now) => promoteDetour(s, now))
681 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
682 return
683 case 'mark':
684 await edit($, (s, now) => mark(s, '', now))
685 await setView($, v => ({ ...v, popup: null, popupScroll: 0 }))
686 $.ui.toast('Checkpoint marked')
687 return
688 }
689}
690
691const HELP = [
692 'Atlas commands (the pane buttons do the same):',
693 ' /atlas open the pane',
694 ' /atlas goal <text> set your goal (alias: aim)',
695 ' /atlas next <step> pin the next step',
696 ' /atlas mark [name] mark a checkpoint to come back to',
697 ' /atlas decision <text> [--reason <why>] record a settled decision',
698 ' /atlas detour <reason> start a side trip from the latest mark',
699 ' /atlas outcome <finding> keep a finding from the detour',
700 ' /atlas exclude <material> set detour material aside',
701 ' /atlas return end the detour; Claude gets one recap',
702 ' /atlas promote make the detour your goal',
703 ' /atlas observer [claude|engine] show or change the observer mode',
704 ' /atlas hide <b|h> hide report-backs (b) or hand-offs (h)',
705 ' /atlas show <b|h> show a hidden type',
706 ' /atlas filters list hidden types',
707 ' Trail: use ≡ for Trail settings: view, order and hidden types',
708 ' /atlas setup show the first-run setup screen again',
709 ' /atlas scan map the conversation so far with Claude (one cached request)',
710 ' /atlas recover [n] list earlier sessions, or resume number n',
711 ' /atlas recover <n> full [confirm] replace this map with an Atlas save',
712 " /atlas reset clear this session's map",
713].join('\n')
714
715async function command($: EngineInterface, args: string): Promise<string> {
716 const [verb = '', ...rest] = args.trim().split(/\s+/)
717 const text = rest.join(' ')
718 const s = await snap($)
719 switch (verb) {
720 case '':
721 return (await openPane($, true)) ? 'Atlas opened.' : 'Atlas is waiting for room: widen the terminal or use /tui fullscreen.'
722 case 'help':
723 return HELP
724 case 'filters': {
725 const state = (await $.state.get(VIEW)).value as AtlasViewState | undefined
726 const hidden = state?.hidden ?? []
727 return hidden.length ? `Hidden types: ${hidden.map(type => `${type} (${type === 'b' ? 'report-backs' : 'hand-offs'})`).join(', ')}` : 'no filters'
728 }
729 case 'hide':
730 case 'show': {
731 const type = text.toLowerCase()
732 if (type !== 'b' && type !== 'h') return 'Valid type letters: b (report-backs), h (hand-offs).'
733 const state = (await $.state.get(VIEW)).value as AtlasViewState | undefined
734 const current = state?.hidden ?? []
735 const hidden = verb === 'hide'
736 ? [...new Set([...current, type])]
737 : current.filter(value => value !== type)
738 await $.store.set(TRAIL_VIEW_STORE, { view: state?.trailView ?? 'story', hidden, at: await $.clock.now() })
739 await setView($, value => ({ ...value, hidden }))
740 const name = type === 'b' ? 'report-backs' : 'hand-offs'
741 return verb === 'hide' ? `Hidden ${type} (${name}).` : `Shown ${type} (${name}).`
742 }
743 case 'observer': {
744 const current = await modeOf($)
745 if (!text) {
746 const saved = await setupChoice($)
747 return `Observer: ${current === 'claude' ? 'Claude' : 'Engine only'}${saved ? '' : ` (setup not chosen; /atlas setup, default: ${configuredMode === 'claude' ? 'Claude' : 'Engine only'})`}`
748 }
749 const requested = text.toLowerCase()
750 const mode = requested === 'claude' ? 'claude' : requested === 'engine' || requested === 'engine only' ? 'engine' : null
751 if (!mode) return 'Usage: /atlas observer [claude|engine]'
752 await chooseObserver($, mode)
753 return `Observer set to ${mode === 'claude' ? 'Claude' : 'Engine only'}.`
754 }
755 case 'setup':
756 await setView($, v => ({ ...v, setup: true, legend: false, popup: null, popupScroll: 0, scroll: 0 }))
757 await openPane($, true)
758 return 'Atlas setup opened.'
759 case 'goal':
760 case 'aim':
761 if (!text) return HELP
762 if (s?.detour) return 'Return from the detour (or /atlas promote) before changing the goal.'
763 await edit($, (cur, now) => setGoal(cur, text, 'person', now))
764 return `Goal confirmed: ${clip(text, 140)}`
765 case 'next':
766 if (!text) return HELP
767 await edit($, (cur, now) => setNextStep(cur, text, now))
768 return `Next step pinned: ${clip(text, 140)}`
769 case 'decision': {
770 const [what = '', why] = text.split(/\s--reason\s/)
771 if (!what.trim()) return HELP
772 await edit($, (cur, now) => recordDecision(cur, what.trim(), why?.trim() || null, now))
773 return `Decision settled: ${clip(what, 140)}`
774 }
775 case 'detour':
776 if (!text) return HELP
777 if (s?.detour) return `Already on a detour: ${s.detour.reason}. /atlas return first.`
778 await edit($, (cur, now) => startDetour(cur, text, null, now))
779 return `Detour started: ${clip(text, 120)}`
780 case 'outcome':
781 case 'exclude':
782 if (!s?.detour) return 'No active detour.'
783 if (!text) return HELP
784 await edit($, (cur, now) => addDetourFinding(cur, verb === 'outcome' ? 'outcomes' : 'exclusions', text, now))
785 return `${verb === 'outcome' ? 'Outcome kept' : 'Excluded'}: ${clip(text, 140)}`
786 case 'return':
787 if (!s?.detour) return 'No active detour.'
788 await edit($, (cur, now) => returnFromDetour(cur, now))
789 return `Returned from: ${s.detour.reason}. The return packet goes to Claude with your next message.`
790 case 'promote':
791 if (!s?.detour) return 'No active detour.'
792 await edit($, (cur, now) => promoteDetour(cur, now))
793 return `Goal is now: ${s.detour.reason}`
794 case 'mark':
795 await edit($, (cur, now) => mark(cur, text, now))
796 return 'Checkpoint marked.'
797 case 'recover': {
798 await loadRecall($)
799 const list = (await snap($))?.recall ?? []
800 if (!list.length) return 'No earlier sessions found in this project.'
801 const [numberText = '', mode = '', confirmation = ''] = rest
802 const n = Number(numberText)
803 if (text && Number.isInteger(n) && n >= 1 && n <= list.length) {
804 const pick = list[n - 1]
805 if (!pick) return 'No such session.'
806 if (mode.toLowerCase() === 'full') {
807 if (pick.source !== 'atlas') return 'Full recovery is available only for Atlas save files.'
808 const current = await snap($)
809 if (current && hasReplaceableContent(current) && confirmation.toLowerCase() !== 'confirm') {
810 return 'this replaces your current map; run /atlas recover <n> full confirm to proceed'
811 }
812 const loaded = await readFullRecall($, pick)
813 const source = loaded.source
814 if (!source) return `Can't load that save: ${loaded.error ?? 'invalid Atlas save'}`
815 await edit($, (cur, now) => recoverFull(cur, source, now))
816 return `Loaded full map from session ${pick.sessionId.slice(0, 8)}`
817 }
818 await edit($, (cur, now) => adoptRecall(cur, pick.id, now))
819 return `Resumed ${pick.source} session ${pick.sessionId.slice(0, 8)}: ${pick.goal ?? 'no goal recorded'}`
820 }
821 return [
822 'Earlier sessions (resume with /atlas recover <n>, or in the Evidence tab):',
823 ...list.map((r, i) => ` ${i + 1}. ${r.goal ?? 'no goal'} · ${r.source} · ${r.sessionId.slice(0, 8)}${r.detour ? ` · on detour: ${r.detour}` : ''}${r.nextStep ? ` · next: ${r.nextStep}` : ''}`),
824 ].join('\n')
825 }
826 case 'scan': {
827 const rows = await history($)
828 if (s?.scanned === 'none' && rows.length) await edit($, (cur, now) => ({ ...replay(cur, rows, now), scanned: 'engine' }))
829 return mapWithClaude($)
830 }
831 case 'reset': {
832 const now = await $.clock.now()
833 await $.state.set(SNAP, emptySnapshot(sid, root, now))
834 return 'Atlas cleared for this session.'
835 }
836 default:
837 return `Unknown: ${verb}\n${HELP}`
838 }
839}
840
841// Registration runs once per load, on whichever event reaches the module first. A hot
842// reload does not always raise session.start, so every hook below also calls this.
843let ready: Promise<void> | null = null
844let lastProblem: string | null = null
845let saving: Timer | null = null
846let scanResultTimer: Timer | null = null
847
848const EMPTY_SCAN: AtlasScanState = { active: false, startedAt: 0, result: null, resultAt: 0 }
849
850function setup($: EngineInterface): Promise<void> {
851 ready ??= (async () => {
852 const failed: string[] = []
853 const step = async (name: string, run: () => Promise<unknown>) => {
854 try {
855 await run()
856 } catch (err) {
857 failed.push(name)
858 $.ui.log(`${PLUGIN}: ${name} failed: ${err instanceof Error ? err.message : String(err)}`)
859 }
860 }
861 await step('command /atlas', () => $.command.register({
862 name: 'atlas',
863 description: 'ConversationAtlas: open the pane or set goal, next, mark, decision, detour, hide, show, filters, scan, recover, help',
864 argumentHint: '[goal|next|mark|decision|detour|hide|show|filters|scan|recover|help] [text]',
865 }))
866 await step('session binding', () => bind($))
867 // The tool is inert until setup consent. Registration itself does not call Claude;
868 // this lets tests and an already-equipped model receive the short "off" result.
869 await step('observe tool', () => registerObserverTool($))
870 saving?.cancel()
871 saving = $.clock.every(SAVE_MS, () => {
872 void checkHandoffReports($).catch(() => undefined)
873 void save($).catch(() => undefined)
874 })
875 await step('pane', () => openPane($, false))
876 const problem = failed.join(', ')
877 if (problem !== lastProblem) $.ui.toast(problem ? `Atlas loaded with problems: ${problem}` : 'Atlas ready: /atlas or the footer button')
878 lastProblem = problem
879 // A step that failed (say, before the session bound) is tried again on the next event.
880 if (failed.length) ready = null
881 })()
882 return ready
883}
884
885// ------------------------------------------------------------------ hooks
886
887export const register: Register = (on, options) => {
888 configuredMode = options?.observer === 'engine only' ? 'engine' : 'claude'
889 observerToolRegistered = false
890 scanOnLaunch = options?.scanOnLaunch === 'off' || options?.scanOnLaunch === 'claude' ? options.scanOnLaunch : 'engine'
891 ready = null
892
893 on('session.start', async ($, e, next) => {
894 const result = await next(e)
895 await setup($)
896 return result
897 })
898
899 on('session.end', async ($, e, next) => {
900 scanResultTimer?.cancel()
901 scanResultTimer = null
902 await save($).catch(() => undefined)
903 if (e.reason === 'clear') sid = ''
904 return next(e)
905 })
906
907 on('prompt.compose', async ($, e, next) => {
908 const result = await next(e)
909 if ((await modeOf($)) !== 'claude') return result
910 return { sections: [...result.sections, { id: `${PLUGIN}:rules`, text: RULES, scope: 'session' as const }] }
911 })
912
913 // What the person asked for this turn, and what the atlas hands Claude beside it.
914 on('prompt.submit', async ($, e, next) => {
915 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
916 await ensureBound($)
917 const extra: string[] = []
918 let taken: string[] = []
919 await update($, SNAP, cur => {
920 if (!cur) return emptySnapshot(sid, root, 0)
921 const [ctx, rest] = takeContext(upgrade(cur))
922 taken = ctx
923 return rest
924 })
925 extra.push(...taken)
926 const view = (await $.state.get(VIEW)).value
927 // Only chips still in the text count: a deleted chip sends nothing.
928 const refs = view?.refs ?? {}
929 const seen = new Set<string>()
930 for (const m of e.text.matchAll(/\[Atlas #(\d+)[^\]]*\]/g)) {
931 const n = m[1] ?? ''
932 const ref = refs[n]
933 if (!ref || seen.has(n)) continue
934 seen.add(n)
935 extra.push(`Atlas reference #${n} (${ref.kind}), attached deliberately by the user: ${ref.text}`)
936 }
937 const s = await snap($)
938 const line = (await modeOf($)) === 'claude' && s ? intentLine(s) : null
939 if (line) extra.push(line)
940 return next(extra.length ? { ...e, context: [...(e.context ?? []), ...extra] } : e)
941 })
942
943 on('turn.start', async ($, e, next) => {
944 const result = await next(e)
945 observedThisTurn = false
946 await ensureBound($)
947 await checkHandoffReports($)
948 const rawText = String(e.text ?? '')
949 const marker = reportMarker(rawText)
950 await edit($, (s, now) => {
951 let nextState = startTurn(s, rawText, now)
952 if (marker) {
953 return reportHandoff(nextState, marker.summary, now, marker.taskId ? marker.agent ?? undefined : undefined, marker.taskId ?? undefined)
954 }
955 // Only a genuinely typed remainder closes an unreported hand-off. A
956 // marker-only engine turn therefore neither creates a prompt nor closes
957 // the running delegation.
958 if (nextState.events.at(-1)?.kind === 'prompt') nextState = closeUnverifiedHandoffs(nextState, now)
959 return nextState
960 })
961 return result
962 })
963
964 // A main turn that ends on a question Claude did not report leaves that question open.
965 on('turn.complete', async ($, e, next) => {
966 const result = await next(e)
967 if (e.agentId || e.reason !== 'answer' || observedThisTurn) return result
968 const last = (e.answer.trim().split(/\n\s*\n/).pop() ?? '').trim()
969 if (last.endsWith('?')) {
970 const q = sentences(last).filter(x => x.endsWith('?')).pop()
971 if (q) await edit($, (s, now) => addQuestion(s, q, 'engine', now))
972 }
973 return result
974 })
975
976 on('agent.spawn', async ($, e, next) => {
977 const started = await next(e)
978 if ('agentId' in started && started.agentId) agentNames.set(started.agentId, clip(e.description || e.subagentType, 24))
979 return started
980 })
981
982 on('tool.call', { tool: TOOL }, async ($, e) => {
983 if ((await modeOf($)) !== 'claude') return { result: 'Conversation Atlas observer is off; use /atlas observer claude to turn it on.' }
984 const raw = e as unknown as Raw
985 const list = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').map(x => clip(x, 160)).filter(Boolean).slice(0, 6) : undefined)
986 const shift = typeof raw.shift === 'string' && ['same', 'subtopic', 'sibling', 'possible-detour', 'return'].includes(raw.shift) ? (raw.shift as ObserveReport['shift']) : undefined
987 const report: ObserveReport = {
988 topic: clip(raw.topic, 60) || undefined,
989 shift,
990 why: clip(raw.why, 140) || undefined,
991 decisions: list(raw.decisions),
992 questions: list(raw.questions),
993 resolved: list(raw.resolved),
994 next: clip(raw.next, 140) || undefined,
995 checkpoint: clip(raw.checkpoint, 60) || undefined,
996 goal: clip(raw.goal, 140) || undefined,
997 }
998 await ensureBound($)
999 await edit($, (s, now) => observe(s, report, now))
1000 observedThisTurn = true
1001 return { result: 'noted' }
1002 })
1003
1004 // Every other tool call: an activity row while it runs, then files, tests, commits.
1005 on('tool.call', async ($, e, next) => {
1006 const tool = String(e.tool)
1007 const input = e as unknown as Raw
1008 const c = classify(tool, input)
1009 if (!c) return next(e)
1010 const delegation = handoffStart(tool, input)
1011 await ensureBound($)
1012 const aid = `a${++activitySeq}`
1013 const agent = e.agentId ? (agentNames.get(e.agentId) ?? 'agent') : null
1014 const asked = tool === 'AskUserQuestion' ? askedQuestions(input) : []
1015 let handoffId: string | undefined
1016 const reportPath = delegation ? expectedReportPath(root, delegation.brief) : null
1017 const existingReportFingerprint = reportPath
1018 ? await (async () => {
1019 try {
1020 return reportFingerprint(await $.fs.read(reportPath))
1021 } catch {
1022 return null
1023 }
1024 })()
1025 : null
1026 await edit($, (s, now) => {
1027 let out = startActivity(s, { id: aid, kind: c.kind, label: c.label, at: now, agent })
1028 if (delegation) {
1029 out = startHandoff(out, delegation.label, delegation.agent, delegation.brief, reportPath, now, existingReportFingerprint)
1030 handoffId = out.handoffs.at(-1)?.id
1031 }
1032 for (const q of asked) out = addQuestion(out, q, 'claude', now)
1033 return out
1034 })
1035 let ran: Awaited<ReturnType<typeof next>>
1036 try {
1037 ran = await next(e)
1038 } catch (err) {
1039 await edit($, (s, now) => endActivity(s, aid, 'failed', now))
1040 throw err
1041 }
1042 const failed = ran.deny !== undefined || ran.isError === true
1043 const result = (ran.result && typeof ran.result === 'object' ? ran.result : {}) as Raw
1044 const taskId = taskIdOf(ran) ?? taskIdOf(result)
1045 const commit = (result.gitOperation as { commit?: { sha?: string } } | undefined)?.commit
1046 await edit($, (s, now) => {
1047 let out = endActivity(s, aid, failed ? 'failed' : 'done', now, c.isTest ? `Tests ${failed ? 'failed' : 'passed'}: ${c.label.replace(/^Tests: /, '')}` : undefined)
1048 if (failed) return out
1049 for (const f of c.files) out = touchFile(out, f.path, f.op, now)
1050 if (c.isTest) out = addCheckpoint(out, 'Tests passed', 'tests', c.label.replace(/^Tests: /, ''), now)
1051 if (commit?.sha) out = addCheckpoint(out, `Commit ${commit.sha.slice(0, 7)}`, 'commit', typeof input.command === 'string' ? clip(input.command, 120) : null, now)
1052 if (asked.length) out = resolveQuestions(out, asked, now)
1053 if (tool === 'ExitPlanMode') {
1054 const plan = typeof result.plan === 'string' ? result.plan : typeof input.plan === 'string' ? input.plan : ''
1055 out = addDecision(out, planTitle(plan), 'engine', now)
1056 }
1057 return out
1058 })
1059 if (delegation && !delegation.external) {
1060 const summaryText = `${delegation.agent} ${failed ? 'failed' : 'completed'}`
1061 await edit($, (s, now) => reportHandoff(s, summaryText, now, delegation.agent, handoffId))
1062 }
1063 if (delegation && handoffId && taskId) await edit($, s => setHandoffTaskId(s, handoffId as string, taskId))
1064 return ran
1065 })
1066
1067 on('command.run', { command: 'atlas' }, async ($, e) => {
1068 await ensureBound($)
1069 return { text: await command($, e.args ?? '') }
1070 })
1071
1072 // Always in the footer while the mod is loaded: opens the pane anywhere, at any width.
1073 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1074 const below = await next(e)
1075 const s = await snap($)
1076 const { Box, Button } = $.ui.resolve(e)
1077 const waiting = s ? s.suggestions.length + openQuestions(s).length : 0
1078 const topic = s ? oneLine(s).replace(/^[^\p{L}\p{N}]+/u, '') : ''
1079 const label = s && topic && topic !== 'Atlas' ? clip(`Atlas: ${topic}${waiting ? ` · ${waiting} to review` : ''}`, 40) : waiting ? clip(`Atlas · ${waiting} to review`, 40) : 'Atlas'
1080 const press = async () => {
1081 await openPane($, true)
1082 if (waiting > 0) await setView($, v => ({ ...v, tab: 'open', scroll: 0 }))
1083 }
1084 return (
1085 <Box flexDirection="row" alignItems="center" gap={1}>
1086 <Button key="atlas-open" dimColor={waiting === 0} label={label} onPress={() => void press().catch(() => undefined)} />
1087 {below}
1088 </Box>
1089 )
1090 })
1091
1092 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1093 const t = $.ui.resolve(e)
1094 const { Box, Text } = t
1095 const s = await snap($)
1096 if (!s) return <Text dimColor>Atlas is starting…</Text>
1097 const view = { ...DEFAULT_VIEW, ...((await $.state.get(VIEW)).value ?? {}) }
1098 const choice = await setupChoice($)
1099 const renderView = { ...view, setup: view.setup || !choice }
1100 const mode = await modeOf($)
1101 const now = await $.clock.now()
1102 const scan = await scanState($)
1103 const width = Math.max(20, (e.props.bodyColumns || 48) - 2)
1104 const Client = 'Client' in t ? t.Client : undefined
1105 const live = (key: string, rows: LiveRow[], scanProps?: { active: boolean; startedAt: number; result: string | null; resultAt: number; now: number }) =>
1106 Client ? (
1107 <Client key={`live-${key}`} module="./live.tsx" props={scanProps ? { rows: plain(rows), tones: TONES, scan: scanProps } : { rows: plain(rows), tones: TONES }} />
1108 ) : (
1109 <Box key={`live-${key}`} flexDirection="column">
1110 {rows.map((r, i) => (
1111 <Text key={`static-${i}`} wrap="truncate-end">{r.segs.map(seg => (seg.spin ? '… ' : seg.t)).join('')}</Text>
1112 ))}
1113 </Box>
1114 )
1115 const el = { Box: t.Box, Text: t.Text, Button: t.Button, Input: 'Input' in t ? t.Input : undefined, Select: 'Select' in t ? t.Select : undefined, Svg: 'Svg' in t ? t.Svg : undefined, Link: 'Link' in t ? t.Link : undefined, Code: 'Code' in t ? t.Code : undefined, Markdown: 'Markdown' in t ? t.Markdown : undefined }
1116 const rows = Math.max(8, e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30)
1117 const surface = (e.surface ?? 'terminal') as Surface
1118 const drawn = pane({
1119 el, surface, width, rows, now, mode, setupDefault: configuredMode, scan, view: renderView, live,
1120 act: a => void act($, a, surface).catch(err => $.ui.toast(`atlas: ${err instanceof Error ? err.message : String(err)}`)),
1121 }, s)
1122 drawnSurfaces.add(surface)
1123 surfaceBounds.set(surface, {
1124 bodyRows: rows,
1125 maxScroll: drawn.maxScroll,
1126 maxPopupScroll: drawn.maxPopupScroll,
1127 maxExpandedScroll: drawn.maxExpandedScroll,
1128 maxLegendScroll: drawn.maxLegendScroll,
1129 })
1130 return drawn.tree
1131 })
1132
1133 // The terminal pane scrolls its own body (the app bar stays pinned), unless a popup or
1134 // expanded help or a Trail event is using the same wheel gesture.
1135 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1136 // Desktop moves before this hook fires (#99449); blocking would snap it back.
1137 // With both surfaces drawn, keep the old branches; desktop wheel is swallowed for 0.1.
1138 // Only a non-terminal (GUI) surface has drawn: the host scrolls its full-length tree itself. The seam keeps the GUI's name out of this file.
1139 if (drawnSurfaces.size > 0 && !drawnSurfaces.has('terminal')) return next(e)
1140 const { maxScroll, maxPopupScroll, maxExpandedScroll, maxLegendScroll } = wheelBounds(e.bodyRows)
1141 const view = (await $.state.get(VIEW)).value as AtlasView | undefined
1142 if (view?.popup) {
1143 if (view.popup.kind === 'trail-view') {
1144 await setView($, v => ({ ...v, settingsPage: Math.max(0, Math.min(2, v.settingsPage + Math.sign(e.by))) }))
1145 return {}
1146 }
1147 await setView($, v => ({ ...v, popupScroll: Math.max(0, Math.min(maxPopupScroll, v.popupScroll + e.by)) }))
1148 return {}
1149 }
1150 if (view?.legend) {
1151 await setView($, v => ({ ...v, legendScroll: Math.max(0, Math.min(maxLegendScroll, v.legendScroll + e.by)) }))
1152 return {}
1153 }
1154 if (view?.expanded && maxExpandedScroll > 0) {
1155 await setView($, v => ({ ...v, expandedScroll: Math.max(0, Math.min(maxExpandedScroll, v.expandedScroll + e.by)) }))
1156 return {}
1157 }
1158 await setView($, v => ({ ...v, scroll: Math.max(0, Math.min(maxScroll, v.scroll + e.by)) }))
1159 return {}
1160 })
1161}
1162hooks/activity.ts 107 lines1// Turns a tool call into an activity row and the files it touched. Pure; no `$`.
2
3import type { AtlasActivityKind } from '../types'
4
5export type Classified = {
6 kind: AtlasActivityKind
7 label: string
8 files: { path: string; op: 'read' | 'write' }[]
9 isTest: boolean
10}
11
12type Input = Record<string, unknown>
13
14const str = (v: unknown): string => (typeof v === 'string' ? v : '')
15
16export function posix(path: string): string {
17 return path.replace(/\\/g, '/')
18}
19
20export function base(path: string): string {
21 const p = posix(path).replace(/\/+$/, '')
22 return p.slice(p.lastIndexOf('/') + 1) || p
23}
24
25// Path shown relative to the project root when inside it.
26export function rel(path: string, root: string): string {
27 const p = posix(path)
28 const r = posix(root).replace(/\/+$/, '')
29 if (r && p.toLowerCase().startsWith(`${r.toLowerCase()}/`)) return p.slice(r.length + 1)
30 return p
31}
32
33const TEST_CMD = /(^|[\s;&|(])((npm|pnpm|yarn|bun)( run)? test|npx (vitest|jest|mocha|playwright)|vitest|jest|pytest|py\.test|cargo test|go test|dotnet test|mvn test|gradle test|node --test|deno test|claude plugin test|rspec|phpunit|ctest|make (test|check))\b/
34const GIT_CMD = /(^|[\s;&|(])(git|gh)\s+(\w+)/
35
36export function isTestCommand(command: string): boolean {
37 return TEST_CMD.test(command)
38}
39
40function shortCommand(command: string): string {
41 const first = command.split('\n')[0] ?? command
42 return first.replace(/^cd\s+\S+\s*&&\s*/, '').trim()
43}
44
45export function classify(tool: string, input: Input): Classified | null {
46 const none: Classified['files'] = []
47 switch (tool) {
48 case 'Read': {
49 const path = posix(str(input.file_path))
50 return { kind: 'read', label: `Read ${base(path)}`, files: path ? [{ path, op: 'read' }] : none, isTest: false }
51 }
52 case 'Edit':
53 case 'MultiEdit':
54 case 'Write':
55 case 'NotebookEdit': {
56 const path = posix(str(input.file_path) || str(input.notebook_path))
57 const verb = tool === 'Write' ? 'Write' : 'Edit'
58 return { kind: 'edit', label: `${verb} ${base(path)}`, files: path ? [{ path, op: 'write' }] : none, isTest: false }
59 }
60 case 'Grep':
61 return { kind: 'search', label: `Search "${str(input.pattern).slice(0, 40)}"`, files: none, isTest: false }
62 case 'Glob':
63 return { kind: 'search', label: `Find ${str(input.pattern).slice(0, 40)}`, files: none, isTest: false }
64 case 'Bash':
65 case 'PowerShell': {
66 const command = str(input.command)
67 const short = shortCommand(command)
68 if (isTestCommand(command)) return { kind: 'test', label: `Tests: ${short}`, files: none, isTest: true }
69 const git = GIT_CMD.exec(command)
70 if (git) return { kind: 'git', label: `${git[2]} ${git[3]}${str(input.description) ? ` · ${str(input.description)}` : ''}`, files: none, isTest: false }
71 return { kind: 'bash', label: str(input.description) || short, files: none, isTest: false }
72 }
73 case 'WebFetch':
74 return { kind: 'web', label: `Fetch ${str(input.url).replace(/^https?:\/\//, '').slice(0, 50)}`, files: none, isTest: false }
75 case 'WebSearch':
76 return { kind: 'web', label: `Search web "${str(input.query).slice(0, 40)}"`, files: none, isTest: false }
77 case 'Agent':
78 case 'Task':
79 return { kind: 'agent', label: `Agent: ${str(input.description) || str(input.subagent_type) || 'subagent'}`, files: none, isTest: false }
80 case 'AskUserQuestion':
81 return { kind: 'ask', label: 'Asking you', files: none, isTest: false }
82 case 'ExitPlanMode':
83 return { kind: 'plan', label: 'Plan proposed', files: none, isTest: false }
84 case 'TodoWrite':
85 case 'ToolSearch':
86 return null
87 default: {
88 if (tool.startsWith('mcp__conversation-atlas__')) return null
89 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
90 if (mcp) return { kind: 'other', label: `${mcp[1]?.replace(/^claude_ai_/, '')}: ${mcp[2]}`, files: none, isTest: false }
91 return { kind: 'other', label: tool, files: none, isTest: false }
92 }
93 }
94}
95
96// The question texts of an AskUserQuestion call: each becomes an open question on the map.
97export function askedQuestions(input: Input): string[] {
98 const qs = Array.isArray(input.questions) ? input.questions : []
99 return qs.map(q => (q && typeof q === 'object' ? str((q as Input).question) : '')).filter(Boolean)
100}
101
102// The first heading or line of a proposed plan, used as the decision's text.
103export function planTitle(plan: string): string {
104 const line = plan.split('\n').map(l => l.replace(/^#+\s*/, '').trim()).find(Boolean)
105 return line ? `Plan approved: ${line}` : 'Plan approved'
106}
107hooks/live.tsx 110 lines1// Surface module for the parts of the atlas that move: running activity spins, and a
2// segment marked with a tone shimmers (a bright band sweeping through its letters) while
3// it is fresh or running. Runs on the drawing thread; the hooks module only hands it rows.
4
5import type { ClientModule } from 'claude-code'
6
7export type LiveSeg = { t: string; c?: string; b?: boolean; d?: boolean; sh?: string; spin?: boolean }
8export type LiveRow = { segs: LiveSeg[]; right?: LiveSeg[] }
9export type LiveScan = { active: boolean; startedAt: number; result: string | null; resultAt: number; now: number }
10export type LiveProps = { rows: LiveRow[]; tones: Record<string, string[]>; scan?: LiveScan }
11
12type Local = { phase: number; ref: { stop?: () => void }; scanKey: string; elapsedMs: number }
13
14const TICK_MS = 100
15const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
16
17function band(i: number, phase: number, len: number, palette: string[]): string {
18 const at = ((phase * 1.4) % (len + 10)) - 5
19 const d = Math.abs(i - at)
20 return palette[d < 1 ? 3 : d < 2 ? 2 : d < 3 ? 1 : 0] ?? palette[0] ?? '#bb9af7'
21}
22
23function scanBar(width: number, phase: number): string {
24 const len = Math.max(8, width)
25 const at = phase % len
26 return Array.from({ length: len }, (_, i) => i === at ? '█' : '─').join('')
27}
28
29const Live: ClientModule<LiveProps, Local> = (props, surface) => {
30 const { Box, Text } = surface.elements
31 let state = surface.state
32 if (state === undefined) {
33 state = { phase: 0, ref: {}, scanKey: '', elapsedMs: 0 }
34 surface.setState(state)
35 }
36 const scan = props.scan
37 const scanKey = scan ? `${scan.active ? 'active' : 'result'}:${scan.startedAt}:${scan.resultAt}:${scan.result ?? ''}` : ''
38 if (state.scanKey !== scanKey) {
39 const elapsedMs = scan
40 ? scan.active
41 ? Math.max(0, scan.now - scan.startedAt)
42 : Math.max(0, scan.now - scan.resultAt)
43 : 0
44 state = { ...state, scanKey, elapsedMs }
45 surface.setState(state)
46 }
47 const moving = props.rows.some(r => r.segs.some(s => s.sh || s.spin)) || Boolean(scan?.active)
48 if (moving && !state.ref.stop) {
49 state.ref.stop = surface.every(TICK_MS, () => {
50 const cur = surface.state
51 if (cur) surface.setState({ ...cur, phase: cur.phase + 1, elapsedMs: cur.elapsedMs + TICK_MS })
52 })
53 } else if (!moving && state.ref.stop) {
54 state.ref.stop()
55 state.ref.stop = undefined
56 }
57 const phase = state.phase
58 const draw = (s: LiveSeg) => {
59 if (s.spin) return <Text color={s.c} bold>{`${FRAMES[phase % FRAMES.length]} `}</Text>
60 const palette = s.sh ? props.tones[s.sh] : undefined
61 if (!palette) {
62 return (
63 <Text color={s.c} bold={s.b} dimColor={s.d}>
64 {s.t}
65 </Text>
66 )
67 }
68 const chars = [...s.t]
69 return (
70 <Text bold={s.b}>
71 {chars.map((ch, i) => (
72 <Text color={band(i, phase, chars.length, palette)}>{ch}</Text>
73 ))}
74 </Text>
75 )
76 }
77 const drawRow = (r: LiveRow) => (
78 <Box flexDirection="row" height={1} overflow="hidden">
79 <Box flexDirection="row" flexShrink={1} overflow="hidden">
80 {r.segs.map(draw)}
81 </Box>
82 <Box flexGrow={1} />
83 {r.right?.length ? (
84 <Box flexDirection="row" flexShrink={0}>
85 {r.right.map(draw)}
86 </Box>
87 ) : null}
88 </Box>
89 )
90 if (scan?.active) {
91 return (
92 <Box flexDirection="column">
93 <Text color="#7aa2f7">{scanBar(surface.columns, phase)}</Text>
94 <Box flexDirection="row" height={1} overflow="hidden">
95 <Box flexDirection="row" flexShrink={1} overflow="hidden">{props.rows[0]?.segs.map(draw)}</Box>
96 <Box flexGrow={1} />
97 <Text dimColor>{`${Math.floor(state.elapsedMs / 1000)}s`}</Text>
98 </Box>
99 </Box>
100 )
101 }
102 return (
103 <Box flexDirection="column">
104 {props.rows.map(drawRow)}
105 </Box>
106 )
107}
108
109export default Live
110hooks/delegation.ts 152 lines1// Pure delegation detection and handoff-report parsing. The register hook owns
2// the filesystem and state writes; this module only interprets tool inputs and
3// report text.
4
5export type HandoffStart = {
6 label: string
7 agent: string
8 brief: string | null
9 external: boolean
10}
11
12export type HandoffReport = {
13 status: 'done' | 'blocked' | 'failed'
14 summary: string
15 branch: string
16 tests: string
17 files: string[]
18}
19
20type Input = Record<string, unknown>
21
22const stringOf = (value: unknown): string => typeof value === 'string' ? value : ''
23
24function labelOf(value: string): string {
25 const line = value.replace(/\s+/g, ' ').trim()
26 return line.length <= 120 ? line : `${line.slice(0, 119).trimEnd()}…`
27}
28
29function titleOf(command: string): string | null {
30 const match = /(?:^|\s)--title(?:=|\s+)(?:"([^"]+)"|'([^']+)'|(\S+))/i.exec(command)
31 return match?.[1] ?? match?.[2] ?? match?.[3] ?? null
32}
33
34function agentOf(command: string): string | null {
35 const direct = /(?:^|&&|[;|])\s*(?:[A-Za-z_][\w-]*=\S+\s+)*(?:["']?)(codex|claude)(?:\.exe)?(?=\s|$)/gi
36 for (const match of command.matchAll(direct)) {
37 const name = match[1]?.toLowerCase()
38 if (name === 'codex') return 'Codex'
39 if (name === 'claude') {
40 const rest = command.slice((match.index ?? 0) + match[0].length)
41 if (/^\s+(?:-p|--print)(?:\s|$)/i.test(rest)) return 'Claude'
42 }
43 }
44 const terminal = /\bwt(?:\.exe)?\b[\s\S]*?\bnew-tab\b([\s\S]*)/i.exec(command)?.[1] ?? ''
45 if (/(?:^|\s)(?:["']?)(codex|claude)(?:\.exe)?(?=\s|$)/i.test(terminal)) {
46 const match = /(?:^|\s)(?:["']?)(codex|claude)(?:\.exe)?(?=\s|$)/i.exec(terminal)
47 if (match?.[1]?.toLowerCase() === 'codex') return 'Codex'
48 if (match?.[1] && /\s+(?:-p|--print)(?:\s|$)/i.test(terminal.slice((match.index ?? 0) + match[0].length))) return 'Claude'
49 }
50 return null
51}
52
53function briefOf(command: string): string | null {
54 const matches = [...command.matchAll(/(?:^|[\s"'])([A-Za-z]:[\\/][^\s"']+\.md|(?:\.\.?[\\/])?[^\s"']+\.md)\b/gi)]
55 return matches.at(-1)?.[1] ?? null
56}
57
58export function handoffStart(tool: string, input: Input): HandoffStart | null {
59 if (tool === 'Agent' || tool === 'Task') {
60 const agent = stringOf(input.subagent_type) || stringOf(input.subagentType) || 'subagent'
61 const label = labelOf(stringOf(input.description)) || agent
62 return { label, agent, brief: null, external: false }
63 }
64 if (tool !== 'Bash' && tool !== 'PowerShell') return null
65 const command = stringOf(input.command)
66 const agent = agentOf(command)
67 const terminalTab = /\bwt(?:\.exe)?\b[\s\S]*\bnew-tab\b/i.test(command) && Boolean(agent)
68 const external = Boolean(agent || terminalTab)
69 if (!external) return null
70 const description = labelOf(stringOf(input.description))
71 const firstCommandLine = labelOf(command.split(/\r?\n/, 1)[0] ?? '')
72 const name = agent ? (titleOf(command) || agent) : (description || firstCommandLine || 'background')
73 return { label: name, agent: agent ?? 'background', brief: briefOf(command), external: true }
74}
75
76export function expectedReportPath(root: string, brief: string | null): string | null {
77 if (!brief) return null
78 const name = brief.replace(/\\/g, '/').split('/').at(-1) ?? ''
79 const slug = name.replace(/\.md$/i, '')
80 if (!slug || !/^[A-Za-z0-9._-]+$/.test(slug)) return null
81 return `${root.replace(/[\\/]$/, '').replace(/\\/g, '/')}/.claude/atlas/handoffs/${slug}.md`
82}
83
84function cleanValue(value: string): string {
85 return value.replace(/^['"]|['"]$/g, '').trim()
86}
87
88// A short, deterministic fingerprint is enough to distinguish a report left by
89// an earlier hand-off from the report written by the current one.
90export function reportFingerprint(text: unknown): string | null {
91 if (typeof text !== 'string') return null
92 let hash = 2166136261
93 for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619)
94 return `${text.length}:${hash >>> 0}`
95}
96
97export function reportChanged(previous: string | null | undefined, current: unknown): boolean {
98 const next = reportFingerprint(current)
99 return next !== null && next !== previous
100}
101
102// A missing optional field is not a malformed report. A missing/invalid status
103// is the one condition that keeps a report open, so a partially written file
104// can safely be observed on the next timer tick.
105export function parseHandoffReport(text: unknown): HandoffReport | null {
106 if (typeof text !== 'string' || !text.trim()) return null
107 const lines = text.replace(/^---\s*\r?\n/, '').split(/\r?\n/)
108 let status: HandoffReport['status'] | null = null
109 let summary = ''
110 let branch = ''
111 let tests = ''
112 const files: string[] = []
113 let inFiles = false
114 for (const raw of lines) {
115 const line = raw.trim()
116 if (!line) {
117 inFiles = false
118 continue
119 }
120 if (line === '---') continue
121 if (/^files\s*:/i.test(line)) {
122 inFiles = true
123 const inline = line.replace(/^files\s*:\s*/i, '').trim()
124 if (inline && inline !== '[]') files.push(cleanValue(inline.replace(/^[-*]\s*/, '')))
125 continue
126 }
127 const field = /^(status|summary|branch|tests)\s*:\s*(.*)$/i.exec(line)
128 if (field) {
129 inFiles = false
130 const value = cleanValue(field[2] ?? '')
131 if (field[1]?.toLowerCase() === 'status' && (value === 'done' || value === 'blocked' || value === 'failed')) status = value
132 if (field[1]?.toLowerCase() === 'summary') summary = value
133 if (field[1]?.toLowerCase() === 'branch') branch = value
134 if (field[1]?.toLowerCase() === 'tests') tests = value
135 continue
136 }
137 if (inFiles && /^[A-Za-z][A-Za-z0-9_.-]*\s*:\s*/.test(line)) {
138 inFiles = false
139 continue
140 }
141 if (inFiles) files.push(cleanValue(line.replace(/^[-*]\s+/, '')))
142 }
143 if (!status) return null
144 return {
145 status,
146 summary: summary || 'No summary provided',
147 branch: branch || 'unknown',
148 tests: tests || 'tests not recorded',
149 files: [...new Set(files.filter(Boolean))],
150 }
151}
152hooks/model.ts 1085 lines1// Pure reducers for Conversation Atlas. No `$`, no clock: every function takes the
2// snapshot and the time, and returns a new snapshot. The hooks module wires them to events.
3//
4// The one rule this file enforces: observations never write intent. `observe`, `startTurn`
5// and the activity reducers only add topics, suggestions, items, files and activity.
6// Goal, detour, next step and marked checkpoints change only in the `confirm*`/`set*`
7// functions, which the hooks call from a person's press or command.
8
9import type {
10 AtlasActivity,
11 AtlasCheckpoint,
12 AtlasDetour,
13 AtlasEvent,
14 AtlasFile,
15 AtlasHandoff,
16 AtlasItem,
17 AtlasRecall,
18 AtlasSnapshot as BaseSnapshot,
19 AtlasSource,
20 AtlasSuggestion,
21 AtlasSuggestionKind,
22 AtlasTopic,
23} from '../types'
24
25// Optional at the type boundary because pre-focus snapshots are still supported.
26// All constructors/loaders below provide a plain JSON field with a null default.
27export type AtlasFocus = { text: string; since: number; turns: number; lastTurn: number }
28export type AtlasSnapshot = BaseSnapshot & { focus?: AtlasFocus | null }
29
30export const LIMITS = {
31 events: 200,
32 activity: 24,
33 files: 60,
34 topics: 80,
35 items: 60,
36 checkpoints: 40,
37 suggestions: 10,
38 history: 20,
39 handoffs: 20,
40 focusText: 140,
41 focusTurns: 2,
42} as const
43
44export type Shift = 'same' | 'subtopic' | 'sibling' | 'possible-detour' | 'return'
45
46export type ObserveReport = {
47 topic?: string
48 shift?: Shift
49 why?: string
50 decisions?: string[]
51 questions?: string[]
52 resolved?: string[]
53 next?: string
54 checkpoint?: string
55 goal?: string
56}
57
58export function clip(value: unknown, max = 120): string {
59 if (typeof value !== 'string') return ''
60 const text = value.replace(/\s+/g, ' ').trim()
61 return text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1)).trimEnd()}…`
62}
63
64export function norm(value: string): string {
65 return value.toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim()
66}
67
68function words(value: string): Set<string> {
69 return new Set(norm(value).split(' ').filter(w => w.length > 2))
70}
71
72// Loose match for "the question Claude says is resolved" and "the topic it returns to".
73export function similar(a: string, b: string): boolean {
74 const x = norm(a)
75 const y = norm(b)
76 if (!x || !y) return false
77 if (x === y || x.includes(y) || y.includes(x)) return true
78 const wa = words(a)
79 const wb = words(b)
80 if (wa.size === 0 || wb.size === 0) return false
81 let shared = 0
82 for (const w of wa) if (wb.has(w)) shared += 1
83 return shared / Math.min(wa.size, wb.size) >= 0.6
84}
85
86export function emptySnapshot(sessionId: string, root: string, now: number): AtlasSnapshot {
87 return {
88 v: 1,
89 sessionId,
90 root,
91 startedAt: now,
92 turn: 0,
93 seq: 0,
94 goal: null,
95 focus: null,
96 detectedGoal: null,
97 goalHistory: [],
98 detour: null,
99 detourHistory: [],
100 nextStep: null,
101 topics: [],
102 currentTopicId: null,
103 suggestions: [],
104 decisions: [],
105 questions: [],
106 checkpoints: [],
107 files: [],
108 activity: [],
109 handoffs: [],
110 events: [],
111 pendingContext: [],
112 recall: [],
113 adopted: [],
114 scanned: 'none',
115 fresh: [],
116 }
117}
118
119// Fill fields a snapshot saved by an older build may lack, so a restore never breaks a render.
120export function hydrate(raw: unknown, sessionId: string, root: string, now: number): AtlasSnapshot {
121 const base = emptySnapshot(sessionId, root, now)
122 if (!raw || typeof raw !== 'object') return base
123 const s = raw as Partial<AtlasSnapshot>
124 if (s.v !== 1) return base
125 const detour = s.detour ? { ...s.detour, exclusions: s.detour.exclusions ?? [] } : null
126 return {
127 ...base,
128 ...s,
129 focus: boundedFocus(s.focus),
130 detour,
131 sessionId,
132 root,
133 fresh: [],
134 activity: (s.activity ?? []).map(a => (a.state === 'running' ? { ...a, state: 'failed' as const, endedAt: a.endedAt ?? now } : a)),
135 handoffs: (s.handoffs ?? []).map(h => ({
136 ...h,
137 reportFingerprint: h.reportFingerprint ?? null,
138 taskId: h.taskId ?? null,
139 ...(h.status === 'open' ? { status: 'closed' as const, summary: h.summary ?? 'session ended', tests: h.tests ?? null } : {}),
140 })),
141 }
142}
143
144type RawSnapshot = Record<string, unknown>
145
146function isRawRecord(value: unknown): value is RawSnapshot {
147 return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
148}
149
150function focusError(value: unknown): string | null {
151 if (value === undefined || value === null) return null
152 if (!isRawRecord(value) || typeof value.text !== 'string' || !clip(value.text) ||
153 ![value.since, value.turns, value.lastTurn].every(n => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0) ||
154 Number(value.turns) < 1 || Number(value.lastTurn) < Number(value.since)) return 'malformed focus record'
155 return null
156}
157
158function boundedFocus(value: unknown): AtlasFocus | null {
159 if (value == null || focusError(value)) return null
160 const focus = value as AtlasFocus
161 return { text: clip(focus.text, LIMITS.focusText), since: focus.since,
162 turns: Math.min(focus.turns, LIMITS.focusTurns), lastTurn: focus.lastTurn }
163}
164
165const requiredRecordArrays = ['goalHistory', 'detourHistory', 'topics', 'suggestions', 'decisions', 'questions', 'checkpoints', 'files', 'activity', 'events']
166const optionalRecordArrays = ['handoffs', 'recall']
167const optionalStringArrays = ['pendingContext', 'adopted', 'fresh']
168
169function arrayShapeError(raw: RawSnapshot, field: string, required: boolean): string | null {
170 if (raw[field] === undefined && !required) return null
171 if (!Array.isArray(raw[field])) return `${required ? 'missing or malformed' : 'malformed'} ${field} collection`
172 return null
173}
174
175function recordArrayError(raw: RawSnapshot, field: string, required: boolean): string | null {
176 const shapeError = arrayShapeError(raw, field, required)
177 if (shapeError || raw[field] === undefined) return shapeError
178 return (raw[field] as unknown[]).every(isRawRecord) ? null : `malformed ${field} collection`
179}
180
181function stringArrayError(raw: RawSnapshot, field: string): string | null {
182 const shapeError = arrayShapeError(raw, field, false)
183 if (shapeError || raw[field] === undefined) return shapeError
184 return (raw[field] as unknown[]).every(value => typeof value === 'string') ? null : `malformed ${field} collection`
185}
186
187function nullableRecordError(raw: RawSnapshot, field: string): string | null {
188 const value = raw[field]
189 return value === undefined || value === null || isRawRecord(value) ? null : `malformed ${field} record`
190}
191
192function nestedArrayError(record: RawSnapshot, field: string, label: string): string | null {
193 if (record[field] === undefined) return null
194 if (!Array.isArray(record[field])) return `malformed ${label} collection`
195 return (record[field] as unknown[]).every(value => typeof value === 'string') ? null : `malformed ${label} collection`
196}
197
198function detourShapeError(value: unknown, label: string): string | null {
199 if (!isRawRecord(value)) return `malformed ${label} record`
200 const departure = value.departure
201 if (!isRawRecord(departure)) return `malformed ${label} record`
202 return nestedArrayError(value, 'outcomes', `${label}.outcomes`) ??
203 nestedArrayError(value, 'exclusions', `${label}.exclusions`) ??
204 nestedArrayError(departure, 'decisions', `${label}.departure.decisions`)
205}
206
207// Full recovery is a replacement, so reject malformed snapshots before hydrate can
208// turn an unsupported or incomplete shape into a seemingly valid empty snapshot.
209export function snapshotError(raw: unknown): string | null {
210 if (!isRawRecord(raw)) return 'snapshot is not an object'
211 if (raw.v !== 1) return `unsupported version ${raw.v === undefined ? 'missing' : String(raw.v)}`
212 const error = focusError(raw.focus)
213 if (error) return error
214 for (const field of requiredRecordArrays) {
215 const error = recordArrayError(raw, field, true)
216 if (error) return error
217 }
218 for (const field of optionalRecordArrays) {
219 const error = recordArrayError(raw, field, false)
220 if (error) return error
221 }
222 for (const field of optionalStringArrays) {
223 const error = stringArrayError(raw, field)
224 if (error) return error
225 }
226 for (const field of ['goal', 'detectedGoal']) {
227 const error = nullableRecordError(raw, field)
228 if (error) return error
229 }
230 for (const field of ['detour', 'detourHistory']) {
231 const value = raw[field]
232 if (field === 'detour') {
233 if (value !== undefined && value !== null) {
234 const error = detourShapeError(value, field)
235 if (error) return error
236 }
237 continue
238 }
239 for (const item of value as unknown[]) {
240 const error = detourShapeError(item, field)
241 if (error) return error
242 }
243 }
244 return null
245}
246
247// A snapshot that survived a reload of an older build keeps its old shape in state:
248// fill every field added since, so no reader ever meets undefined. Cheap and idempotent.
249export function upgrade(s: AtlasSnapshot): AtlasSnapshot {
250 const partial = s as Partial<AtlasSnapshot>
251 const needs =
252 partial.focus === undefined ||
253 partial.detectedGoal === undefined ||
254 !Array.isArray(partial.recall) ||
255 !Array.isArray(partial.adopted) ||
256 !partial.scanned ||
257 !Array.isArray(partial.handoffs) ||
258 s.handoffs.some(h => h.reportFingerprint === undefined || h.taskId === undefined) ||
259 (s.detour && !Array.isArray(s.detour.exclusions)) ||
260 s.detourHistory.some(d => !Array.isArray(d.exclusions))
261 if (!needs) return s
262 const base = emptySnapshot(s.sessionId, s.root, s.startedAt)
263 const withEx = <T extends { exclusions?: string[] }>(d: T) => ({ ...d, exclusions: d.exclusions ?? [] })
264 return {
265 ...base,
266 ...s,
267 focus: boundedFocus(partial.focus),
268 detectedGoal: partial.detectedGoal ?? null,
269 recall: partial.recall ?? [],
270 adopted: partial.adopted ?? [],
271 scanned: partial.scanned ?? 'none',
272 handoffs: (partial.handoffs ?? []).map(h => ({ ...h, reportFingerprint: h.reportFingerprint ?? null, taskId: h.taskId ?? null })),
273 detour: s.detour ? withEx(s.detour) : null,
274 detourHistory: s.detourHistory.map(withEx),
275 }
276}
277
278function id(s: AtlasSnapshot, prefix: string): [string, AtlasSnapshot] {
279 const seq = s.seq + 1
280 return [`${prefix}${seq}`, { ...s, seq }]
281}
282
283function keep<T>(list: T[], max: number): T[] {
284 return list.length > max ? list.slice(list.length - max) : list
285}
286
287function event(s: AtlasSnapshot, kind: AtlasEvent['kind'], text: string, now: number, detail?: string[]): AtlasSnapshot {
288 const [eid, next] = id(s, 'e')
289 const made: AtlasEvent = { id: eid, at: now, turn: s.turn, kind, text: kind === 'prompt' ? text : clip(text, 160), ...(detail?.length ? { detail } : {}) }
290 return { ...next, events: keep([...next.events, made], LIMITS.events) }
291}
292
293// Rendering a long run of identical evidence rows as one event keeps the Trail
294// useful without growing the stored event list or changing LIMITS.
295export function collapseEvents(events: AtlasEvent[]): AtlasEvent[] {
296 const out: AtlasEvent[] = []
297 for (const current of events) {
298 const previous = out[out.length - 1]
299 const currentText = current.text.replace(/ ×\d+$/, '')
300 const previousText = previous?.text.replace(/ ×\d+$/, '') ?? ''
301 if (previous && current.kind === 'checkpoint' && previous.kind === 'checkpoint' && currentText === previousText) {
302 const count = Number(/ ×(\d+)$/.exec(previous.text)?.[1] ?? 1) + 1
303 out[out.length - 1] = { ...current, text: `${currentText} ×${count}` }
304 } else {
305 out.push(current)
306 }
307 }
308 return out
309}
310
311function flash(s: AtlasSnapshot, ...ids: string[]): AtlasSnapshot {
312 return { ...s, fresh: [...new Set([...s.fresh, ...ids])].slice(-12) }
313}
314
315export function clearFresh(s: AtlasSnapshot): AtlasSnapshot {
316 return s.fresh.length ? { ...s, fresh: [] } : s
317}
318
319export function currentTopic(s: AtlasSnapshot): AtlasTopic | null {
320 return s.topics.find(t => t.id === s.currentTopicId) ?? null
321}
322
323// Count only topic observations, once per turn. A different candidate or a gap
324// resets the run; similar wording in the immediately following turn reaches two.
325// startTurn advances the clock but never counts the previous turn's stale topic.
326// Matching the confirmed goal (or no topic) immediately restores goal focus.
327// This reducer writes no intent, fresh mark or Trail event.
328function observeFocus(s: AtlasSnapshot): AtlasSnapshot {
329 const goal = s.goal?.text
330 if (!goal) return s.focus == null ? s : { ...s, focus: null }
331 const topic = currentTopic(s)?.title
332 const text = clip(!topic || similar(topic, goal) ? goal : topic, LIMITS.focusText)
333 const previous = s.focus
334 const same = Boolean(previous && similar(previous.text, text))
335 const consecutive = same && previous!.lastTurn === s.turn - 1
336 const repeated = same && previous!.lastTurn === s.turn
337 const turns = repeated ? previous!.turns : consecutive ? Math.min(LIMITS.focusTurns, previous!.turns + 1) : 1
338 return { ...s, focus: { text, since: repeated || consecutive ? previous!.since : s.turn, turns, lastTurn: s.turn } }
339}
340
341export function focusedGoal(s: AtlasSnapshot): string | null {
342 if (!s.goal) return null
343 return s.focus && s.focus.turns >= LIMITS.focusTurns && !similar(s.focus.text, s.goal.text)
344 ? s.focus.text : s.goal.text
345}
346
347// Derived alternatives are intentionally not stored as suggestions: they are a
348// read-only view of observed evidence until the person presses Switch to this.
349export function goalSuggestions(s: AtlasSnapshot): string[] {
350 const goal = s.goal?.text
351 if (!goal) return []
352 const focus = focusedGoal(s)
353 const moved = focus && !similar(focus, goal) ? focus : null
354 // A detour still blocks setGoal, but must not hide the observed focus row.
355 // Its existing goal Action explains that Return/promote is required first.
356 if (s.detour) return moved ? [moved] : []
357 const recentMain = s.topics.filter(topic => topic.kind === 'main').slice(-5)
358 const current = currentTopic(s)
359 const recentEvidence = [current?.title, ...recentMain.map(topic => topic.title)].filter((text): text is string => Boolean(text))
360 if (!moved && (!recentEvidence.length || recentEvidence.some(text => similar(text, goal)))) return []
361
362 const candidates = [
363 moved,
364 s.detectedGoal?.text,
365 current?.title,
366 ...recentMain
367 .slice()
368 .reverse()
369 .map(topic => topic.title),
370 ...s.checkpoints
371 .filter(checkpoint => checkpoint.kind === 'claude')
372 .slice(-5)
373 .reverse()
374 .map(checkpoint => checkpoint.name),
375 ]
376 const out: string[] = []
377 for (const candidate of candidates) {
378 const text = clip(candidate, 140)
379 if (!text || similar(text, goal) || out.some(existing => similar(existing, text))) continue
380 out.push(text)
381 if (out.length === 3) break
382 }
383 return out
384}
385
386// Root-to-current chain of the topic tree: the "current path".
387export function pathOf(s: AtlasSnapshot, topicId = s.currentTopicId): AtlasTopic[] {
388 const chain: AtlasTopic[] = []
389 const seen = new Set<string>()
390 let cur = s.topics.find(t => t.id === topicId) ?? null
391 while (cur && !seen.has(cur.id)) {
392 seen.add(cur.id)
393 chain.unshift(cur)
394 cur = cur.parentId ? (s.topics.find(t => t.id === cur?.parentId) ?? null) : null
395 }
396 return chain
397}
398
399export function activeDecisions(s: AtlasSnapshot): AtlasItem[] {
400 return s.decisions.filter(d => d.status === 'observed' || d.status === 'settled')
401}
402
403export function openQuestions(s: AtlasSnapshot): AtlasItem[] {
404 return s.questions.filter(q => q.status === 'open')
405}
406
407// ---------------------------------------------------------------- suggestions
408
409function suggest(
410 s: AtlasSnapshot,
411 kind: AtlasSuggestionKind,
412 text: string,
413 why: string | null,
414 source: AtlasSource,
415 now: number,
416 replaceKind = true,
417): AtlasSnapshot {
418 const body = clip(text, 140)
419 if ((body.replace(/…/g, '').match(/[\p{L}\p{N}]/gu)?.length ?? 0) < 2) return s
420 if (s.suggestions.some(x => x.kind === kind && similar(x.text, body))) return s
421 const [sid, next] = id(s, 's')
422 const kept = replaceKind ? next.suggestions.filter(x => x.kind !== kind) : next.suggestions
423 const made: AtlasSuggestion = { id: sid, kind, text: body, why: why ? clip(why, 140) : null, at: now, turn: s.turn, source, topicId: s.currentTopicId }
424 return flash({ ...next, suggestions: keep([...kept, made], LIMITS.suggestions) }, sid)
425}
426
427function dropSuggestions(s: AtlasSnapshot, kind: AtlasSuggestionKind): AtlasSnapshot {
428 return s.suggestions.some(x => x.kind === kind) ? { ...s, suggestions: s.suggestions.filter(x => x.kind !== kind) } : s
429}
430
431export function dismissSuggestion(s: AtlasSnapshot, sid: string, now: number): AtlasSnapshot {
432 const hit = s.suggestions.find(x => x.id === sid)
433 if (!hit) return s
434 let next: AtlasSnapshot = { ...s, suggestions: s.suggestions.filter(x => x.id !== sid) }
435 // "Not a detour": the excursion stays on the map as part of the main path.
436 if (hit.kind === 'detour' && hit.topicId) {
437 next = { ...next, topics: next.topics.map(t => (t.id === hit.topicId && t.kind === 'possible-detour' ? { ...t, kind: 'main' as const } : t)) }
438 }
439 return event(next, 'dismiss', `Dismissed ${hit.kind}: ${hit.text}`, now)
440}
441
442// ---------------------------------------------------------------- items
443
444// Deterministic consequential-choice cues, matched as words (case-insensitive).
445// Old decisions use exactly this rule on read; observation never writes intent.
446const MAJOR_DECISION_CUE = /\b(?:architecture|schema|api|security|release|publish|delete|migrate|replace|rename|breaking|public|data|must|never|always|default|go\s+with)\b/i
447
448export function autoDecisionWeight(text: string): 'major' | 'minor' {
449 return MAJOR_DECISION_CUE.test(text) ? 'major' : 'minor'
450}
451
452export function decisionWeight(item: AtlasItem): 'major' | 'minor' {
453 return item.weight ?? autoDecisionWeight(item.text)
454}
455
456export function toggleDecisionWeight(s: AtlasSnapshot, iid: string): AtlasSnapshot {
457 const hit = s.decisions.find(item => item.id === iid)
458 if (!hit) return s
459 const weight = decisionWeight(hit) === 'major' ? 'minor' : 'major'
460 return { ...s, decisions: s.decisions.map(item => item.id === iid ? { ...item, weight, weightBy: 'person' as const } : item) }
461}
462
463function addItem(s: AtlasSnapshot, list: 'decisions' | 'questions', text: string, source: AtlasSource, now: number): AtlasSnapshot {
464 const body = clip(text, 160)
465 if (!body) return s
466 if (s[list].some(x => similar(x.text, body) && x.status !== 'resolved')) return s
467 const [iid, next] = id(s, list === 'decisions' ? 'd' : 'q')
468 const item: AtlasItem = { id: iid, text: body, at: now, turn: s.turn, topicId: s.currentTopicId, source, status: list === 'decisions' ? 'observed' : 'open', ...(list === 'decisions' ? { weight: autoDecisionWeight(body), weightBy: 'auto' as const } : {}) }
469 const withItem = { ...next, [list]: keep([...next[list], item], LIMITS.items) } as AtlasSnapshot
470 return flash(event(withItem, list === 'decisions' ? 'decision' : 'question', body, now), iid)
471}
472
473export function addDecision(s: AtlasSnapshot, text: string, source: AtlasSource, now: number): AtlasSnapshot {
474 return addItem(s, 'decisions', text, source, now)
475}
476
477export function addQuestion(s: AtlasSnapshot, text: string, source: AtlasSource, now: number): AtlasSnapshot {
478 return addItem(s, 'questions', text, source, now)
479}
480
481export function resolveQuestions(s: AtlasSnapshot, texts: string[], now: number): AtlasSnapshot {
482 let next = s
483 for (const text of texts) {
484 const hit = next.questions.find(q => q.status === 'open' && similar(q.text, text))
485 if (!hit) continue
486 next = { ...next, questions: next.questions.map(q => (q.id === hit.id ? { ...q, status: 'resolved' as const } : q)) }
487 next = event(next, 'resolved', hit.text, now)
488 }
489 return next
490}
491
492export function setItemStatus(s: AtlasSnapshot, iid: string, status: AtlasItem['status'] | 'drop', now: number): AtlasSnapshot {
493 const list = iid.startsWith('d') ? 'decisions' : 'questions'
494 const hit = s[list].find(x => x.id === iid)
495 if (!hit) return s
496 const items = status === 'drop' ? s[list].filter(x => x.id !== iid) : s[list].map(x => (x.id === iid ? { ...x, status } : x))
497 let next = { ...s, [list]: items } as AtlasSnapshot
498 if (status === 'settled' && s.detour) {
499 // A decision settled during a detour is an accepted outcome of it.
500 next = { ...next, detour: { ...s.detour, outcomes: [...s.detour.outcomes, hit.text].slice(-20) } }
501 }
502 if (status === 'excluded' && s.detour) {
503 // Excluded material was explored during the detour and must not become an assumption.
504 next = { ...next, detour: { ...s.detour, exclusions: [...s.detour.exclusions, hit.text].slice(-20) } }
505 }
506 const verb = status === 'settled' ? 'Settled' : status === 'excluded' ? 'Excluded' : status === 'resolved' ? 'Resolved' : status === 'open' ? 'Reopened' : status === 'drop' ? 'Dropped' : 'Marked'
507 return event(next, status === 'resolved' ? 'resolved' : 'decision', `${verb}: ${hit.text}`, now)
508}
509
510// ---------------------------------------------------------------- topics (observation)
511
512function addTopic(s: AtlasSnapshot, title: string, parentId: string | null, kind: AtlasTopic['kind'], source: AtlasSource, now: number): AtlasSnapshot {
513 const [tid, next] = id(s, 't')
514 const topic: AtlasTopic = { id: tid, title: clip(title, 60), parentId, kind, firstTurn: s.turn, lastTurn: s.turn, at: now, status: 'active', source }
515 const topics = next.topics.map(t => (t.id === s.currentTopicId ? { ...t, status: 'left' as const } : t))
516 const trimmed = keep([...topics, topic], LIMITS.topics)
517 return flash(event({ ...next, topics: trimmed, currentTopicId: tid }, 'topic', `${kind === 'possible-detour' ? 'Possible detour' : 'Topic'}: ${topic.title}`, now), tid)
518}
519
520function moveTo(s: AtlasSnapshot, target: AtlasTopic, now: number): AtlasSnapshot {
521 const topics = s.topics.map(t => {
522 if (t.id === target.id) return { ...t, status: 'returned' as const, lastTurn: s.turn }
523 if (t.id === s.currentTopicId) return { ...t, status: 'left' as const }
524 return t
525 })
526 return flash(event({ ...s, topics, currentTopicId: target.id }, 'return', `Back to ${target.title}`, now), target.id)
527}
528
529function inDetourBranch(s: AtlasSnapshot, topic: AtlasTopic | null): boolean {
530 return Boolean(topic && pathOf(s, topic.id).some(t => t.kind !== 'main'))
531}
532
533function returnTarget(s: AtlasSnapshot, title: string): AtlasTopic | null {
534 const cur = currentTopic(s)
535 if (title) {
536 const named = [...s.topics].reverse().find(t => t.id !== cur?.id && similar(t.title, title))
537 if (named) return named
538 }
539 if (s.detour?.topicId) {
540 const departure = s.topics.find(t => t.id === s.detour?.topicId)
541 if (departure?.parentId) return s.topics.find(t => t.id === departure.parentId) ?? null
542 }
543 // Leave the nearest excursion: return to the topic above the first non-main topic in the path.
544 const chain = pathOf(s)
545 const first = chain.findIndex(t => t.kind !== 'main')
546 if (first > 0) return chain[first - 1] ?? null
547 return chain.length > 1 ? (chain[chain.length - 2] ?? null) : null
548}
549
550export function observeTopic(s: AtlasSnapshot, title: string, shift: Shift | undefined, why: string | null, source: AtlasSource, now: number): AtlasSnapshot {
551 const name = clip(title, 60)
552 const cur = currentTopic(s)
553 if (!cur) {
554 if (!name) return s
555 // A first topic that already reads as a side trip is still only a suggestion.
556 if (shift === 'possible-detour' && !s.detour) return suggest(addTopic(s, name, null, 'possible-detour', source, now), 'detour', name, why, source, now)
557 return addTopic(s, name, null, 'main', source, now)
558 }
559 const sameName = Boolean(name) && similar(cur.title, name)
560 const move: Shift = shift ?? (sameName || !name ? 'same' : cur.parentId ? 'sibling' : 'subtopic')
561 if (move === 'same' || (sameName && move !== 'return')) {
562 const topics = s.topics.map(t => (t.id === cur.id ? { ...t, lastTurn: s.turn, title: name && move === 'same' ? name : t.title } : t))
563 return { ...s, topics }
564 }
565 if (move === 'return') {
566 const target = returnTarget(s, name)
567 if (!target) return s
568 let next = moveTo(s, target, now)
569 if (s.detour) next = suggest(next, 'return', `Return to ${s.detour.departure.topic ?? s.detour.departure.goal ?? target.title}`, why ?? 'Claude observed the conversation going back', source, now)
570 return next
571 }
572 if (!name) return s
573 const branchKind: AtlasTopic['kind'] = inDetourBranch(s, cur) ? cur.kind : 'main'
574 if (move === 'possible-detour') {
575 if (s.detour) return addTopic(s, name, cur.id, 'detour', source, now)
576 const next = addTopic(s, name, cur.id, 'possible-detour', source, now)
577 return suggest(next, 'detour', name, why, source, now)
578 }
579 if (move === 'sibling') return addTopic(s, name, cur.parentId, cur.parentId ? branchKind : 'main', source, now)
580 return addTopic(s, name, cur.id, branchKind, source, now)
581}
582
583// ---------------------------------------------------------------- Claude's report (observation)
584
585export function observe(s: AtlasSnapshot, report: ObserveReport, now: number): AtlasSnapshot {
586 let next = s
587 if (report.topic || report.shift) next = observeTopic(next, report.topic ?? '', report.shift, report.why ?? null, 'claude', now)
588 for (const d of report.decisions ?? []) next = addDecision(next, d, 'claude', now)
589 if (report.resolved?.length) next = resolveQuestions(next, report.resolved, now)
590 for (const q of report.questions ?? []) next = addQuestion(next, q, 'claude', now)
591 if (report.next) next = suggest(next, 'next', report.next, null, 'claude', now)
592 if (report.checkpoint) next = addCheckpoint(next, report.checkpoint, 'claude', null, now)
593 if (report.goal) next = { ...next, detectedGoal: { text: clip(report.goal, 140), source: 'claude', at: now } }
594 // A proposed goal is only ever a suggestion, and only while nothing is confirmed.
595 if (report.goal && !next.goal) next = suggest(next, 'goal', report.goal, 'Proposed by Claude', 'claude', now)
596 return report.topic || report.shift ? observeFocus(next) : next
597}
598
599// ---------------------------------------------------------------- prompt cues (observation)
600
601const DETOUR_CUE = /\b(quick (detour|tangent|side ?quest)|side (question|quest|note)|tangent|unrelated(ly)?|btw|by the way|before (we|i) (continue|forget)|one sec|real quick)\b/i
602const RETURN_CUE = /\b(back to (the )?(main|original|it|where)|let'?s get back|go back to|return(ing)? to (the )?(main|original|goal)|where were we|anyway,? (let'?s|back)|resume (the )?(main|original|work))\b/i
603const DECISION_CUE = /\b(let'?s go with|we('ll| will) go with|i('ve| have)? decided|decision:|go with option|settled on|we('ll| will) use)\b/i
604
605export function sentences(text: string): string[] {
606 return (text.match(/[^.!?\n]+[.!?]*/g) ?? [text]).map(p => p.trim()).filter(Boolean)
607}
608
609type ReportMarker = { agent: string | null; taskId: string | null; summary: string }
610
611function cleanMarkup(value: string): string {
612 return value.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
613}
614
615// Engine-originated blocks are not prompts. Pasted content and image notices
616// remain visible as compact typed-language stand-ins; notification blocks are
617// consumed by delegation observation instead.
618export function stripPromptMarkers(text: string): string {
619 const lines = text.split(/\r?\n/)
620 const kept: string[] = []
621 for (let index = 0; index < lines.length; index++) {
622 const line = lines[index] ?? ''
623 if (/^\s*<!--\s*reply\b[^>]*?-->\s*$/i.test(line)) {
624 index++
625 while (index < lines.length && (lines[index] ?? '').startsWith('>')) index++
626 if ((lines[index] ?? '').trim() === '') index++
627 index--
628 continue
629 }
630 kept.push(line)
631 }
632 let body = kept.join('\n')
633 body = body.replace(/<task-notification\b[^>]*>[\s\S]*?<\/task-notification>/gi, '')
634 body = body.replace(/<agent-message\b[^>]*>[\s\S]*?<\/agent-message>/gi, '')
635 // Blocks the app injects around the person's own text: never part of the title or the cues.
636 body = body.replace(/<(system-reminder|local-command-caveat|cross-session-message)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
637 body = body.replace(/<pasted_content\b[^>]*>([\s\S]*?)<\/pasted_content>/gi, (_whole, inner: string) => {
638 const count = inner.trim() ? inner.trim().split(/\r?\n/).length : 0
639 return `pasted ${count} lines`
640 })
641 body = body.replace(/\[Image\s+#\d+\]/gi, 'image')
642 return body.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
643}
644
645export function reportMarker(text: string): ReportMarker | null {
646 const matches = [...text.matchAll(/<(task-notification|agent-message)\b([^>]*)>([\s\S]*?)<\/\1>/gi)]
647 if (!matches.length) return null
648 const last = matches[matches.length - 1]
649 const attrs = last?.[2] ?? ''
650 const fromMatch = /\bfrom\s*=\s*(?:["']([^"']+)["']|([^\s>]+))/i.exec(attrs)
651 const from = fromMatch?.[1] ?? fromMatch?.[2] ?? null
652 const body = last?.[3] ?? ''
653 const taskId = cleanMarkup(body.match(/<task-id\b[^>]*>([\s\S]*?)<\/task-id>/i)?.[1] ?? '') || null
654 const nestedSummary = body.match(/<summary\b[^>]*>([\s\S]*?)<\/summary>/i)?.[1]
655 const nestedStatus = body.match(/<status\b[^>]*>([\s\S]*?)<\/status>/i)?.[1]
656 const lineSummary = body.match(/^\s*summary\s*:\s*(.+)$/im)?.[1]
657 const lineStatus = body.match(/^\s*status\s*:\s*(.+)$/im)?.[1]
658 const summary = cleanMarkup(nestedSummary ?? lineSummary ?? nestedStatus ?? lineStatus ?? '') || (from ? `Report from ${from}` : 'Report received')
659 return { agent: from, taskId, summary: clip(summary, 140) }
660}
661
662// The points a longer prompt makes, after its first sentence: list items as written,
663// other lines split into sentences. The event keeps the full prompt separately.
664// Heuristic, no model; at most 6.
665export function promptBullets(text: string): string[] {
666 const items: string[] = []
667 let fenced = false
668 for (const raw of text.split('\n')) {
669 const line = raw.trim()
670 if (/^(```|~~~)/.test(line)) {
671 fenced = !fenced
672 continue
673 }
674 if (fenced || !line) continue
675 const bullet = /^(?:[-*•]|\d+[.)])\s+(.*)$/.exec(line)
676 if (bullet) items.push(bullet[1] ?? '')
677 else items.push(...sentences(line))
678 }
679 const kept = items
680 .slice(1)
681 .map(x => x.replace(/\s+/g, ' ').trim())
682 .filter(x => x.length >= 4)
683 const shown = kept.slice(0, 6).map(x => clip(x, 90))
684 return kept.length > 6 ? [...shown, `+${kept.length - 6} more`] : shown
685}
686
687function sentenceWith(text: string, cue: RegExp): string {
688 return sentences(text).find(p => cue.test(p)) ?? text
689}
690
691export function startTurn(s: AtlasSnapshot, text: string, now: number): AtlasSnapshot {
692 const body = stripPromptMarkers(text)
693 let next: AtlasSnapshot = { ...s, turn: s.turn + 1 }
694 if (!body) return next
695 next = event(next, 'prompt', body, now, promptBullets(body))
696 if (body.startsWith('/') || body.startsWith('<')) return next
697 if (!next.goal && !next.suggestions.some(x => x.kind === 'goal')) {
698 const first = sentences(body)[0] ?? ''
699 // Only the first sentence qualifies: 4 words, 16 characters, and not a
700 // greeting/acknowledgment made solely of these words. Never fall back to body.
701 const phraseWords = first.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []
702 const ackWords = new Set(['hi', 'hello', 'hey', 'ok', 'okay', 'thanks', 'thank', 'you', 'yes', 'yep', 'sure', 'please', 'do', 'it', 'go', 'ahead'])
703 if (first.length >= 16 && phraseWords.length >= 4 && !phraseWords.every(word => ackWords.has(word)))
704 next = suggest(next, 'goal', first, 'From your first request', 'cue', now)
705 }
706 if (DETOUR_CUE.test(body) && !next.detour) next = suggest(next, 'detour', clip(sentenceWith(body, DETOUR_CUE), 90), 'Your wording suggests a side trip', 'cue', now)
707 if (RETURN_CUE.test(body) && next.detour) next = suggest(next, 'return', `Return to ${next.detour.departure.topic ?? next.detour.departure.goal ?? 'the main path'}`, 'Your wording suggests going back', 'cue', now)
708 if (DECISION_CUE.test(body)) next = addDecision(next, sentenceWith(body, DECISION_CUE), 'cue', now)
709 return next
710}
711
712// ---------------------------------------------------------------- activity & files (observation)
713
714export function startActivity(s: AtlasSnapshot, a: Omit<AtlasActivity, 'state' | 'endedAt'>): AtlasSnapshot {
715 return { ...s, activity: keep([...s.activity, { ...a, state: 'running', endedAt: null }], LIMITS.activity) }
716}
717
718export function endActivity(s: AtlasSnapshot, aid: string, state: 'done' | 'failed', now: number, label?: string): AtlasSnapshot {
719 if (!s.activity.some(a => a.id === aid)) return s
720 return { ...s, activity: s.activity.map(a => (a.id === aid ? { ...a, state, endedAt: now, label: label ?? a.label } : a)) }
721}
722
723function handoffBriefLabel(brief: string): string {
724 if (!/\$[A-Za-z_][\w-]*[\\/]/.test(brief)) return brief
725 return brief.replace(/\\/g, '/').split('/').at(-1) ?? brief
726}
727
728export function startHandoff(
729 s: AtlasSnapshot,
730 label: string,
731 agent: string,
732 brief: string | null,
733 reportPath: string | null,
734 now: number,
735 reportFingerprint: string | null = null,
736 taskId: string | null = null,
737): AtlasSnapshot {
738 const body = clip(label, 120) || agent || 'external agent'
739 const [hid, next] = id(s, 'h')
740 const handoff: AtlasHandoff = {
741 id: hid,
742 label: body,
743 agent: clip(agent, 40) || 'agent',
744 brief: brief ? clip(brief, 240) : null,
745 reportPath: reportPath ? clip(reportPath, 400) : null,
746 at: now,
747 turn: s.turn,
748 status: 'open',
749 summary: null,
750 tests: null,
751 files: [],
752 reportFingerprint,
753 taskId: taskId ? clip(taskId, 120) : null,
754 }
755 const path = brief ? ` · ${handoffBriefLabel(brief)}` : ''
756 return flash(event({ ...next, handoffs: keep([...next.handoffs, handoff], LIMITS.handoffs) }, 'handoff', `${body}${path}`, now), hid)
757}
758
759function handoffFor(s: AtlasSnapshot, agent?: string, idHint?: string): AtlasHandoff | null {
760 const open = s.handoffs.filter(h => h.status === 'open')
761 if (idHint) return open.find(h => h.id === idHint || h.taskId === idHint) ?? null
762 if (agent) return [...open].reverse().find(h => h.agent.toLowerCase() === agent.toLowerCase()) ?? null
763 return open.at(-1) ?? null
764}
765
766export function setHandoffTaskId(s: AtlasSnapshot, handoffId: string, taskId: string): AtlasSnapshot {
767 const clean = clip(taskId, 120)
768 if (!clean) return s
769 return { ...s, handoffs: s.handoffs.map(h => h.id === handoffId ? { ...h, taskId: clean } : h) }
770}
771
772export function reportHandoff(s: AtlasSnapshot, summaryText: string, now: number, agent?: string, idHint?: string, tests: string | null = null, files: string[] = []): AtlasSnapshot {
773 const hit = handoffFor(s, agent, idHint)
774 const summary = clip(summaryText, 140) || 'Report received'
775 const uniqueFiles = [...new Set(files.filter(Boolean))].slice(0, 20)
776 const marked = hit
777 ? s.handoffs.map(h => h.id === hit.id ? { ...h, status: 'reported' as const, summary, tests: tests ? clip(tests, 140) : h.tests, files: uniqueFiles.length ? uniqueFiles : h.files } : h)
778 : s.handoffs
779 const suffix = `${uniqueFiles.length ? ` · ${uniqueFiles.length} files` : ''}${tests ? ` · ${clip(tests, 100)}` : ''}`
780 return event({ ...s, handoffs: marked }, 'report-back', `${summary}${suffix}`, now)
781}
782
783export function closeUnverifiedHandoffs(s: AtlasSnapshot, now: number): AtlasSnapshot {
784 const hit = [...s.handoffs].reverse().find(h => h.status === 'open' && !h.reportPath && !h.taskId)
785 if (!hit) return s
786 const next: AtlasSnapshot = {
787 ...s,
788 handoffs: s.handoffs.map(h => h.id === hit.id ? { ...h, status: 'closed' as const, summary: 'back · unverified' } : h),
789 }
790 return event(next, 'report-back', `back · unverified${hit.label ? ` · ${hit.label}` : ''}`, now)
791}
792
793export function resolveRepoPath(root: string, path: string): string {
794 const value = path.trim().replace(/\\/g, '/')
795 if (!value) return ''
796 if (value.startsWith('/') || /^[A-Za-z]:\//.test(value)) return value
797 const base = root.replace(/\\/g, '/').replace(/\/+$/, '')
798 return `${base}/${value.replace(/^\.\//, '')}`
799}
800
801export function touchFile(s: AtlasSnapshot, path: string, op: 'read' | 'write', now: number, source?: string): AtlasSnapshot {
802 const resolved = resolveRepoPath(s.root, path)
803 if (!resolved) return s
804 const hit = s.files.find(f => f.path === resolved)
805 const file: AtlasFile = hit
806 ? { ...hit, reads: hit.reads + (op === 'read' ? 1 : 0), writes: hit.writes + (op === 'write' ? 1 : 0), lastOp: op, at: now, turn: s.turn, ...(source ? { source } : {}) }
807 : { path: resolved, reads: op === 'read' ? 1 : 0, writes: op === 'write' ? 1 : 0, lastOp: op, at: now, turn: s.turn, ...(source ? { source } : {}) }
808 const rest = s.files.filter(f => f.path !== resolved)
809 return { ...s, files: keep([...rest, file], LIMITS.files) }
810}
811
812function writtenSince(s: AtlasSnapshot, at: number): string[] {
813 return s.files.filter(f => f.writes > 0 && f.at > at).map(f => f.path).slice(-12)
814}
815
816// Checkpoints are evidence: a commit, a passing test run after edits, a milestone Claude
817// reports, or a mark the person makes. None of them change the goal.
818export function addCheckpoint(s: AtlasSnapshot, name: string, kind: AtlasCheckpoint['kind'], detail: string | null, now: number): AtlasSnapshot {
819 const last = s.checkpoints[s.checkpoints.length - 1]
820 const files = writtenSince(s, last?.at ?? 0)
821 if (kind === 'tests' && files.length === 0) return s
822 const [cid, next] = id(s, 'c')
823 const label = clip(name, 60) || `checkpoint-${s.checkpoints.length + 1}`
824 const cp: AtlasCheckpoint = { id: cid, name: label, at: now, turn: s.turn, kind, goal: s.goal?.text ?? null, topic: currentTopic(s)?.title ?? null, files, detail: detail ? clip(detail, 120) : null }
825 const text = kind === 'tests' ? `${kindLabel(kind)} · ${detail ? clip(detail, 120) : 'command not recorded'}` : `${kindLabel(kind)}: ${label}`
826 return flash(event({ ...next, checkpoints: keep([...next.checkpoints, cp], LIMITS.checkpoints) }, 'checkpoint', text, now), cid)
827}
828
829function kindLabel(kind: AtlasCheckpoint['kind']): string {
830 return kind === 'marked' ? 'Marked' : kind === 'commit' ? 'Commit' : kind === 'tests' ? 'Tests passed' : 'Milestone'
831}
832
833// ---------------------------------------------------------------- intent (explicit only)
834
835export function setGoal(s: AtlasSnapshot, text: string, source: AtlasSource, now: number): AtlasSnapshot {
836 const body = clip(text, 140)
837 if (!body) return s
838 if (s.detour) return s
839 const [gid, next] = id(s, 'g')
840 const history = s.goal ? keep([...s.goalHistory, s.goal], LIMITS.history) : s.goalHistory
841 const goal = { id: gid, text: body, at: now, turn: s.turn, source }
842 return flash(event(dropSuggestions({ ...next, goal, goalHistory: history }, 'goal'), 'goal', body, now), gid)
843}
844
845export function setNextStep(s: AtlasSnapshot, text: string, now: number): AtlasSnapshot {
846 const body = clip(text, 140)
847 if (!body) return s
848 return event(dropSuggestions({ ...s, nextStep: body }, 'next'), 'next', body, now)
849}
850
851export function mark(s: AtlasSnapshot, name: string, now: number): AtlasSnapshot {
852 return addCheckpoint(s, name || `mark-${s.checkpoints.filter(c => c.kind === 'marked').length + 1}`, 'marked', null, now)
853}
854
855export function startDetour(s: AtlasSnapshot, reason: string, topicId: string | null, now: number): AtlasSnapshot {
856 const body = clip(reason, 120)
857 if (!body || s.detour) return s
858 // A detour departs from the latest mark, creating one when none exists.
859 let next = s
860 let checkpoint = [...s.checkpoints].reverse().find(c => c.kind === 'marked') ?? null
861 if (!checkpoint) {
862 next = addCheckpoint(next, `before ${clip(body, 40)}`, 'marked', 'Created at detour departure', now)
863 checkpoint = next.checkpoints[next.checkpoints.length - 1] ?? null
864 }
865 const departureTopic = topicId ? s.topics.find(t => t.id === topicId) : null
866 const above = departureTopic?.parentId ? s.topics.find(t => t.id === departureTopic.parentId) : currentTopic(s)
867 const [did, withId] = id(next, 'x')
868 const detour: AtlasDetour = {
869 id: did,
870 reason: body,
871 at: now,
872 turn: s.turn,
873 topicId,
874 departure: {
875 goal: s.goal?.text ?? null,
876 topic: above?.title ?? null,
877 nextStep: s.nextStep,
878 checkpointId: checkpoint?.id ?? null,
879 decisions: s.decisions.filter(d => d.status === 'settled').map(d => d.text).slice(-8),
880 },
881 outcomes: [],
882 exclusions: [],
883 status: 'active',
884 endedAt: null,
885 }
886 const topics = withId.topics.map(t => (t.id === topicId ? { ...t, kind: 'detour' as const } : t))
887 return flash(event(dropSuggestions({ ...withId, topics, detour }, 'detour'), 'detour', body, now), did)
888}
889
890const missing = (v: string | null | undefined) => (v && v.trim() ? v : 'not recorded')
891
892// Deterministic return packet with what Claude needs to pick the main work back up.
893export function returnPacket(s: AtlasSnapshot, d: AtlasDetour): string {
894 const cp = s.checkpoints.find(c => c.id === d.departure.checkpointId)
895 const observed = s.decisions.filter(x => x.status === 'observed' && x.at >= d.at).map(x => x.text)
896 const list = (items: string[]) => (items.length ? items.map(x => `- ${x}`) : ['- not recorded'])
897 return [
898 'Conversation Atlas return packet (confirmed by the user)',
899 `Original goal: ${missing(d.departure.goal)}`,
900 `Returning to: ${missing(d.departure.topic)}`,
901 `Detour: ${d.reason}`,
902 `Return point: ${missing(cp?.name)}`,
903 `Intended next step: ${missing(d.departure.nextStep)}`,
904 'Settled decisions at departure:',
905 ...list(d.departure.decisions),
906 'Accepted detour outcomes:',
907 ...list(d.outcomes),
908 'Excluded material (explored, do not rely on it):',
909 ...list(d.exclusions ?? []),
910 'Observed during the detour, not confirmed (do not treat as settled):',
911 ...list(observed.slice(-6)),
912 ].join('\n')
913}
914
915export function returnFromDetour(s: AtlasSnapshot, now: number): AtlasSnapshot {
916 const d = s.detour
917 if (!d) return s
918 const packet = returnPacket(s, d)
919 const closed: AtlasDetour = { ...d, status: 'returned', endedAt: now }
920 let next: AtlasSnapshot = { ...s, detour: null, detourHistory: keep([...s.detourHistory, closed], LIMITS.history), pendingContext: [...s.pendingContext, packet] }
921 next = dropSuggestions(next, 'return')
922 const target = d.topicId ? s.topics.find(t => t.id === d.topicId)?.parentId : null
923 const topic = target ? next.topics.find(t => t.id === target) : null
924 if (topic && next.currentTopicId !== topic.id) next = moveTo(next, topic, now)
925 const topics = next.topics.map(t => (t.id === d.topicId ? { ...t, status: 'left' as const } : t))
926 return event({ ...next, topics }, 'return', `Returned from detour: ${d.reason}`, now)
927}
928
929export function promoteDetour(s: AtlasSnapshot, now: number): AtlasSnapshot {
930 const d = s.detour
931 if (!d) return s
932 const closed: AtlasDetour = { ...d, status: 'promoted', endedAt: now }
933 const cleared: AtlasSnapshot = { ...s, detour: null, detourHistory: keep([...s.detourHistory, closed], LIMITS.history) }
934 const promoted = setGoal(cleared, d.reason, 'person', now)
935 const topics = promoted.topics.map(t => (t.id === d.topicId ? { ...t, kind: 'main' as const } : t))
936 return event({ ...promoted, topics }, 'promote', `Detour became the goal: ${d.reason}`, now)
937}
938
939// /atlas decision: the person states a decision, so it is settled from the start.
940export function recordDecision(s: AtlasSnapshot, text: string, why: string | null, now: number): AtlasSnapshot {
941 const body = clip(why ? `${text} (because ${why})` : text, 160)
942 if (!body) return s
943 const added = addDecision(s, body, 'person', now)
944 const item = added.decisions[added.decisions.length - 1]
945 return item && item.text === body && item.status === 'observed' ? setItemStatus(added, item.id, 'settled', now) : added
946}
947
948// /atlas outcome and /atlas exclude record explicit detour findings.
949export function addDetourFinding(s: AtlasSnapshot, kind: 'outcomes' | 'exclusions', text: string, now: number): AtlasSnapshot {
950 const body = clip(text, 160)
951 if (!s.detour || !body) return s
952 const detour = { ...s.detour, [kind]: [...s.detour[kind], body].slice(-20) }
953 return event({ ...s, detour }, 'decision', `${kind === 'outcomes' ? 'Outcome' : 'Excluded'}: ${body}`, now)
954}
955
956export function setRecall(s: AtlasSnapshot, recall: AtlasRecall[]): AtlasSnapshot {
957 return { ...s, recall: recall.filter(r => r.sessionId !== s.sessionId).slice(0, 12) }
958}
959
960export function hasReplaceableContent(s: AtlasSnapshot): boolean {
961 return Boolean(
962 s.goal ||
963 s.nextStep ||
964 s.detour ||
965 s.topics.length ||
966 s.decisions.length ||
967 s.questions.length ||
968 s.checkpoints.length ||
969 s.files.length ||
970 s.activity.length,
971 )
972}
973
974function highestIdSequence(value: unknown): number {
975 if (Array.isArray(value)) return value.reduce((max, item) => Math.max(max, highestIdSequence(item)), 0)
976 if (!value || typeof value !== 'object') return 0
977 let max = 0
978 for (const [key, child] of Object.entries(value)) {
979 if (key === 'id' && typeof child === 'string') {
980 const match = /(\d+)$/.exec(child)
981 if (match) max = Math.max(max, Number(match[1]))
982 } else {
983 max = Math.max(max, highestIdSequence(child))
984 }
985 }
986 return max
987}
988
989export function recoverFull(
990 current: AtlasSnapshot,
991 source: { id: string; sessionId: string; snapshot: unknown },
992 now: number,
993): AtlasSnapshot {
994 if (snapshotError(source.snapshot)) return current
995 const loaded = upgrade(hydrate(source.snapshot, current.sessionId, current.root, now))
996 const seq = Math.max(current.seq, loaded.seq, highestIdSequence(loaded))
997 const adopted = [...new Set([...loaded.adopted, source.id])]
998 const next: AtlasSnapshot = {
999 ...loaded,
1000 sessionId: current.sessionId,
1001 root: current.root,
1002 fresh: [],
1003 recall: current.recall,
1004 adopted,
1005 pendingContext: [],
1006 seq,
1007 }
1008 return event(next, 'resume', `Loaded full map from session ${source.sessionId.slice(0, 8)}`, now)
1009}
1010
1011// Resume an earlier Atlas session: its goal, next step and settled
1012// decisions become yours because you pressed Resume; a detour it was on comes back
1013// as a suggestion, so you choose whether to re-enter it.
1014export function adoptRecall(s: AtlasSnapshot, rid: string, now: number): AtlasSnapshot {
1015 const r = s.recall.find(x => x.id === rid)
1016 if (!r || s.adopted.includes(rid)) return s
1017 let next: AtlasSnapshot = { ...s, adopted: [...s.adopted, rid] }
1018 if (r.goal && !next.detour) next = setGoal(next, r.goal, 'person', now)
1019 if (r.nextStep) next = setNextStep(next, r.nextStep, now)
1020 for (const d of r.decisions.slice(-8)) {
1021 const added = addDecision(next, d, 'person', now)
1022 const item = added.decisions[added.decisions.length - 1]
1023 next = item && item !== next.decisions[next.decisions.length - 1] ? setItemStatus(added, item.id, 'settled', now) : added
1024 }
1025 if (r.detour && !next.detour) next = suggest(next, 'detour', r.detour, 'The earlier session was on this detour', 'engine', now)
1026 return event(dropSuggestions(next, 'resume'), 'resume', `Resumed session ${r.sessionId.slice(0, 8)}: ${r.goal ?? 'no goal'}`, now)
1027}
1028
1029export function confirmSuggestion(s: AtlasSnapshot, sid: string, now: number): AtlasSnapshot {
1030 const hit = s.suggestions.find(x => x.id === sid)
1031 if (!hit) return s
1032 const rest: AtlasSnapshot = { ...s, suggestions: s.suggestions.filter(x => x.id !== sid) }
1033 if (hit.kind === 'goal') return setGoal(rest, hit.text, 'person', now)
1034 if (hit.kind === 'next') return setNextStep(rest, hit.text, now)
1035 if (hit.kind === 'detour') return startDetour(rest, hit.text, hit.topicId, now)
1036 if (hit.kind === 'return') return returnFromDetour(rest, now)
1037 if (hit.kind === 'resume') {
1038 const [goal, step] = hit.text.split(' → next: ')
1039 let next = rest.goal ? rest : setGoal(rest, goal ?? hit.text, 'person', now)
1040 if (step) next = setNextStep(next, step, now)
1041 return event(next, 'resume', hit.text, now)
1042 }
1043 return rest
1044}
1045
1046// Offered once in a new session of the same project: the last session's goal and next step.
1047export function offerResume(s: AtlasSnapshot, previous: { goal: string | null; nextStep: string | null; detour: string | null } | null, now: number): AtlasSnapshot {
1048 if (!previous?.goal || s.goal) return s
1049 const text = previous.nextStep ? `${previous.goal} → next: ${previous.nextStep}` : previous.goal
1050 return suggest(s, 'resume', text, previous.detour ? `Last session ended inside a detour: ${previous.detour}` : 'Where the last session in this project left off', 'engine', now)
1051}
1052
1053export function takeContext(s: AtlasSnapshot): [string[], AtlasSnapshot] {
1054 return s.pendingContext.length ? [s.pendingContext, { ...s, pendingContext: [] }] : [[], s]
1055}
1056
1057// What to pick up next, most specific first. Every line says where it came from.
1058export function resumeHint(s: AtlasSnapshot): { text: string; source: string; suggestionId?: string } | null {
1059 if (s.detour) return { text: `Finish "${s.detour.reason}", then return to ${s.detour.departure.topic ?? s.detour.departure.goal ?? 'the main path'}`, source: 'detour' }
1060 if (s.nextStep) return { text: s.nextStep, source: 'pinned' }
1061 const next = [...s.suggestions].reverse().find(x => x.kind === 'next')
1062 if (next) return { text: next.text, source: 'suggested', suggestionId: next.id }
1063 const open = openQuestions(s)
1064 const lastQ = open[open.length - 1]
1065 if (lastQ) return { text: `Answer: ${lastQ.text}`, source: 'open question' }
1066 const lastWrite = [...s.files].reverse().find(f => f.writes > 0)
1067 if (lastWrite) return { text: `Continue in ${lastWrite.path.split('/').pop()}`, source: 'last edit' }
1068 return null
1069}
1070
1071// One short line Claude reads beside a prompt, only while the person has confirmed intent.
1072export function intentLine(s: AtlasSnapshot): string | null {
1073 if (!s.goal && !s.detour && !s.nextStep) return null
1074 const parts = [
1075 s.goal ? `goal "${s.goal.text}"` : null,
1076 s.detour ? `on a detour "${s.detour.reason}" (returns to ${s.detour.departure.topic ?? 'the main path'})` : null,
1077 s.nextStep ? `next step "${s.nextStep}"` : null,
1078 ].filter(Boolean)
1079 return `Conversation Atlas, confirmed by the user: ${parts.join('; ')}.`
1080}
1081
1082export function summary(s: AtlasSnapshot): { goal: string | null; nextStep: string | null; detour: string | null; topic: string | null; at: number } {
1083 return { goal: s.goal?.text ?? null, nextStep: s.nextStep ?? resumeHint(s)?.text ?? null, detour: s.detour?.reason ?? null, topic: currentTopic(s)?.title ?? null, at: s.events[s.events.length - 1]?.at ?? s.startedAt }
1084}
1085hooks/scan.ts 106 lines1// Mapping a conversation that already happened: a session Atlas joins late (installed or
2// reloaded mid-thread, resumed with no save). Pure: the hooks module reads the rows with
3// `$.session.messages()` and, when asked, one `$.model.fork` reply.
4//
5// replay free; prompts, wording cues, files, tests, commits, open questions
6// claudeMap one forked request; topics, decisions, open questions, next step
7// Both write observations only, like everything else outside the confirm functions.
8
9import type { AtlasSnapshot } from '../types'
10import { askedQuestions, classify, isTestCommand } from './activity'
11import { addCheckpoint, addQuestion, clip, observe, type ObserveReport, sentences, type Shift, startTurn, touchFile } from './model'
12
13export type ScanRow = {
14 role: 'user' | 'assistant'
15 text: string
16 toolUses?: readonly { tool: string; input: Record<string, unknown>; result?: unknown; isError?: true }[]
17}
18
19// Engine rows the person did not type: reminders, command echoes, task notifications.
20function typed(text: string): boolean {
21 const t = text.trim()
22 return t.length > 0 && !t.startsWith('<') && !t.startsWith('[Request interrupted')
23}
24
25export function replay(s: AtlasSnapshot, rows: readonly ScanRow[], now: number): AtlasSnapshot {
26 let next = s
27 let lastAnswer = ''
28 for (const row of rows) {
29 if (row.role === 'user') {
30 if (typed(row.text)) next = startTurn(next, row.text, now)
31 continue
32 }
33 if (row.text.trim()) lastAnswer = row.text
34 for (const use of row.toolUses ?? []) {
35 const c = classify(String(use.tool), use.input)
36 if (!c || use.isError) continue
37 for (const f of c.files) next = touchFile(next, f.path, f.op, now)
38 if (c.isTest || (use.tool === 'Bash' && isTestCommand(String(use.input.command ?? '')))) next = addCheckpoint(next, 'Tests passed', 'tests', c.label.replace(/^Tests: /, ''), now)
39 const commit = (use.result as { gitOperation?: { commit?: { sha?: string } } } | undefined)?.gitOperation?.commit
40 if (commit?.sha) next = addCheckpoint(next, `Commit ${commit.sha.slice(0, 7)}`, 'commit', null, now)
41 // A question Claude asked earlier and the person answered is history, not an open item.
42 if (use.tool === 'AskUserQuestion' && use.result === undefined) for (const q of askedQuestions(use.input)) next = addQuestion(next, q, 'claude', now)
43 }
44 }
45 const tail = (lastAnswer.trim().split(/\n\s*\n/).pop() ?? '').trim()
46 const lastRow = rows[rows.length - 1]
47 if (lastRow?.role === 'assistant' && tail.endsWith('?')) {
48 const q = sentences(tail).filter(x => x.endsWith('?')).pop()
49 if (q) next = addQuestion(next, q, 'engine', now)
50 }
51 return next
52}
53
54export const MAP_PROMPT = `Map this conversation so far for the user's Conversation Atlas pane. Reply with JSON only, no prose, no tool calls:
55{"goal": "the user's main goal in their own words, or null",
56 "topics": [{"topic": "at most 6 words", "shift": "subtopic | sibling | possible-detour | return", "why": "one line, only for possible-detour or return"}],
57 "decisions": ["conclusions actually reached"],
58 "questions": ["questions still open now"],
59 "resolved": ["questions that were open and got answered"],
60 "next": "the most useful next step to resume from"}
61Topics in the order they happened, at most 12; the first one's shift is ignored. At most 8 decisions, 5 open questions. Observations only: do not decide anything for the user.`
62
63const SHIFTS: Shift[] = ['same', 'subtopic', 'sibling', 'possible-detour', 'return']
64
65function list(v: unknown, max: number): string[] {
66 return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').map(x => clip(x, 160)).filter(Boolean).slice(0, max) : []
67}
68
69// Parses the fork's reply; tolerant of a fenced block or text around the JSON.
70export function parseMap(text: string): { topics: { topic: string; shift: Shift; why: string | null }[]; rest: ObserveReport } | null {
71 const start = text.indexOf('{')
72 const end = text.lastIndexOf('}')
73 if (start < 0 || end <= start) return null
74 let raw: unknown
75 try {
76 raw = JSON.parse(text.slice(start, end + 1))
77 } catch {
78 return null
79 }
80 if (!raw || typeof raw !== 'object') return null
81 const v = raw as Record<string, unknown>
82 const topics = (Array.isArray(v.topics) ? v.topics : [])
83 .filter((t): t is Record<string, unknown> => Boolean(t) && typeof t === 'object')
84 .map(t => ({
85 topic: clip(t.topic, 60),
86 shift: (SHIFTS.includes(t.shift as Shift) ? t.shift : 'sibling') as Shift,
87 why: clip(t.why, 140) || null,
88 }))
89 .filter(t => t.topic)
90 .slice(0, 12)
91 const rest: ObserveReport = {
92 goal: clip(v.goal, 140) || undefined,
93 decisions: list(v.decisions, 8),
94 questions: list(v.questions, 5),
95 resolved: list(v.resolved, 8),
96 next: clip(v.next, 140) || undefined,
97 }
98 return { topics, rest }
99}
100
101export function applyMap(s: AtlasSnapshot, map: NonNullable<ReturnType<typeof parseMap>>, now: number): AtlasSnapshot {
102 let next = s
103 for (const t of map.topics) next = observe(next, { topic: t.topic, shift: t.shift, why: t.why ?? undefined }, now)
104 return observe(next, map.rest, now)
105}
106hooks/recall.ts 126 lines1// Earlier sessions of this project, read from its .claude folder. Pure: the hooks module
2// lists and reads the files, this turns their text into resume candidates.
3//
4// .claude/atlas/<session>.json Atlas save files (written by this mod)
5
6import type { AtlasRecall, AtlasSnapshot } from '../types'
7import { snapshotError, summary } from './model'
8
9export const ATLAS_DIR = '.claude/atlas'
10export const SAVE_FORMAT = 'conversation-atlas'
11
12export type AtlasFullRecall = {
13 id: string
14 sessionId: string
15 snapshot: unknown
16}
17
18export type StoredSession = { key: string; savedAt: number }
19
20export type RecallEntry = { name: string; mtimeMs?: number }
21
22export function pickRecallEntries<T extends RecallEntry>(entries: T[], limit: number): T[] {
23 return entries.slice().sort((a, b) => {
24 const aKnown = typeof a.mtimeMs === 'number' && Number.isFinite(a.mtimeMs)
25 const bKnown = typeof b.mtimeMs === 'number' && Number.isFinite(b.mtimeMs)
26 if (aKnown && bKnown && a.mtimeMs !== b.mtimeMs) return b.mtimeMs! - a.mtimeMs!
27 if (aKnown !== bKnown) return aKnown ? -1 : 1
28 return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
29 }).slice(0, Math.max(0, limit))
30}
31
32type Raw = Record<string, unknown>
33
34const isRecord = (v: unknown): v is Raw => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
35const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null)
36
37export function safeName(sessionId: string): string {
38 return sessionId.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
39}
40
41export function saveFile(s: AtlasSnapshot, savedAt: number): string {
42 const { fresh: _fresh, pendingContext: _pending, recall: _recall, ...kept } = s
43 return JSON.stringify({ format: SAVE_FORMAT, v: 1, savedAt, sessionId: s.sessionId, root: s.root, summary: summary(s), snapshot: kept })
44}
45
46function parse(text: string): Raw | null {
47 try {
48 const v = JSON.parse(text) as unknown
49 return isRecord(v) ? v : null
50 } catch {
51 return null
52 }
53}
54
55export function fromAtlasFile(text: string, root: string): AtlasRecall | null {
56 const envelope = atlasEnvelope(text, root)
57 if (!envelope) return null
58 const { v, sessionId } = envelope
59 const snap = isRecord(v.snapshot) ? v.snapshot : {}
60 const decisions = Array.isArray(snap.decisions) ? snap.decisions.filter(isRecord) : []
61 const settled = decisions.filter(d => d.status === 'settled').map(d => str(d.text)).filter((x): x is string => Boolean(x))
62 const goal = isRecord(snap.goal) ? str(snap.goal.text) : null
63 const detour = isRecord(snap.detour) ? str(snap.detour.reason) : null
64 return {
65 id: `atlas:${sessionId}`,
66 source: 'atlas',
67 sessionId,
68 at: typeof v.savedAt === 'number' ? v.savedAt : 0,
69 goal,
70 nextStep: str(snap.nextStep),
71 detour,
72 topic: isRecord(v.summary) ? str(v.summary.topic) : null,
73 decisions: settled.slice(-8),
74 }
75}
76
77function atlasEnvelope(text: string, root: string): { v: Raw; sessionId: string } | null {
78 const v = parse(text)
79 if (!v || v.format !== SAVE_FORMAT) return null
80 const sessionId = str(v.sessionId)
81 if (!sessionId || (str(v.root) && str(v.root)?.toLowerCase() !== root.toLowerCase())) return null
82 return { v, sessionId }
83}
84
85export function atlasFullFileError(text: string, root: string): string | null {
86 const v = parse(text)
87 if (!v) return 'invalid JSON'
88 if (v.format !== SAVE_FORMAT) return 'not an Atlas save file'
89 const sessionId = str(v.sessionId)
90 if (!sessionId) return 'missing session id'
91 if (str(v.root) && str(v.root)?.toLowerCase() !== root.toLowerCase()) return 'save belongs to another project'
92 if (!isRecord(v.snapshot)) return 'missing snapshot object'
93 return snapshotError(v.snapshot)
94}
95
96export function fromAtlasFullFile(text: string, root: string): AtlasFullRecall | null {
97 if (atlasFullFileError(text, root)) return null
98 const envelope = atlasEnvelope(text, root)
99 if (!envelope || !isRecord(envelope.v.snapshot)) return null
100 return { id: `atlas:${envelope.sessionId}`, sessionId: envelope.sessionId, snapshot: envelope.v.snapshot }
101}
102
103// Keep the newest candidate for each session, ordered by save time.
104export function mergeRecall(list: AtlasRecall[]): AtlasRecall[] {
105 const best = new Map<string, AtlasRecall>()
106 for (const r of list) {
107 const cur = best.get(r.id)
108 if (!cur || r.at > cur.at) best.set(r.id, r)
109 }
110 return [...best.values()]
111 .sort((a, b) => b.at - a.at)
112}
113
114export function sessionsToDelete(entries: StoredSession[], currentKey: string, keep: number): string[] {
115 const ordered = entries.slice().sort((a, b) => a.savedAt - b.savedAt || a.key.localeCompare(b.key))
116 const deleted: string[] = []
117 let remaining = ordered.length
118 for (const entry of ordered) {
119 if (remaining <= keep) break
120 if (entry.key === currentKey) continue
121 deleted.push(entry.key)
122 remaining -= 1
123 }
124 return deleted
125}
126hooks/view.tsx 21 lines1import type { RenderElement } from 'claude-code'
2import type { AtlasSnapshot } from '../types'
3import type { Surface } from './ui/shared'
4import type { ScreenPopup } from './screens/types'
5import * as Tui from './render-tui'
6import * as Gui from './render-gui'
7
8export type { Action } from './render-tui'
9export { C, GLYPH, LEGEND, TONES, rowsOf, oneLine, ago } from './render-tui'
10export const rendererFor = (surface: Surface | 'desktop'): 'gui' | 'tui' => surface === 'desktop' ? 'gui' : 'tui'
11export type Ctx = Tui.Ctx & { surface: Surface | 'desktop' }
12export function pane(ctx: Ctx, snapshot: AtlasSnapshot): ReturnType<typeof Tui.pane> {
13 return rendererFor(ctx.surface) === 'gui' ? Gui.pane(ctx, snapshot) : Tui.pane(ctx, snapshot)
14}
15export function popupShell(ctx: Ctx, popup: ScreenPopup, placement: Parameters<typeof Tui.popupShell>[2]): RenderElement {
16 return rendererFor(ctx.surface) === 'gui' ? Gui.popupShell(ctx, popup, placement) : Tui.popupShell(ctx, popup, placement)
17}
18export function legendPanel(ctx: Ctx, height?: number, at?: number): RenderElement {
19 return rendererFor(ctx.surface) === 'gui' ? Gui.legendPanel(ctx, height, at) : Tui.legendPanel(ctx, height, at)
20}
21hooks/ui/shared.tsx 360 lines1// Pure UI primitives shared by the terminal and desktop renderers.
2//
3// These functions only receive element constructors and plain render data. They
4// do not know about Atlas state and never touch `$`.
5
6import type { Elements, RenderElement } from 'claude-code'
7
8export type Surface = string
9export type UiElements = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
10
11export type UiContext = {
12 el: UiElements
13 width: number
14}
15
16export type UiRow = {
17 key: string
18 text: string
19 glyph?: string
20 glyphColor?: string
21 right?: string
22 dim?: boolean
23 bold?: boolean
24 onPress?: () => void
25}
26
27export type UiSection = {
28 key: string
29 heading: string
30 explain?: string
31 headingPress?: () => void
32 expansion?: RenderElement
33 count?: string
34 right?: string | RenderElement
35 color?: string
36 dim?: boolean
37}
38
39export type GlyphSet = {
40 goal: string
41 suggestion: string
42 currentTopic: string
43 detour: string
44 returned: string
45 observedDecision: string
46 settledDecision: string
47 checkpoint: string
48 openQuestion: string
49 resolved: string
50 ok: string
51 fail: string
52 editedFile: string
53 readFile: string
54 fresh: string
55 expanded: string
56 prompt: string
57 next: string
58 resume: string
59 handoff: string
60 reportBack: string
61}
62
63// A small cell-width approximation. It handles combining marks and the wide
64// Unicode ranges that matter for the glyphs used by Atlas without depending on
65// a terminal package that is unavailable to the plugin runtime.
66export function cellWidth(value: string): number {
67 let width = 0
68 for (const ch of value) {
69 const code = ch.codePointAt(0) ?? 0
70 if (code === 0 || (code >= 0x300 && code <= 0x36f) || (code >= 0xfe00 && code <= 0xfe0f)) continue
71 const wide = code >= 0x1100 && (
72 code <= 0x115f || code === 0x2329 || code === 0x232a ||
73 (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) ||
74 (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe10 && code <= 0xfe6f) ||
75 (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) ||
76 (code >= 0x1f300 && code <= 0x1faff)
77 )
78 width += wide ? 2 : 1
79 }
80 return width
81}
82
83export function truncateCells(value: string, width: number): string {
84 const limit = Math.max(1, Math.floor(width))
85 if (cellWidth(value) <= limit) return value
86 if (limit === 1) return '…'
87 let out = ''
88 for (const ch of value) {
89 if (cellWidth(`${out}${ch}…`) > limit) break
90 out += ch
91 }
92 return `${out}…`
93}
94
95function takeStartCells(value: string, width: number): string {
96 const limit = Math.max(0, Math.floor(width))
97 let out = ''
98 for (const ch of value) {
99 if (cellWidth(`${out}${ch}`) > limit) break
100 out += ch
101 }
102 return out
103}
104
105function takeEndCells(value: string, width: number): string {
106 const limit = Math.max(0, Math.floor(width))
107 let out = ''
108 for (const ch of Array.from(value).reverse()) {
109 if (cellWidth(`${ch}${out}`) > limit) break
110 out = `${ch}${out}`
111 }
112 return out
113}
114
115// Keep a path's filename intact while preserving the beginning and a small
116// amount of the tail. The filename is only truncated when it cannot fit by
117// itself, which makes this useful for both absolute and project-relative paths.
118export function truncateMiddleCells(value: string, width: number): string {
119 const limit = Math.max(1, Math.floor(width))
120 if (cellWidth(value) <= limit) return value
121 const slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
122 const filename = slash >= 0 ? value.slice(slash + 1) : value
123 if (cellWidth(filename) + 1 >= limit) return truncateCells(filename, limit)
124
125 const before = slash >= 0 ? value.slice(0, slash + 1) : ''
126 const spare = limit - cellWidth(filename) - 1
127 const tailBudget = Math.min(spare, Math.max(1, Math.floor(spare / 2)))
128 const tail = takeEndCells(before, tailBudget)
129 const suffix = `${tail}${filename}`
130 const head = takeStartCells(value, Math.max(0, limit - cellWidth(suffix) - 1))
131 const result = head ? `${head}…${suffix}` : `…${suffix}`
132 return cellWidth(result) <= limit ? result : truncateCells(filename, limit)
133}
134
135export type RowMetaInput = { text: string; compact?: string }
136
137export type RowLayoutInput = {
138 width: number
139 prefix: string
140 text: string
141 meta?: string
142 metaParts?: readonly RowMetaInput[]
143 right?: string
144 middle?: boolean
145}
146
147export type RowMetaOutput = { text: string; sourceIndex: number }
148
149export type RowLayout = {
150 text: string
151 meta?: string
152 metaParts: RowMetaOutput[]
153 right?: string
154}
155
156type MetaChoice = { meta?: string; parts: RowMetaOutput[]; width: number; separator: number }
157
158function oneLine(value: string): string {
159 return value.replace(/\s+/g, ' ').trim()
160}
161
162function shortMetaPart(value: string): string {
163 const colon = value.indexOf(':')
164 if (colon > 0) return value.slice(colon + 1).trim()
165 return value.replace(/^turn\s+/i, 't').replace(/\bsuggestion\b/gi, 'suggestion')
166}
167
168function metaChoices(input: RowLayoutInput): MetaChoice[] {
169 if (input.metaParts?.length) {
170 const compact = input.width < 60
171 const parts = input.metaParts.map((part, sourceIndex) => ({
172 text: oneLine(compact ? part.compact ?? part.text : part.text),
173 sourceIndex,
174 }))
175 const choices: MetaChoice[] = []
176 for (let count = parts.length; count >= 0; count--) {
177 const selected = parts.slice(0, count)
178 choices.push({
179 parts: selected,
180 width: selected.reduce((sum, part, index) => sum + cellWidth(part.text) + (index ? 1 : 0), 0),
181 separator: selected.length ? 1 : 0,
182 })
183 }
184 return choices
185 }
186
187 const words = input.meta ? oneLine(input.meta).split(' · ').filter(Boolean) : []
188 const choices: MetaChoice[] = []
189 for (let count = words.length; count >= 0; count--) {
190 const selected = words.slice(0, count)
191 const value = selected.join(' · ')
192 const short = selected.map(shortMetaPart).join(' · ')
193 choices.push({ meta: value || undefined, parts: [], width: cellWidth(value), separator: value && input.right ? 3 : 0 })
194 if (short && short !== value) {
195 choices.push({ meta: short, parts: [], width: cellWidth(short), separator: short && input.right ? 3 : 0 })
196 }
197 }
198 return choices.length ? choices : [{ parts: [], width: 0, separator: 0 }]
199}
200
201function choiceWidth(choice: MetaChoice, right: string): number {
202 return choice.width + choice.separator + cellWidth(right)
203}
204
205// Layout the four row segments in priority order: prefix/glyph, text, low
206// priority metadata, and the right-side time or status. The renderer uses the
207// returned metadata indexes to keep each file count's color intact.
208export function layoutRow(input: RowLayoutInput): RowLayout {
209 const width = Math.max(1, Math.floor(input.width))
210 const prefix = input.prefix.replace(/[\r\n\t]+/g, ' ')
211 const text = oneLine(input.text)
212 const rightRaw = input.right ? oneLine(input.right) : ''
213 const prefixWidth = cellWidth(prefix)
214 const choices = metaChoices(input)
215 const fits = (choice: MetaChoice, candidateText: string, right: string): boolean =>
216 prefixWidth + cellWidth(candidateText) + choiceWidth(choice, right) <= width
217 const chosen = choices.find(choice => fits(choice, text, rightRaw)) ?? choices.at(-1) ?? { parts: [], width: 0, separator: 0 }
218 const availableText = Math.max(1, width - prefixWidth - choiceWidth(chosen, rightRaw))
219 const candidateText = input.middle ? truncateMiddleCells(text, availableText) : truncateCells(text, availableText)
220 const displayText = cellWidth(candidateText) <= availableText
221 ? candidateText
222 : availableText === 1
223 ? '…'
224 : `${takeStartCells(candidateText, availableText - 1)}…`
225 const right = prefixWidth + cellWidth(displayText) + choiceWidth(chosen, rightRaw) <= width
226 ? rightRaw || undefined
227 : rightRaw
228 ? truncateCells(rightRaw, Math.max(1, width - prefixWidth - cellWidth(displayText) - chosen.width - chosen.separator))
229 : undefined
230 return {
231 text: displayText,
232 meta: chosen.meta,
233 metaParts: chosen.parts,
234 right,
235 }
236}
237
238export type BarItem = {
239 key: string
240 label: string
241 short?: string
242 compact?: string
243 icon?: string
244 active?: boolean
245 activeColor?: string
246 hotkey?: string
247 onPress: () => void
248}
249
250export type BarLabels = {
251 tier: 'full' | 'short' | 'compact'
252 labels: string[]
253 itemWidths: number[]
254 grid: BarGrid
255}
256
257export type BarGridRow = {
258 items: number[]
259 width: number
260}
261
262export type BarGrid = {
263 columns: number
264 rows: BarGridRow[]
265 columnWidths: number[]
266 rowWidths: number[]
267 fits: boolean
268}
269
270export type BarMeasure = {
271 label: string
272 hotkey?: string
273 icon?: string
274 activeMarker?: string
275 prefixGap?: number
276 buttonChrome?: 'none' | 'bracketed'
277}
278
279// The renderer draws a hotkey as an accent key, colon and space. Keep that
280// chrome here so layout and tests measure the same thing the person sees.
281export function measuredBarItemWidth(measure: BarMeasure): number {
282 const prefix = measure.icon ? cellWidth(measure.icon) + (measure.prefixGap ?? 0) : 0
283 const marker = measure.activeMarker ? cellWidth(measure.activeMarker) : 0
284 const hotkey = measure.hotkey ? cellWidth(measure.hotkey) + 2 : 0
285 const brackets = measure.buttonChrome === 'bracketed' ? 2 : 0
286 return prefix + marker + hotkey + brackets + cellWidth(measure.label)
287}
288
289export type BarKind = 'tabs' | 'bar'
290
291function gridForWidths(widths: readonly number[], availableWidth: number, gap: number): BarGrid {
292 const count = widths.length
293 if (count === 0) return { columns: 0, rows: [], columnWidths: [], rowWidths: [], fits: true }
294 const available = Math.max(1, Math.floor(availableWidth))
295 for (let columns = count; columns >= 1; columns--) {
296 const columnWidths = Array.from({ length: columns }, () => 0)
297 const rows: BarGridRow[] = []
298 for (let start = 0; start < count; start += columns) {
299 const items = Array.from({ length: Math.min(columns, count - start) }, (_, offset) => start + offset)
300 for (const index of items) columnWidths[index % columns] = Math.max(columnWidths[index % columns] ?? 0, widths[index] ?? 0)
301 rows.push({ items, width: 0 })
302 }
303 const rowWidths = rows.map(row => {
304 const width = row.items.reduce((sum, index) => sum + (columnWidths[index % columns] ?? 0), 0) + gap * Math.max(0, row.items.length - 1)
305 row.width = width
306 return width
307 })
308 if (rowWidths.every(width => width <= available)) return { columns, rows, columnWidths, rowWidths, fits: true }
309 }
310 const columnWidths = [Math.max(...widths)]
311 const rows = widths.map((width, index) => ({ items: [index], width }))
312 return { columns: 1, rows, columnWidths, rowWidths: rows.map(row => row.width), fits: false }
313}
314
315// Choose the largest readable grid first. A shorter tier is allowed to keep
316// more columns, but only after the full tier fails for that column count.
317export function barGrid(widths: readonly number[], availableWidth: number, gap = 1): BarGrid {
318 return gridForWidths(widths, availableWidth, gap)
319}
320
321function tierLabels(items: BarItem[], tier: BarLabels['tier']): string[] {
322 return items.map(item => tier === 'full'
323 ? item.label
324 : tier === 'short'
325 ? (item.short ?? item.label)
326 : (item.compact ?? item.icon ?? item.short ?? item.label.slice(0, 1)))
327}
328
329export function barWidth(labels: string[], gap = 1): number {
330 return labels.reduce((sum, label, i) => sum + cellWidth(label) + (i ? gap : 0), 0)
331}
332
333
334export function collapseBarLabelsWith(
335 items: BarItem[],
336 width: number,
337 tiers: BarLabels['tier'][],
338 measure: (item: BarItem, label: string, kind: BarKind, hotkeys: boolean) => number,
339 gap = 1,
340 kind: BarKind = 'bar',
341): BarLabels {
342 const widthsFor = (labels: string[], hotkeys: boolean) => items.map((item, i) => measure(item, labels[i] ?? '', kind, hotkeys))
343 const columns = Math.max(1, items.length)
344 for (let desiredColumns = columns; desiredColumns >= 1; desiredColumns--) {
345 for (const tier of tiers) {
346 const tieredLabels = tierLabels(items, tier)
347 const widths = widthsFor(tieredLabels, true)
348 const grid = barGrid(widths, width, gap)
349 if (grid.fits && grid.columns === desiredColumns) return { tier, labels: tieredLabels, itemWidths: widths, grid }
350 }
351 }
352 const compact = tierLabels(items, 'compact').map((label, i) => {
353 const fixed = measure(items[i]!, '', kind, false)
354 return truncateCells(label, Math.max(1, Math.max(1, Math.floor(width)) - fixed))
355 })
356 const widths = widthsFor(compact, false)
357 return { tier: 'compact', labels: compact, itemWidths: widths, grid: barGrid(widths, width, gap) }
358}
359
360hooks/screens/types.ts 148 lines1// Defines the pure screen models and typed actions shared by every Atlas screen. No `$`.
2
3import type { AtlasMode, AtlasPopup, AtlasSelection, AtlasSnapshot, AtlasTab, AtlasTrailView, AtlasView } from '../../types'
4
5export type Action =
6 | { type: 'tab'; tab: AtlasTab }
7 | { type: 'legend' }
8 | { type: 'legend-howto' }
9 | { type: 'popup'; popup: AtlasPopup }
10 | { type: 'popup-scroll'; by: number }
11 | { type: 'expanded-scroll'; by: number }
12 | { type: 'scroll'; by: number }
13 | { type: 'scroll-to'; at: number }
14 | { type: 'trail-sort' }
15 | { type: 'trail-view'; view: AtlasTrailView }
16 | { type: 'trail-view-set'; view: AtlasTrailView }
17 | { type: 'trail-settings-page'; page: number }
18 | { type: 'trail-filter'; filter: 'b' | 'h' }
19 | { type: 'expand'; id: string }
20 | { type: 'confirm'; id: string }
21 | { type: 'dismiss'; id: string }
22 | { type: 'dismiss-goal-alt'; text: string }
23 | { type: 'settle'; id: string }
24 | { type: 'weight'; id: string }
25 | { type: 'exclude'; id: string }
26 | { type: 'drop'; id: string }
27 | { type: 'restore'; id: string }
28 | { type: 'resolve'; id: string }
29 | { type: 'reopen'; id: string }
30 | { type: 'attach'; ref: AtlasSelection }
31 | { type: 'return' }
32 | { type: 'promote' }
33 | { type: 'mark' }
34 | { type: 'goal'; text: string }
35 | { type: 'pin'; text: string }
36 | { type: 'edit-goal' }
37 | { type: 'adopt'; id: string }
38 | { type: 'adopt-full'; id: string }
39 | { type: 'scan' }
40 | { type: 'open-setup' }
41 | { type: 'set-observer'; mode: AtlasMode }
42 | { type: 'toggle-observer' }
43
44export type GlyphKey =
45 | 'goal'
46 | 'suggestion'
47 | 'currentTopic'
48 | 'detour'
49 | 'returned'
50 | 'observedDecision'
51 | 'settledDecision'
52 | 'checkpoint'
53 | 'openQuestion'
54 | 'resolved'
55 | 'ok'
56 | 'fail'
57 | 'editedFile'
58 | 'readFile'
59 | 'fresh'
60 | 'expanded'
61 | 'prompt'
62 | 'next'
63 | 'resume'
64 | 'handoff'
65 | 'reportBack'
66
67export type ToneKey = 'goal' | 'path' | 'trail' | 'detour' | 'decision' | 'question' | 'checkpoint' | 'read' | 'write' | 'ok' | 'fail'
68
69export type ScreenAction = {
70 key: string
71 label: string
72 action: Action
73 primary?: boolean
74 role?: 'dismiss'
75}
76
77export type ScreenRow = {
78 id: string
79 key: string
80 kind: 'text' | 'goal' | 'suggestion' | 'topic' | 'item' | 'file' | 'activity' | 'detour' | 'next' | 'event' | 'checkpoint' | 'recall'
81 glyph?: GlyphKey
82 // A semantic modifier drawn after the leading icon by each renderer.
83 marker?: '✦' | '!' | '·'
84 sourceMark?: string
85 sourceMarkColor?: string
86 text: string
87 checkbox?: boolean
88 direction?: 'asc' | 'desc'
89 setting?: boolean
90 meta?: string
91 metaParts?: { text: string; compact?: string; tone?: ToneKey; dim?: boolean }[]
92 right?: string
93 source?: string
94 tone?: ToneKey
95 dim?: boolean
96 bold?: boolean
97 italic?: boolean
98 fresh?: boolean
99 depth?: number
100 expandable?: boolean
101 detail?: string[]
102 fullText?: string
103 actions?: ScreenAction[]
104 suggestedGoals?: ScreenRow[]
105 interactive?: boolean
106 overflowPopup?: boolean
107 empty?: boolean
108 // Path/activity rows can retain their semantic live behavior without any
109 // renderer-specific element or width assumption in the screen model.
110 live?: 'path' | 'activity'
111}
112
113export type ScreenSection = {
114 key: string
115 heading: string
116 explain: string
117 help: string[]
118 count?: string
119 right?: string
120 tone?: ToneKey
121 dim?: boolean
122 rows: ScreenRow[]
123 empty?: string
124 actions?: ScreenAction[]
125 input?: { key: string; label: string; placeholder: string; submitLabel: string; value: string }
126}
127
128export type ScreenPopup = {
129 kind: 'legend' | 'item' | 'trail-view'
130 id?: string
131 title: string
132 titleCount?: string
133 page?: { current: number; total: number; name: string }
134 closeGlyph?: boolean
135 rows: ScreenRow[]
136 footer?: string
137 footerActions?: ScreenAction[]
138}
139
140export type ScreenModel = {
141 tab: AtlasTab
142 sections: ScreenSection[]
143}
144
145export type ScreenView = AtlasView & { mode: AtlasMode; hidden?: string[]; settingsPage?: number }
146
147export type ScreenBuilder = (snapshot: AtlasSnapshot, view: ScreenView, now: number) => ScreenModel
148hooks/render-tui.tsx 1519 lines1// The pane shell and surface renderers. Tab content is pure data from
2// hooks/screens; this module decides only how that data is laid out.
3
4import type { Elements, RenderElement } from 'claude-code'
5import type { AtlasMode, AtlasScanState, AtlasSnapshot, AtlasTab, AtlasView } from '../types'
6import type { LiveRow } from './live'
7import { openQuestions } from './model'
8import {
9 collapseBarLabelsWith,
10 cellWidth,
11 layoutRow as uiLayoutRow,
12 measuredBarItemWidth,
13 type BarItem,
14 type BarKind,
15 type GlyphSet,
16 type UiContext,
17 type UiSection,
18} from './ui/shared'
19import { buildEvidence } from './screens/evidence'
20import { buildMap } from './screens/map'
21import { buildOpen } from './screens/open'
22import { buildTrail, SOURCE_MARK, trailViewPopup } from './screens/trail'
23import { ago } from './screens/shared'
24import type {
25 Action,
26 GlyphKey,
27 ScreenAction,
28 ScreenBuilder,
29 ScreenModel,
30 ScreenPopup,
31 ScreenRow,
32 ScreenSection,
33 ScreenView,
34} from './screens/types'
35
36export type { Action } from './screens/types'
37
38export const C = {
39 goal: '#7dcfff',
40 action: '#7dcfff',
41 path: '#bb9af7',
42 trail: '#b9f27c',
43 map: 'yellowBright',
44 detour: '#e0af68',
45 decision: '#9ece6a',
46 question: '#f7768e',
47 checkpoint: '#7aa2f7',
48 fail: '#f7768e',
49 ok: '#9ece6a',
50 read: '#bb9af7',
51 write: '#ff9e64',
52} as const
53
54type Glyph = { char: string; color?: string }
55export const GLYPH: Record<GlyphKey, Glyph> = {
56 goal: { char: '◎', color: C.goal },
57 suggestion: { char: '○' },
58 currentTopic: { char: '●', color: C.path },
59 detour: { char: '↳', color: C.detour },
60 returned: { char: '↩', color: C.detour },
61 observedDecision: { char: '◇', color: C.decision },
62 settledDecision: { char: '◆', color: C.decision },
63 checkpoint: { char: '⚑', color: C.checkpoint },
64 openQuestion: { char: '?', color: C.question },
65 resolved: { char: '✓', color: C.ok },
66 ok: { char: '✓', color: C.ok },
67 fail: { char: '✗', color: C.fail },
68 editedFile: { char: '✎', color: C.write },
69 readFile: { char: '·', color: C.read },
70 fresh: { char: '✦' },
71 expanded: { char: '▾', color: C.goal },
72 prompt: { char: '›' },
73 next: { char: '▸', color: C.goal },
74 resume: { char: '◎', color: C.goal },
75 handoff: { char: '→', color: C.checkpoint },
76 reportBack: { char: '←', color: C.ok },
77}
78const TAB_ACCENT: Record<AtlasTab, string> = { map: C.map, trail: C.trail, open: C.question, evidence: C.checkpoint }
79export const TONES: Record<string, string[]> = {
80 violet: ['#6d5a9c', '#8f78c9', '#b9a3f0', '#efe6ff'],
81 orange: ['#9c5a2c', '#c9783e', '#f0a46e', '#fff0e0'],
82 amber: ['#8c6a2c', '#b8903e', '#e0b86e', '#fff4d6'],
83 green: ['#4f7a3a', '#6fa052', '#9ece6a', '#e8ffd6'],
84 blue: ['#3d5a9c', '#5a7ac9', '#8aa8f0', '#e0eaff'],
85}
86
87type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & { Input?: Elements['terminal']['Input'] }
88export type Ctx = {
89 el: El
90 width: number
91 rows: number
92 bodyViewport?: number
93 inPopup?: boolean
94 now: number
95 mode: AtlasMode
96 setupDefault: AtlasMode
97 scan: AtlasScanState
98 view: AtlasView & { hidden?: string[]; settingsPage?: number }
99 live: (
100 key: string,
101 rows: LiveRow[],
102 scan?: { active: boolean; startedAt: number; result: string | null; resultAt: number; now: number },
103 ) => RenderElement
104 act: (action: Action) => void
105}
106type PaneCtx = Ctx
107
108// Owner decision "Option A": flip to true to restore expansion close buttons.
109const EXPANSION_CLOSE_BUTTON = false
110
111const OBSERVER_NOTE = 'Needs the Claude observer · /atlas observer claude'
112const POPUP_BG = '#16161e'
113const SCAN_RESULT_MS = 4_000
114
115function childrenOf(node: { children?: unknown; props?: Record<string, unknown> } | null | undefined): unknown {
116 return node?.children ?? node?.props?.children
117}
118function kids(value: unknown): unknown[] {
119 if (value === null || value === undefined || value === false || value === true) return []
120 if (Array.isArray(value)) return value.flatMap(kids)
121 return [value]
122}
123function textOf(value: unknown): string {
124 return kids(value)
125 .map(item =>
126 typeof item === 'string' || typeof item === 'number'
127 ? String(item)
128 : textOf(childrenOf(item as { children?: unknown; props?: Record<string, unknown> })),
129 )
130 .join('')
131}
132
133export function rowsOf(value: unknown, width: number): number {
134 const list = kids(value)
135 if (list.length !== 1) return list.reduce((sum: number, item) => sum + rowsOf(item, width), 0)
136 const node = list[0]
137 if (typeof node === 'string' || typeof node === 'number') return 1
138 const element = node as { type?: string; props?: Record<string, unknown>; children?: unknown }
139 const props = element.props ?? {}
140 if (props.position === 'absolute') return 0
141 const extra =
142 (Number(props.marginTop) || 0) +
143 (Number(props.marginBottom) || 0) +
144 2 * (Number(props.marginY) || 0) +
145 2 * (Number(props.paddingY) || 0)
146 switch (element.type) {
147 case 'Text': {
148 const lines = textOf(childrenOf(element)).split('\n')
149 return (
150 (props.wrap === 'wrap'
151 ? lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / Math.max(8, width))), 0)
152 : Math.max(1, lines.length)) + extra
153 )
154 }
155 case 'Button':
156 case 'Input':
157 case 'Select':
158 return 1 + extra
159 case 'Client': {
160 const client = props.props as { rows?: unknown[]; scan?: { active?: boolean; result?: string | null } } | undefined
161 return Math.max(client?.scan?.active ? 2 : client?.scan?.result ? 1 : 0, client?.rows?.length ?? 1) + extra
162 }
163 case 'Box': {
164 if (props.display === 'none') return 0
165 if (props.overflow === 'hidden' && typeof props.height === 'number') return props.height + extra
166 const children = kids(childrenOf(element)).filter(
167 child => (child as { props?: Record<string, unknown> }).props?.position !== 'absolute',
168 )
169 const inner = Math.max(8, width - 2 * (Number(props.paddingX) || 0) - (Number(props.marginLeft) || 0))
170 if (props.flexDirection === 'row') {
171 if (props.flexWrap === 'wrap')
172 return (
173 Math.max(
174 1,
175 Math.ceil(
176 children.reduce(
177 (sum: number, child) =>
178 sum +
179 textOf(
180 (child as { props?: Record<string, unknown> }).props?.label ??
181 childrenOf(child as { props?: Record<string, unknown> }),
182 ).length +
183 5,
184 0,
185 ) / inner,
186 ),
187 ) + extra
188 )
189 const fixed = children.reduce((sum: number, child) => {
190 const childProps = (child as { props?: Record<string, unknown> }).props ?? {}
191 return sum + (typeof childProps.width === 'number' ? childProps.width : 0)
192 }, 0)
193 return Math.max(0, ...children.map(child => {
194 const childProps = (child as { props?: Record<string, unknown> }).props ?? {}
195 const available = typeof childProps.width === 'number'
196 ? childProps.width
197 : childProps.flexGrow && childProps.flexShrink ? inner - fixed : inner
198 return rowsOf(child, Math.max(8, available))
199 })) + extra
200 }
201 const gap = (Number(props.gap) || 0) * Math.max(0, children.length - 1)
202 return children.reduce((sum: number, child) => sum + rowsOf(child, inner), 0) + gap + extra
203 }
204 default:
205 return 1 + extra
206 }
207}
208
209function toneColor(tone: ScreenRow['tone'] | ScreenSection['tone']): string | undefined {
210 return tone ? C[tone] : undefined
211}
212function glyph(ctx: Ctx, name: GlyphKey): Glyph {
213 return { ...GLYPH[name], char: surfaceGlyphs[name] }
214}
215function actions(
216 ctx: Ctx,
217 items: ScreenAction[],
218 options: { marginLeft?: number; gap?: number; flexWrap?: 'wrap' | 'nowrap' } = {},
219): RenderElement | null {
220 if (!items.length) return null
221 return ActionGroup(
222 { el: ctx.el, width: ctx.width },
223 items.map(item => ({ key: item.key, label: item.label, primary: item.primary, role: item.role, onPress: () => ctx.act(item.action) })),
224 options,
225 )
226}
227
228// Reserve the close cell before allowing the left-hand actions to wrap.
229function expansionActions(ctx: Ctx, row: ScreenRow, items: ScreenAction[]): RenderElement | null {
230 const { Box, Button } = ctx.el
231 const close = items.find(item => item.action.type === 'expand' && item.action.id === row.id)
232 const left = items.filter(item => item !== close)
233 if (!EXPANSION_CLOSE_BUTTON) return actions(ctx, left)
234 return (
235 <Box key={`expansion-actions-${row.key}`} flexDirection="row" gap={1} marginLeft={2} flexShrink={0}>
236 <Box key={`expansion-actions-left-${row.key}`} flexGrow={1} flexShrink={1} minWidth={0}>
237 {actions({ ...ctx, width: Math.max(1, ctx.width - 4) }, left, { marginLeft: 0 })}
238 </Box>
239 <Box key={`expansion-close-cell-${row.key}`} width={1} flexShrink={0}>
240 <Button key={close?.key ?? `close-${row.key}`} plain label="✕"
241 onPress={() => ctx.act(close?.action ?? { type: 'expand', id: row.id })} />
242 </Box>
243 </Box>
244 )
245}
246
247function layoutRow(input: Parameters<typeof uiLayoutRow>[0], keepCounts = false): ReturnType<typeof uiLayoutRow> {
248 const layout = uiLayoutRow(keepCounts ? { ...input, text: '' } : input)
249 const parts = layout.metaParts.map(part => part.text)
250 const droppedShortMeta = !parts.length && !layout.meta && !input.metaParts?.length && input.meta?.trim() === 'auto'
251 ? input.meta
252 : undefined
253 const meta = parts.length ? [...parts, ...(layout.right ? [layout.right] : [])].join(' ')
254 : [layout.meta ?? droppedShortMeta, layout.right].filter(Boolean).join(' · ')
255 if (!meta) return layout
256 const available = Math.max(1, input.width - cellWidth(input.prefix) - cellWidth(meta) - 1)
257 const title = uiLayoutRow({ width: available, prefix: '', text: input.text, middle: input.middle })
258 return { ...layout, text: title.text, meta: layout.meta ?? droppedShortMeta }
259}
260
261function inlineMeta(ctx: Ctx, row: ScreenRow, layout: ReturnType<typeof layoutRow>): RenderElement | null {
262 const { Box, Text } = ctx.el
263 if (layout.metaParts.length) {
264 return (
265 <Box flexDirection="row" gap={1} flexShrink={0}>
266 {layout.metaParts.map(part => {
267 const source = row.metaParts?.[part.sourceIndex]
268 return (
269 <Text
270 key={`meta-${row.key}-${part.sourceIndex}`}
271 color={source?.tone ? toneColor(source.tone) : undefined}
272 dimColor={source?.dim}
273 >
274 {part.text}
275 </Text>
276 )
277 })}
278 {layout.right ? <Text dimColor>{layout.right}</Text> : null}
279 </Box>
280 )
281 }
282 if (!layout.meta && !layout.right) return null
283 return (
284 <Box flexDirection="row" gap={1} flexShrink={0}>
285 {layout.meta ? <Text dimColor wrap="truncate-end">{layout.meta}</Text> : null}
286 {layout.meta && layout.right ? <Text dimColor>·</Text> : null}
287 {layout.right ? <Text dimColor wrap="truncate-end">{layout.right}</Text> : null}
288 </Box>
289 )
290}
291
292function renderRow(ctx: Ctx, row: ScreenRow): RenderElement {
293 const content = renderRowContent(ctx, row)
294 if (ctx.inPopup || !row.expandable || ctx.view.expanded !== row.id) return content
295 const { Box, Text } = ctx.el
296 return (
297 <Box key={`expansion-${row.key}`} flexDirection="column">
298 {content}
299 <Box key={`expansion-gap-${row.key}`} height={1} flexShrink={0}><Text>{''}</Text></Box>
300 </Box>
301 )
302}
303
304function renderRowContent(ctx: Ctx, row: ScreenRow): RenderElement {
305 const { Box, Text, Button } = ctx.el
306 if (row.setting) {
307 const mark = row.checkbox === undefined ? '' : row.checkbox ? '[x] ' : '[ ] '
308 const prefix = `${row.direction ? (row.direction === 'desc' ? '▼ ' : '▲ ') : ''}${mark}`
309 const layout = uiLayoutRow({ width: ctx.width, prefix, text: row.text, right: row.meta })
310 const tail = layout.right ?? ''
311 const used = cellWidth(prefix + layout.text) + (tail ? cellWidth(tail) + 1 : 0)
312 const label = `${prefix}${layout.text}${tail ? `${' '.repeat(Math.max(1, ctx.width - used))}${tail}` : ''}`
313 const action = row.actions?.[0]?.action
314 return <Button key={row.key} plain label={label} onPress={() => action && ctx.act(action)} />
315 }
316 const rawGlyph = row.glyph ? glyph(ctx, row.glyph) : undefined
317 const g = rawGlyph ? { ...rawGlyph, char: rawGlyph.char.replace(/\uFE0F/g, '') } : undefined
318 const sourceMark = g ? undefined : row.sourceMark?.replace(/\uFE0F/g, '')
319 const indent = row.depth ? ' '.repeat(row.depth) : ''
320 const open = row.expandable && ctx.view.expanded === row.id
321 const headPress = row.interactive
322 ? () =>
323 ctx.act(
324 row.kind === 'event' || open
325 ? { type: 'expand', id: row.id }
326 : row.overflowPopup
327 ? { type: 'popup', popup: { kind: 'item', id: row.id } }
328 : { type: 'expand', id: row.id },
329 )
330 : undefined
331 const prefix = [
332 indent,
333 row.fresh ? `${GLYPH.fresh.char} ` : '',
334 sourceMark ? `${sourceMark} ` : '',
335 g ? `${g.char} ` : '',
336 row.marker ? `${row.marker} ` : '',
337 open ? `${GLYPH.expanded.char} ` : '',
338 ].join('')
339 const layout = layoutRow({
340 width: ctx.width,
341 prefix,
342 text: row.text,
343 meta: row.meta,
344 metaParts: row.metaParts,
345 right: row.right,
346 middle: row.kind === 'file' || (row.kind === 'activity' && /[\\/]/.test(row.text)),
347 }, row.kind === 'event' && Boolean(row.metaParts?.length))
348 const head = (
349 <Box key={`head-${row.key}`} flexDirection="row" flexShrink={0}>
350 {indent ? <Text dimColor>{indent}</Text> : null}
351 {row.fresh ? (
352 <Text color={GLYPH.fresh.color} bold>{`${GLYPH.fresh.char} `}</Text>
353 ) : null}
354 {sourceMark ? (
355 <Text color={row.sourceMarkColor}>{`${sourceMark} `}</Text>
356 ) : null}
357 {g ? (
358 <Text color={g.color} bold={row.bold || open}>{`${g.char} `}</Text>
359 ) : null}
360 {row.marker ? <Text color={row.marker === '!' ? C.decision : row.marker === '✦' ? C.goal : undefined} dimColor={row.marker === '·'} bold={row.marker !== '·'}>{`${row.marker} `}</Text> : null}
361 {open ? <Text color={GLYPH.expanded.color}>{`${GLYPH.expanded.char} `}</Text> : null}
362 <Box flexShrink={1} minWidth={0} overflow="hidden">
363 {headPress ? (
364 <Button
365 key={row.key}
366 plain
367 dimColor={row.dim}
368 label={layout.text}
369 onPress={headPress}
370 />
371 ) : (
372 <Text bold={row.bold} dimColor={row.dim} italic={row.italic} wrap="truncate-end">
373 {layout.text}
374 </Text>
375 )}
376 </Box>
377 <Box flexGrow={1}>
378 {layout.meta || layout.metaParts.length || layout.right ? <Text>{' '}</Text> : null}
379 </Box>
380 {inlineMeta(ctx, row, layout)}
381 </Box>
382 )
383 if (row.kind === 'text')
384 return (
385 <Text key={row.key} bold={row.bold} dimColor={row.dim} wrap="wrap">
386 {row.text}
387 </Text>
388 )
389 if (row.kind === 'suggestion' && !row.expandable)
390 return (
391 <Box key={row.key} flexDirection="column">
392 {head}
393 {actions(ctx, row.actions ?? [])}
394 </Box>
395 )
396 if (row.kind === 'suggestion' || row.kind === 'recall') {
397 const extra = [
398 ...(row.actions ?? []),
399 { key: `close-${row.key}`, label: '✕', action: { type: 'expand', id: row.id } as const },
400 ]
401 return (
402 <Box key={row.key} flexDirection="column">
403 {head}
404 {open ? <Detail ctx={ctx} row={row} /> : null}
405 {open ? expansionActions(ctx, row, extra) : null}
406 </Box>
407 )
408 }
409 if (row.kind === 'activity') return <Box key={row.key}>{head}</Box>
410 if (row.kind === 'detour')
411 return (
412 <Box key={row.key} flexDirection="column">
413 {head}
414 {open ? <Detail ctx={ctx} row={row} /> : null}
415 {open
416 ? expansionActions(ctx, row, [
417 ...(row.actions ?? []),
418 { key: `close-${row.key}`, label: '✕', action: { type: 'expand', id: row.id } },
419 ])
420 : null}
421 </Box>
422 )
423 if (row.kind === 'next')
424 return (
425 <Box key={row.key} flexDirection="column">
426 {head}
427 {open ? expansionActions(ctx, row, row.actions ?? []) : actions(ctx, row.actions ?? [])}
428 {open ? <Detail ctx={ctx} row={row} /> : null}
429 </Box>
430 )
431 if (!row.expandable)
432 return (
433 <Box key={row.key} flexDirection="column">
434 {head}
435 {actions(ctx, row.kind === 'event' ? [] : (row.actions ?? []))}
436 </Box>
437 )
438 if (!open)
439 return (
440 <Box key={row.key} flexDirection="column">
441 {head}
442 </Box>
443 )
444 const extra: ScreenAction[] =
445 row.kind === 'goal'
446 ? (row.actions ?? [])
447 : [
448 ...(row.actions ?? []),
449 {
450 key: `add-${row.key}`,
451 label: 'Chat ⇒',
452 action: { type: 'attach', ref: { kind: row.kind, id: row.id, text: row.fullText ?? row.text } },
453 },
454 { key: `close-${row.key}`, label: '✕', action: { type: 'expand', id: row.id } },
455 ]
456 return (
457 <Box key={row.key} flexDirection="column">
458 {head}
459 {row.kind === 'event' ? <EventDetail ctx={ctx} row={row} /> : <Detail ctx={ctx} row={row} />}
460 {row.suggestedGoals?.length
461 ? suggestedGoalBlock(ctx, row, extra)
462 : expansionActions(ctx, row, extra)}
463 </Box>
464 )
465}
466
467function suggestedGoalBlock(ctx: Ctx, row: ScreenRow, extra: ScreenAction[]): RenderElement {
468 const { Box, Text } = ctx.el
469 const inner = { ...ctx, width: Math.max(8, ctx.width - 6) }
470 return (
471 <Box key="suggested-goals" flexDirection="column" marginLeft={2}>
472 {guideRow(ctx, 'suggested-goals-heading', <Text dimColor>Suggested goals:</Text>)}
473 {(row.suggestedGoals ?? []).map(suggestion => (
474 <Box key={`suggested-${suggestion.key}`} flexDirection="column">
475 {guideRow(ctx, `suggested-guide-${suggestion.key}`, <Box marginLeft={2}>
476 {renderRow(inner, { ...suggestion, actions: [] })}
477 </Box>)}
478 {guideRow(ctx, `suggested-actions-${suggestion.key}`, <Box marginLeft={2}>
479 {actions(inner, suggestion.actions ?? [])}
480 </Box>)}
481 {guideRow(ctx, `suggested-spacer-guide-${suggestion.key}`,
482 <Box key={`suggested-spacer-${suggestion.key}`} height={1} />)}
483 </Box>
484 ))}
485 {guideRow(ctx, 'suggested-goals-use', <Box>{expansionActions(ctx, row, extra)}</Box>)}
486 </Box>
487 )
488}
489
490type DetailLine = { text: string; dim?: boolean }
491
492function eventDetailLines(row: ScreenRow): DetailLine[] {
493 return (row.detail?.length ? row.detail : ['No additional details recorded.']).flatMap((line, index) =>
494 line.split(/\r?\n/).map(text => ({ text, dim: index < 3 })),
495 )
496}
497
498function guideRow(ctx: Ctx, key: string, content: RenderElement): RenderElement {
499 const { Box, Text } = ctx.el
500 return (
501 <Box key={key} flexDirection="row" flexShrink={0}>
502 <Box width={2} flexShrink={0}><Text dimColor>│ </Text></Box>
503 <Box flexGrow={1} flexShrink={1} minWidth={0}>{content}</Box>
504 </Box>
505 )
506}
507
508function eventGeometry(ctx: Ctx, row: ScreenRow) {
509 const { Text } = ctx.el
510 const items = eventDetailLines(row).map((line, index) =>
511 guideRow(ctx, `expanded-line-${row.id}-${index}`, <Text dimColor={line.dim} wrap="wrap">
512 {['✻', '⌬', '□'].includes(line.text[0] ?? '') && line.text[1] === ' ' ? (
513 <Text color={Object.values(SOURCE_MARK).find(source => source.mark === line.text[0])?.color}>{line.text[0]}</Text>
514 ) : null}
515 {['✻', '⌬', '□'].includes(line.text[0] ?? '') && line.text[1] === ' ' ? line.text.slice(1) : line.text}
516 </Text>),
517 )
518 const itemRows = items.map(item => rowsOf(item, Math.max(8, ctx.width - 2)))
519 const total = itemRows.reduce((sum, rows) => sum + rows, 0)
520 const visible = Math.min(total, 6)
521 return { items, itemRows, total, visible, maxScroll: Math.max(0, total - visible) }
522}
523
524function EventDetail({ ctx, row }: { ctx: Ctx; row: ScreenRow }): RenderElement {
525 const { Box, Text, Button } = ctx.el
526 const geometry = eventGeometry(ctx, row)
527 const { visible, maxScroll } = geometry
528 const at = Math.min(Math.max(0, ctx.view.expandedScroll), maxScroll)
529 const window = rowWindow(geometry.items, geometry.itemRows, at, visible)
530 return (
531 <Box key={`expanded-detail-${row.id}`} flexDirection="column" marginLeft={2}>
532 <Box key={`expanded-body-${row.id}`} flexDirection="column" height={visible} overflow="hidden" flexShrink={0}>
533 <Box key={`expanded-window-${row.id}`} flexDirection="column" marginTop={-window.offset} flexShrink={0}>
534 {window.items}
535 </Box>
536 </Box>
537 {maxScroll > 0 ? (
538 <Box flexDirection="row" justifyContent="space-between">
539 <Button key="expanded-up" plain dimColor={at === 0} label="▲" onPress={() => ctx.act({ type: 'expanded-scroll', by: -1 })} />
540 <Text dimColor>{`${at + 1}/${geometry.total}`}</Text>
541 <Button key="expanded-down" plain dimColor={at >= maxScroll} label="▼" onPress={() => ctx.act({ type: 'expanded-scroll', by: 1 })} />
542 </Box>
543 ) : null}
544 </Box>
545 )
546}
547
548function Detail({ ctx, row }: { ctx: Ctx; row: ScreenRow }): RenderElement {
549 const { Box, Text } = ctx.el
550 return (
551 <Box flexDirection="column" marginLeft={2}>
552 {(row.detail?.length ? row.detail : ['No additional details recorded.']).map((line, index) =>
553 guideRow(ctx, `detail-${row.key}-${index}`, <Text dimColor wrap="wrap">{line}</Text>),
554 )}
555 </Box>
556 )
557}
558
559function sectionExpansionId(section: ScreenSection): string {
560 return `section:${section.key}`
561}
562
563function visibleHelpRows(ctx: Ctx, totalRows: number): number {
564 const room = ctx.bodyViewport ?? ctx.rows
565 const target = room >= 12 ? 5 : 3
566 return Math.min(totalRows, target)
567}
568
569function helpGeometry(ctx: Ctx, section: ScreenSection) {
570 const { Text } = ctx.el
571 const items = section.help.map((line, index) =>
572 guideRow(ctx, `help-line-${section.key}-${index}`, <Text dimColor wrap="wrap">{line}</Text>),
573 )
574 const itemRows = items.map(item => rowsOf(item, Math.max(8, ctx.width - 2)))
575 const total = itemRows.reduce((sum, rows) => sum + rows, 0)
576 const visible = visibleHelpRows(ctx, total)
577 return { items, itemRows, total, visible, maxScroll: Math.max(0, total - visible) }
578}
579
580function sectionHelp(ctx: Ctx, section: ScreenSection): RenderElement | null {
581 if (ctx.view.expanded !== sectionExpansionId(section)) return null
582 const { Box, Button, Text } = ctx.el
583 const geometry = helpGeometry(ctx, section)
584 const { visible, maxScroll: max } = geometry
585 const at = Math.min(Math.max(0, ctx.view.expandedScroll), max)
586 const window = rowWindow(geometry.items, geometry.itemRows, at, visible)
587 return (
588 <Box key={`help-${section.key}`} flexDirection="column" marginLeft={2}>
589 <Box key={`help-body-${section.key}`} flexDirection="column" height={visible} overflow="hidden" flexShrink={0}>
590 <Box key={`help-window-${section.key}`} flexDirection="column" marginTop={-window.offset} flexShrink={0}>
591 {window.items}
592 </Box>
593 </Box>
594 {max > 0 ? (
595 guideRow(ctx, `help-controls-${section.key}`, <Box flexDirection="row">
596 <Button
597 key={`help-up-${section.key}`}
598 plain
599 dimColor={at === 0}
600 label="▲"
601 onPress={() => ctx.act({ type: 'expanded-scroll', by: -1 })}
602 />
603 <Text dimColor>{` ${at + 1}/${geometry.total} `}</Text>
604 <Button
605 key={`help-down-${section.key}`}
606 plain
607 dimColor={at >= max}
608 label="▼"
609 onPress={() => ctx.act({ type: 'expanded-scroll', by: 1 })}
610 />
611 </Box>)
612 ) : null}
613 <Box key={`help-gap-${section.key}`} height={1} flexShrink={0}><Text>{''}</Text></Box>
614 </Box>
615 )
616}
617
618function renderSection(ctx: Ctx, section: ScreenSection): RenderElement {
619 const { Box, Text, Input } = ctx.el
620 const rows = section.rows.length
621 ? section.rows.map(row => renderRow(ctx, row))
622 : section.empty
623 ? [
624 <Text key={`${section.key}-empty`} dimColor wrap="wrap">
625 {section.empty}
626 </Text>,
627 ]
628 : []
629 const note = ctx.view.legend ? section.explain : undefined
630 const right = section.actions?.length ? (
631 actions(ctx, section.actions, { marginLeft: 0, gap: 0, flexWrap: 'nowrap' })
632 ) : section.right ? (
633 <Text dimColor wrap="truncate-end">
634 {section.right}
635 </Text>
636 ) : null
637 const showInput = Boolean(section.input && Input && (ctx.view.editingGoal || section.rows.length === 0))
638 return Section(
639 { el: ctx.el, width: ctx.width },
640 {
641 key: section.key,
642 heading: section.heading,
643 explain: note,
644 headingPress: () => ctx.act({ type: 'expand', id: sectionExpansionId(section) }),
645 expansion: sectionHelp(ctx, section) ?? undefined,
646 count: section.count,
647 right: right ?? undefined,
648 color: toneColor(section.tone),
649 dim: section.dim,
650 },
651 <Box flexDirection="column">
652 {section.dim ? (
653 <Text dimColor wrap="truncate-end">
654 {OBSERVER_NOTE}
655 </Text>
656 ) : null}
657 {rows}
658 {showInput && section.input && Input ? (
659 <Input
660 key={section.input.key}
661 label={section.input.label}
662 placeholder={section.input.placeholder}
663 submitLabel={section.input.submitLabel}
664 value={section.input.value}
665 onSubmit={(value: string) => ctx.act({ type: 'goal', text: value })}
666 />
667 ) : null}
668 </Box>,
669 )
670}
671
672function screenFor(snapshot: AtlasSnapshot, view: ScreenView, now: number): ScreenModel {
673 const builders: Record<AtlasTab, ScreenBuilder> = { map: buildMap, trail: buildTrail, open: buildOpen, evidence: buildEvidence }
674 return builders[view.tab](snapshot, view, now)
675}
676
677export const LEGEND: [GlyphKey | '!' | '·', string][] = [
678 ['goal', 'your goal (confirmed)'],
679 ['suggestion', 'suggestion, needs you'],
680 ['currentTopic', 'current topic'],
681 ['detour', 'detour / possible detour'],
682 ['returned', 'returned'],
683 ['observedDecision', 'decision, not settled'],
684 ['settledDecision', 'settled decision'],
685 ['!', 'major decision'],
686 ['·', 'minor decision'],
687 ['checkpoint', 'checkpoint / evidence'],
688 ['openQuestion', 'open question'],
689 ['ok', 'done'],
690 ['fail', 'failed'],
691 ['editedFile', 'file edited'],
692 ['readFile', 'file read'],
693 ['fresh', 'before an icon: just changed'],
694 ['resolved', 'resolved question'],
695 ['next', 'next step'],
696 ['resume', 'resume earlier session'],
697 ['handoff', 'hand-off running'],
698 ['reportBack', 'report back'],
699]
700export function legendPanel(ctx: Ctx, height?: number, at = 0): RenderElement {
701 const { Box, Text, Button } = ctx.el
702 const columns = ctx.width >= 64 ? 2 : 1
703 const width = Math.max(8, Math.floor((ctx.width - 4) / columns))
704 const legendRows: [GlyphKey | '!' | '·', string][][] = []
705 for (let index = 0; index < LEGEND.length; index += columns) legendRows.push(LEGEND.slice(index, index + columns))
706 const content = (
707 <Box key="legend-content" flexDirection="column" flexShrink={0}>
708 <Box flexShrink={0}><Text bold>LEGEND</Text></Box>
709 <Box flexShrink={0}><Text wrap="wrap">
710 Marks: <Text color={SOURCE_MARK.person.color}>›</Text> you ·{' '}
711 <Text color={SOURCE_MARK.claude.color}>✻</Text> Claude · <Text color={SOURCE_MARK.codex.color}>⌬</Text> Codex ·{' '}
712 <Text color={SOURCE_MARK.other.color}>□</Text> other
713 </Text></Box>
714 <Box flexShrink={0}><Button key="legend-howto" plain
715 label={`${ctx.view.legendHowTo ? '▾' : '▸'} How to use`}
716 onPress={() => ctx.act({ type: 'legend-howto' })} /></Box>
717 {ctx.view.legendHowTo ? <Box flexDirection="column" marginLeft={2} flexShrink={0}>
718 <Box flexShrink={0}><Text dimColor wrap="wrap">1. Each row is one thing Atlas saw: an icon, a title, counts and age.</Text></Box>
719 <Box flexShrink={0}><Text dimColor wrap="wrap">2. Press a row to open its details; press it again to close.</Text></Box>
720 <Box flexShrink={0}><Text dimColor wrap="wrap">3. [Bracketed] buttons do something when pressed.</Text></Box>
721 <Box flexShrink={0}><Text dimColor wrap="wrap">4. Nothing is confirmed until you press Confirm; observed items stay auto.</Text></Box>
722 <Box flexDirection="row" gap={1} flexShrink={0}>
723 <Text dimColor>Observer mode:</Text>
724 {actions(
725 ctx,
726 [
727 {
728 key: 'observer-toggle',
729 label: ctx.mode === 'claude' ? 'Observer: Claude' : 'Observer: Engine only',
730 action: { type: 'toggle-observer' },
731 },
732 ],
733 { marginLeft: 0, gap: 0 },
734 )}
735 </Box>
736 <Box flexShrink={0}><Text wrap="wrap">
737 counts: <Text color={C.write}>e</Text> edits <Text color={C.read}>r</Text> reads{' '}
738 <Text color={C.path}>t</Text> topics <Text color={C.decision}>d</Text> decisions{' '}
739 <Text color={C.question}>q</Text> questions <Text color={C.checkpoint}>h</Text> hand-offs{' '}
740 <Text color={C.checkpoint}>b</Text> report-backs
741 </Text></Box>
742 </Box> : null}
743 <Box flexShrink={0}><Text wrap="wrap">✦ suggested / goal detected</Text></Box>
744 {legendRows.map((line, index) => (
745 <Box key={`lg-${index}`} flexDirection="row" flexShrink={0}>
746 {line.map(([name, meaning]) => {
747 const g = name === '!' || name === '·' ? { char: name, color: name === '!' ? C.decision : undefined } : glyph(ctx, name)
748 return (
749 <Box key={`lg-${name}`} width={width} flexDirection="row" flexShrink={0}>
750 <Text color={g.color} dimColor={name === '·'} bold={name !== '·'}>{`${g.char} `}</Text>
751 <Text wrap="truncate-end">{meaning}</Text>
752 </Box>
753 )
754 })}
755 </Box>
756 ))}
757 <Box flexShrink={0}><Text dimColor wrap="wrap">{`pane: ${ctx.width + 2} columns`}</Text></Box>
758 </Box>
759 )
760 const frame = (
761 <Box
762 key="legend-panel"
763 flexDirection="column"
764 borderStyle="round"
765 borderColor={C.path}
766 paddingX={1}
767 flexShrink={0}
768 height={height ?? rowsOf(content, Math.max(8, ctx.width - 4)) + 2}
769 overflow="hidden"
770 >
771 {height === undefined ? content : <Box flexDirection="column" marginTop={-at} flexShrink={0}>{content}</Box>}
772 </Box>
773 )
774 return frame
775}
776function setupScreen(ctx: Ctx): RenderElement {
777 const { Box, Text } = ctx.el
778 return (
779 <Box key="setup-screen" flexDirection="column" marginTop={2}>
780 <Text bold color={C.goal}>
781 Set up Atlas
782 </Text>
783 <Text dimColor wrap="wrap">
784 Claude observer adds topics and path, possible detours, Claude-reported decisions and questions, a detected goal, and a suggested
785 next step.
786 </Text>
787 <Text dimColor wrap="wrap">
788 When something changes, it uses a small amount of Claude usage: one extra tool step plus a cached rules section.
789 </Text>
790 <Text dimColor wrap="wrap">
791 This choice applies in every project.
792 </Text>
793 <Text dimColor wrap="wrap">
794 Engine only is free and keeps activity, files, tests, commits, prompt trail, wording cues, recovery, chips and return packets.
795 </Text>
796 {ctx.setupDefault === 'engine' ? <Text dimColor>Default from /config: Engine only.</Text> : null}
797 <Box marginTop={1}>
798 {actions(
799 ctx,
800 [
801 { key: 'setup-claude', label: 'Use Claude observer', action: { type: 'set-observer', mode: 'claude' }, primary: true },
802 { key: 'setup-engine', label: 'Engine only (free)', action: { type: 'set-observer', mode: 'engine' } },
803 ],
804 { marginLeft: 0 },
805 )}
806 </Box>
807 </Box>
808 )
809}
810
811function popupWidth(ctx: Ctx, popup?: ScreenPopup): number {
812 const max = popup?.kind === 'trail-view' ? 64 : 52
813 return Math.max(8, Math.min(max, ctx.width - 4))
814}
815function popupMaxHeight(ctx: Ctx, popup?: ScreenPopup): number {
816 const available = Math.max(1, ctx.bodyViewport ?? ctx.rows)
817 const max = popup?.kind === 'trail-view' ? 18 : 10
818 const ratio = popup?.kind === 'trail-view' ? 0.6 : 0.4
819 return Math.max(1, Math.min(max, available, Math.floor(available * ratio)))
820}
821function popupContext(ctx: Ctx, popup?: ScreenPopup): Ctx {
822 return { ...ctx, width: Math.max(8, popupWidth(ctx, popup) - 4), inPopup: true }
823}
824function popupGeometry(ctx: Ctx, items: RenderElement[], popup?: ScreenPopup) {
825 const trailSettings = popup?.kind === 'trail-view'
826 const itemRows = items.flatMap((item, index) => [
827 ...(trailSettings && index > 0 && popup?.rows[index]?.checkbox === undefined ? [1] : []),
828 Math.max(1, rowsOf(item, popupContext(ctx, popup).width)),
829 ])
830 const total = itemRows.reduce((sum, value) => sum + value, 0)
831 const maxHeight = popupMaxHeight(ctx, popup)
832 const base = trailSettings ? 5 : 4
833 const fitHeight = Math.max(5, total + base)
834 const height = Math.min(maxHeight, fitHeight)
835 const bodyRows = Math.max(1, height - base - (trailSettings && total + base > maxHeight ? 1 : 0))
836 return { height, bodyRows, maxScroll: Math.max(0, total - bodyRows), itemRows, total }
837}
838function rowWindow(items: RenderElement[], itemRows: number[], at: number, bodyRows: number) {
839 let start = 0
840 let skipped = 0
841 while (start < items.length && skipped + (itemRows[start] ?? 1) <= at) {
842 skipped += itemRows[start] ?? 1
843 start++
844 }
845 let end = start
846 let used = skipped
847 while (end < items.length && used < at + bodyRows) {
848 used += itemRows[end] ?? 1
849 end++
850 }
851 return { items: items.slice(start, end), start, offset: at - skipped }
852}
853export function popupShell(ctx: Ctx, popup: ScreenPopup, placement: { top?: number; bottom?: number; left?: number }): RenderElement {
854 const { Box, Button, Text } = ctx.el
855 const pctx = popupContext(ctx, popup)
856 const items = popup.rows.map(row => renderRow(pctx, row))
857 const geometry = popupGeometry(ctx, items, popup)
858 const at = Math.min(Math.max(0, ctx.view.popupScroll), geometry.maxScroll)
859 const { left: requestedLeft = 0, ...placementProps } = placement
860 const left = Math.max(0, Math.min(requestedLeft, ctx.width - popupWidth(ctx, popup)))
861 const trailSettings = popup.kind === 'trail-view'
862 const visibleItems = trailSettings
863 ? items.flatMap((item, index) => index > 0 && popup.rows[index]?.checkbox === undefined
864 ? [<Box key={`trail-settings-gap-${index}`} height={1} overflow="hidden" flexShrink={0}><Text>{''}</Text></Box>, item]
865 : [item])
866 : items
867 const window = rowWindow(visibleItems, geometry.itemRows, at, geometry.bodyRows)
868 const position = popup.rows.length ? Math.min(popup.rows.length, window.start + 1) : 0
869 const scrollControls = geometry.maxScroll > 0
870 const closeAction = {
871 key: 'popup-close',
872 label: popup.closeGlyph ? '✕' : 'Close',
873 role: 'dismiss' as const,
874 action: { type: 'popup' as const, popup: { kind: popup.kind, ...(popup.id ? { id: popup.id } : {}) } },
875 }
876 const header = (
877 <Box
878 flexDirection="row"
879 justifyContent={trailSettings ? 'flex-start' : 'space-between'}
880 flexShrink={0}
881 backgroundColor={POPUP_BG}
882 >
883 <Box flexDirection="row" gap={1}>
884 <Text bold>{popup.title}</Text>
885 {popup.titleCount ? <Text dimColor>{popup.titleCount}</Text> : null}
886 </Box>
887 {trailSettings && popup.page ? (
888 <Box flexDirection="row" gap={1}>
889 <Button
890 key="settings-page-prev"
891 plain
892 dimColor={popup.page.current <= 0}
893 label="‹"
894 onPress={() => ctx.act({ type: 'trail-settings-page', page: popup.page!.current - 1 })}
895 />
896 <Text dimColor>{`${popup.page.current + 1}/${popup.page.total}`}</Text>
897 <Button
898 key="settings-page-next"
899 plain
900 dimColor={popup.page.current >= popup.page.total - 1}
901 label="›"
902 onPress={() => ctx.act({ type: 'trail-settings-page', page: popup.page!.current + 1 })}
903 />
904 </Box>
905 ) : null}
906 {!trailSettings && popup.page ? (
907 <Box flexDirection="row" gap={1}>
908 <Button
909 key="settings-page-prev"
910 plain
911 dimColor={popup.page.current <= 0}
912 label="‹"
913 onPress={() => ctx.act({ type: 'trail-settings-page', page: popup.page!.current - 1 })}
914 />
915 <Text dimColor>{`${popup.page.current + 1}/${popup.page.total}`}</Text>
916 <Button
917 key="settings-page-next"
918 plain
919 dimColor={popup.page.current >= popup.page.total - 1}
920 label="›"
921 onPress={() => ctx.act({ type: 'trail-settings-page', page: popup.page!.current + 1 })}
922 />
923 </Box>
924 ) : null}
925 {trailSettings ? <Box flexGrow={1} /> : null}
926 {trailSettings ? (
927 <Button key={closeAction.key} plain label={closeAction.label} onPress={() => ctx.act(closeAction.action)} />
928 ) : actions(ctx, [closeAction], { marginLeft: 0, gap: 0, flexWrap: 'nowrap' })}
929 </Box>
930 )
931 const pager = !trailSettings && popup.page ? (
932 <Box flexDirection="row" gap={1} flexShrink={0} backgroundColor={POPUP_BG}>
933 <Button
934 key="settings-page-prev"
935 plain
936 dimColor={popup.page.current <= 0}
937 label="‹"
938 onPress={() => ctx.act({ type: 'trail-settings-page', page: popup.page!.current - 1 })}
939 />
940 <Text dimColor>{`${popup.page.current + 1}/${popup.page.total}`}</Text>
941 <Button
942 key="settings-page-next"
943 plain
944 dimColor={popup.page.current >= popup.page.total - 1}
945 label="›"
946 onPress={() => ctx.act({ type: 'trail-settings-page', page: popup.page!.current + 1 })}
947 />
948 <Text dimColor>{popup.page.name}</Text>
949 </Box>
950 ) : null
951 const popupChildren: RenderElement[] = [header]
952 if (trailSettings && popup.page) {
953 popupChildren.push(
954 <Text key="trail-settings-page-name" bold>{`» ${popup.page.name}`}</Text>,
955 <Box key="trail-settings-header-gap" height={1} overflow="hidden" flexShrink={0}>
956 <Text>{''}</Text>
957 </Box>,
958 )
959 }
960 popupChildren.push(ScrollBox(ctx, [
961 <Box key="popup-window" flexDirection="column" marginTop={-window.offset} flexShrink={0}>
962 {window.items}
963 </Box>,
964 ], { height: geometry.bodyRows, backgroundColor: POPUP_BG }))
965 if (scrollControls || !trailSettings) {
966 popupChildren.push(
967 <Box
968 key="popup-footer"
969 flexDirection="row"
970 justifyContent="space-between"
971 flexShrink={0}
972 backgroundColor={POPUP_BG}
973 >
974 {popup.footerActions?.length ? actions(ctx, popup.footerActions, { marginLeft: 0 }) : <Text dimColor>{popup.footer ?? ''}</Text>}
975 {scrollControls ? <Box flexDirection="row" gap={1}>
976 <Button key="popup-up" plain dimColor={at === 0} label="▲" onPress={() => ctx.act({ type: 'popup-scroll', by: -1 })} />
977 <Text dimColor>{`${position}/${popup.rows.length}`}</Text>
978 <Button
979 key="popup-down"
980 plain
981 dimColor={at >= geometry.maxScroll}
982 label="▼"
983 onPress={() => ctx.act({ type: 'popup-scroll', by: 1 })}
984 />
985 </Box> : null}
986 </Box>,
987 )
988 }
989 return Popup(
990 ctx,
991 popupChildren,
992 { width: popupWidth(ctx, popup), height: geometry.height, backgroundColor: POPUP_BG, borderColor: C.path, left, ...placementProps },
993 )
994}
995
996function popupPlacement(ctx: Ctx, popup: ScreenPopup, rowTop: number): { top: number; left: number } {
997 const items = popup.rows.map(row => renderRow(popupContext(ctx, popup), row))
998 const height = popupGeometry(ctx, items, popup).height
999 const bodyRows = Math.max(1, ctx.bodyViewport ?? ctx.rows)
1000 const below = rowTop + 1
1001 const top = below + height <= bodyRows ? below : Math.max(0, rowTop - height - 1)
1002 return { top: Math.max(0, Math.min(top, Math.max(0, bodyRows - height))), left: 2 }
1003}
1004
1005function itemPopup(row: ScreenRow): ScreenPopup {
1006 return {
1007 kind: 'item',
1008 id: row.id,
1009 title: row.kind === 'item' ? 'ITEM' : row.kind.toUpperCase(),
1010 rows: [
1011 { id: `${row.id}-summary`, key: `${row.key}-summary`, kind: 'text', text: row.text, bold: true },
1012 ...(row.detail ?? []).map((text, index) => ({
1013 id: `${row.id}-detail-${index}`,
1014 key: `${row.key}-detail-${index}`,
1015 kind: 'text' as const,
1016 text,
1017 dim: true,
1018 })),
1019 ],
1020 footerActions: [
1021 {
1022 key: `add-${row.key}`,
1023 label: 'Chat ⇒',
1024 action: { type: 'attach', ref: { kind: row.kind, id: row.id, text: row.text } },
1025 },
1026 { key: `close-${row.key}`, label: 'Close', action: { type: 'popup', popup: { kind: 'item', id: row.id } } },
1027 ],
1028 }
1029}
1030function activePopup(ctx: Ctx, model: ScreenModel): ScreenPopup | undefined {
1031 if (ctx.view.popup?.kind === 'trail-view') return trailViewPopup(ctx.view.trailView, ctx.view.trailNewest, ctx.view.hidden ?? [], ctx.view.settingsPage ?? 0)
1032 if (ctx.view.popup?.kind === 'item') {
1033 const row = model.sections.flatMap(section => section.rows).find(candidate => candidate.id === ctx.view.popup?.id)
1034 return row ? itemPopup(row) : undefined
1035 }
1036 return undefined
1037}
1038function renderBody(ctx: Ctx, model: ScreenModel): RenderElement {
1039 const { Box } = ctx.el
1040 let sectionTop = 0
1041 return (
1042 <Box flexDirection="column" position="relative">
1043 {model.sections.map(section => {
1044 const selectedIndex = section.rows.findIndex(row => row.overflowPopup && ctx.view.popup?.kind === 'item' && ctx.view.popup.id === row.id)
1045 const sectionTree = renderSection(ctx, section)
1046 const prefix = selectedIndex >= 0
1047 ? renderSection(ctx, { ...section, rows: section.rows.slice(0, selectedIndex), empty: undefined, input: undefined })
1048 : null
1049 const sectionPopup = section.key === 'events' && ctx.view.popup?.kind === 'trail-view'
1050 const popup = selectedIndex >= 0 || sectionPopup ? activePopup(ctx, model) : undefined
1051 const rowTop = sectionPopup ? sectionTop : sectionTop + (prefix ? rowsOf(prefix, ctx.width) : 0)
1052 sectionTop += rowsOf(sectionTree, ctx.width)
1053 const placement = popup ? popupPlacement(ctx, popup, rowTop) : undefined
1054 return (
1055 <Box key={`screen-${section.key}`} flexDirection="column">
1056 {sectionTree}
1057 {popup && placement ? popupShell(ctx, popup, placement) : null}
1058 </Box>
1059 )
1060 })}
1061 </Box>
1062 )
1063}
1064
1065function tabItems(ctx: Ctx, snapshot: AtlasSnapshot): BarItem[] {
1066 const pending =
1067 snapshot.suggestions.length + snapshot.decisions.filter(item => item.status === 'observed').length + openQuestions(snapshot).length
1068 return [
1069 {
1070 key: 'tab-map',
1071 label: 'Map',
1072 short: 'Map',
1073 compact: 'M',
1074 hotkey: 'm',
1075 active: ctx.view.tab === 'map',
1076 activeColor: TAB_ACCENT.map,
1077 onPress: () => ctx.act({ type: 'tab', tab: 'map' }),
1078 },
1079 {
1080 key: 'tab-trail',
1081 label: 'Trail',
1082 short: 'Trail',
1083 compact: 'T',
1084 hotkey: 't',
1085 active: ctx.view.tab === 'trail',
1086 activeColor: TAB_ACCENT.trail,
1087 onPress: () => ctx.act({ type: 'tab', tab: 'trail' }),
1088 },
1089 {
1090 key: 'tab-open',
1091 label: pending ? `Open ${pending}` : 'Open',
1092 short: pending ? `Open ${pending}` : 'Open',
1093 compact: 'O',
1094 hotkey: 'o',
1095 active: ctx.view.tab === 'open',
1096 activeColor: TAB_ACCENT.open,
1097 onPress: () => ctx.act({ type: 'tab', tab: 'open' }),
1098 },
1099 {
1100 key: 'tab-evidence',
1101 label: 'Evidence',
1102 short: 'Evid',
1103 compact: 'E',
1104 hotkey: 'e',
1105 active: ctx.view.tab === 'evidence',
1106 activeColor: TAB_ACCENT.evidence,
1107 onPress: () => ctx.act({ type: 'tab', tab: 'evidence' }),
1108 },
1109 ]
1110}
1111function tabBar(ctx: Ctx, snapshot: AtlasSnapshot, scroll: { max: number; at: number; page: number }): RenderElement {
1112 const { Box, Button } = ctx.el
1113 const width = Math.max(1, ctx.width - (scroll.max > 0 ? 5 : 0))
1114 return (
1115 <Box flexDirection="row" gap={1} flexShrink={0} overflow="hidden">
1116 {Tabs({ el: ctx.el, width }, tabItems(ctx, snapshot))}
1117 {scroll.max > 0 ? (
1118 <Box flexDirection="row" gap={1} flexShrink={0}>
1119 <Button
1120 key="scroll-up"
1121 plain
1122 dimColor={scroll.at === 0}
1123 label="▲"
1124 onPress={() => ctx.act({ type: 'scroll', by: -scroll.page })}
1125 />
1126 <Button
1127 key="scroll-down"
1128 plain
1129 dimColor={scroll.at >= scroll.max}
1130 label="▼"
1131 onPress={() => ctx.act({ type: 'scroll', by: scroll.page })}
1132 />
1133 </Box>
1134 ) : null}
1135 </Box>
1136 )
1137}
1138function tabBarRows(ctx: Ctx, snapshot: AtlasSnapshot, max: number): number {
1139 return (
1140 collapseBarLabels(tabItems(ctx, snapshot), Math.max(1, ctx.width - (max > 0 ? 5 : 0)), 1, 'tabs').grid.rows.length +
1141 (ctx.view.legend ? 1 : 0)
1142 )
1143}
1144function appBarItems(ctx: Ctx, snapshot: AtlasSnapshot): BarItem[] {
1145 return [
1146 { key: 'legend', label: 'Legend', short: 'Legend', compact: '≡', hotkey: 'l', onPress: () => ctx.act({ type: 'legend' }) },
1147 { key: 'mark', label: '+ Mark', short: '+ Mark', compact: '+', hotkey: 'k', onPress: () => ctx.act({ type: 'mark' }) },
1148 ]
1149}
1150function appBar(ctx: Ctx, snapshot: AtlasSnapshot): RenderElement {
1151 const { Box } = ctx.el
1152 const items = appBarItems(ctx, snapshot)
1153 return (
1154 <Box key="app-bar" position="relative" flexDirection="column" flexShrink={0}>
1155 {Bar({ el: ctx.el, width: ctx.width }, items.map(item => ({ ...item, active: item.key === 'legend' ? ctx.view.legend : false })))}
1156 </Box>
1157 )
1158}
1159function appBarRows(ctx: Ctx, snapshot: AtlasSnapshot): number {
1160 return collapseBarLabels(appBarItems(ctx, snapshot), ctx.width, 1, 'bar').grid.rows.length
1161}
1162function scrollbarCells(ctx: Ctx, viewport: number, content: number, at: number, maxScroll: number): RenderElement {
1163 const { Box, Button } = ctx.el
1164 const size = Math.min(viewport, Math.max(1, Math.round((viewport * viewport) / content)))
1165 const top = maxScroll ? Math.round((at / maxScroll) * (viewport - size)) : 0
1166 return (
1167 <Box flexDirection="column" width={1} flexShrink={0} height={viewport} overflow="hidden">
1168 {Array.from({ length: viewport }, (_, index) => (
1169 <Button
1170 key={`sb-${index}`}
1171 plain
1172 dimColor={!(index >= top && index < top + size)}
1173 label={index >= top && index < top + size ? '┃' : '│'}
1174 onPress={() => ctx.act({ type: 'scroll-to', at: Math.round((index / Math.max(1, viewport - 1)) * maxScroll) })}
1175 />
1176 ))}
1177 </Box>
1178 )
1179}
1180function scanBanner(ctx: Ctx): RenderElement | null {
1181 const visible = ctx.scan.active || (Boolean(ctx.scan.result) && ctx.now - ctx.scan.resultAt < SCAN_RESULT_MS)
1182 if (!visible) return null
1183 return ctx.live('scan', [{ segs: [{ t: ctx.scan.active ? 'Mapping earlier conversation…' : (ctx.scan.result ?? '') }] }], {
1184 ...ctx.scan,
1185 result: ctx.scan.active ? null : ctx.scan.result,
1186 now: ctx.now,
1187 })
1188}
1189
1190function expandedEvent(ctx: Ctx, model: ScreenModel): ScreenRow | undefined {
1191 if (!ctx.view.expanded) return undefined
1192 return model.sections.flatMap(section => section.rows).find(row => row.id === ctx.view.expanded && row.kind === 'event')
1193}
1194
1195export function pane(input: PaneCtx, snapshot: AtlasSnapshot): {
1196 tree: RenderElement
1197 maxScroll: number
1198 maxPopupScroll: number
1199 maxLegendScroll: number
1200 maxExpandedScroll: numberhooks/render-gui.tsx 1087 lines1// The pane shell and surface renderers. Tab content is pure data from
2// hooks/screens; this module decides only how that data is laid out.
3
4import type { Elements, RenderElement } from 'claude-code'
5import type { AtlasMode, AtlasScanState, AtlasSnapshot, AtlasTab, AtlasView } from '../types'
6import type { LiveRow } from './live'
7import { openQuestions } from './model'
8import {
9 cellWidth,
10 layoutRow as uiLayoutRow,
11 type BarItem,
12 type GlyphSet,
13 type UiContext,
14 type UiSection,
15} from './ui/shared'
16import { buildEvidence } from './screens/evidence'
17import { buildMap } from './screens/map'
18import { buildOpen } from './screens/open'
19import { buildTrail, SOURCE_MARK, trailViewPopup } from './screens/trail'
20import { ago } from './screens/shared'
21import type {
22 Action,
23 GlyphKey,
24 ScreenAction,
25 ScreenBuilder,
26 ScreenModel,
27 ScreenPopup,
28 ScreenRow,
29 ScreenSection,
30 ScreenView,
31} from './screens/types'
32
33export type { Action } from './screens/types'
34
35export const C = {
36 goal: '#7dcfff',
37 action: '#7dcfff',
38 path: '#bb9af7',
39 trail: '#b9f27c',
40 map: 'yellowBright',
41 detour: '#e0af68',
42 decision: '#9ece6a',
43 question: '#f7768e',
44 checkpoint: '#7aa2f7',
45 fail: '#f7768e',
46 ok: '#9ece6a',
47 read: '#bb9af7',
48 write: '#ff9e64',
49} as const
50
51type Glyph = { char: string; color?: string }
52export const GLYPH: Record<GlyphKey, Glyph> = {
53 goal: { char: '◎', color: C.goal },
54 suggestion: { char: '○' },
55 currentTopic: { char: '●', color: C.path },
56 detour: { char: '↳', color: C.detour },
57 returned: { char: '↩', color: C.detour },
58 observedDecision: { char: '◇', color: C.decision },
59 settledDecision: { char: '◆', color: C.decision },
60 checkpoint: { char: '⚑', color: C.checkpoint },
61 openQuestion: { char: '?', color: C.question },
62 resolved: { char: '✓', color: C.ok },
63 ok: { char: '✓', color: C.ok },
64 fail: { char: '✗', color: C.fail },
65 editedFile: { char: '✎', color: C.write },
66 readFile: { char: '·', color: C.read },
67 fresh: { char: '✦' },
68 expanded: { char: '▾', color: C.goal },
69 prompt: { char: '›' },
70 next: { char: '▸', color: C.goal },
71 resume: { char: '◎', color: C.goal },
72 handoff: { char: '→', color: C.checkpoint },
73 reportBack: { char: '←', color: C.ok },
74}
75const TAB_ACCENT: Record<AtlasTab, string> = { map: C.detour, trail: C.decision, open: C.question, evidence: C.checkpoint }
76export const TONES: Record<string, string[]> = {
77 violet: ['#6d5a9c', '#8f78c9', '#b9a3f0', '#efe6ff'],
78 orange: ['#9c5a2c', '#c9783e', '#f0a46e', '#fff0e0'],
79 amber: ['#8c6a2c', '#b8903e', '#e0b86e', '#fff4d6'],
80 green: ['#4f7a3a', '#6fa052', '#9ece6a', '#e8ffd6'],
81 blue: ['#3d5a9c', '#5a7ac9', '#8aa8f0', '#e0eaff'],
82}
83
84type GuiElements = Elements[Exclude<keyof Elements, 'terminal' | 'mobile' | 'vscode'>]
85type El = Pick<GuiElements, 'Box' | 'Text' | 'Button'> & {
86 Input?: GuiElements['Input']
87 Markdown?: GuiElements['Markdown']
88 Svg?: GuiElements['Svg']
89}
90export type Ctx = {
91 el: El
92 width: number
93 rows: number
94 bodyViewport?: number
95 now: number
96 mode: AtlasMode
97 setupDefault: AtlasMode
98 scan: AtlasScanState
99 view: AtlasView & { hidden?: string[]; settingsPage?: number }
100 live: (
101 key: string,
102 rows: LiveRow[],
103 scan?: { active: boolean; startedAt: number; result: string | null; resultAt: number; now: number },
104 ) => RenderElement
105 act: (action: Action) => void
106}
107type PaneCtx = Ctx
108
109// Owner decision "Option A": flip to true to restore expansion close buttons.
110const EXPANSION_CLOSE_BUTTON = false
111
112const OBSERVER_NOTE = 'Needs the Claude observer · /atlas observer claude'
113const SCAN_RESULT_MS = 4_000
114
115function childrenOf(node: { children?: unknown; props?: Record<string, unknown> } | null | undefined): unknown {
116 return node?.children ?? node?.props?.children
117}
118function kids(value: unknown): unknown[] {
119 if (value === null || value === undefined || value === false || value === true) return []
120 if (Array.isArray(value)) return value.flatMap(kids)
121 return [value]
122}
123function textOf(value: unknown): string {
124 return kids(value)
125 .map(item =>
126 typeof item === 'string' || typeof item === 'number'
127 ? String(item)
128 : textOf(childrenOf(item as { children?: unknown; props?: Record<string, unknown> })),
129 )
130 .join('')
131}
132
133export function rowsOf(value: unknown, width: number): number {
134 const list = kids(value)
135 if (list.length !== 1) return list.reduce((sum: number, item) => sum + rowsOf(item, width), 0)
136 const node = list[0]
137 if (typeof node === 'string' || typeof node === 'number') return 1
138 const element = node as { type?: string; props?: Record<string, unknown>; children?: unknown }
139 const props = element.props ?? {}
140 if (props.position === 'absolute') return 0
141 const extra =
142 (Number(props.marginTop) || 0) +
143 (Number(props.marginBottom) || 0) +
144 2 * (Number(props.marginY) || 0) +
145 2 * (Number(props.paddingY) || 0)
146 switch (element.type) {
147 case 'Text': {
148 const lines = textOf(childrenOf(element)).split('\n')
149 return (
150 (props.wrap === 'wrap'
151 ? lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / Math.max(8, width))), 0)
152 : Math.max(1, lines.length)) + extra
153 )
154 }
155 case 'Button':
156 case 'Input':
157 case 'Select':
158 return 1 + extra
159 case 'Client': {
160 const client = props.props as { rows?: unknown[]; scan?: { active?: boolean; result?: string | null } } | undefined
161 return Math.max(client?.scan?.active ? 2 : client?.scan?.result ? 1 : 0, client?.rows?.length ?? 1) + extra
162 }
163 case 'Box': {
164 if (props.display === 'none') return 0
165 if (props.overflow === 'hidden' && typeof props.height === 'number') return props.height + extra
166 const children = kids(childrenOf(element)).filter(
167 child => (child as { props?: Record<string, unknown> }).props?.position !== 'absolute',
168 )
169 const inner = Math.max(8, width - 2 * (Number(props.paddingX) || 0) - (Number(props.marginLeft) || 0))
170 if (props.flexDirection === 'row') {
171 if (props.flexWrap === 'wrap')
172 return (
173 Math.max(
174 1,
175 Math.ceil(
176 children.reduce(
177 (sum: number, child) =>
178 sum +
179 textOf(
180 (child as { props?: Record<string, unknown> }).props?.label ??
181 childrenOf(child as { props?: Record<string, unknown> }),
182 ).length +
183 5,
184 0,
185 ) / inner,
186 ),
187 ) + extra
188 )
189 const fixed = children.reduce((sum: number, child) => {
190 const childProps = (child as { props?: Record<string, unknown> }).props ?? {}
191 return sum + (typeof childProps.width === 'number' ? childProps.width : 0)
192 }, 0)
193 return Math.max(0, ...children.map(child => {
194 const childProps = (child as { props?: Record<string, unknown> }).props ?? {}
195 const available = typeof childProps.width === 'number'
196 ? childProps.width
197 : childProps.flexGrow && childProps.flexShrink ? inner - fixed : inner
198 return rowsOf(child, Math.max(8, available))
199 })) + extra
200 }
201 const gap = (Number(props.gap) || 0) * Math.max(0, children.length - 1)
202 return children.reduce((sum: number, child) => sum + rowsOf(child, inner), 0) + gap + extra
203 }
204 default:
205 return 1 + extra
206 }
207}
208
209function toneColor(tone: ScreenRow['tone'] | ScreenSection['tone']): string | undefined {
210 return tone ? C[tone] : undefined
211}
212function glyph(ctx: Ctx, name: GlyphKey): Glyph {
213 return { ...GLYPH[name], char: surfaceGlyphs[name] }
214}
215function actions(
216 ctx: Ctx,
217 items: ScreenAction[],
218 options: { marginLeft?: number; gap?: number; flexWrap?: 'wrap' | 'nowrap' } = {},
219): RenderElement | null {
220 if (!items.length) return null
221 return ActionGroup(
222 { el: ctx.el, width: ctx.width },
223 items.map(item => ({ key: item.key, label: item.label, primary: item.primary, role: item.role, onPress: () => ctx.act(item.action) })),
224 options,
225 )
226}
227
228function layoutRow(input: Parameters<typeof uiLayoutRow>[0], keepCounts = false): ReturnType<typeof uiLayoutRow> {
229 const guiInput = input.metaParts?.length
230 ? { ...input, metaParts: input.metaParts.map(part => ({ ...part, compact: undefined })) }
231 : input
232 const layout = uiLayoutRow(keepCounts ? { ...guiInput, text: '' } : guiInput)
233 const parts = layout.metaParts.map(part => part.text)
234 const meta = parts.length ? [...parts, ...(layout.right ? [layout.right] : [])].join(' ')
235 : [layout.meta, layout.right].filter(Boolean).join(' · ')
236 if (!meta) return layout
237 const available = Math.max(1, input.width - cellWidth(input.prefix) - cellWidth(meta) - 1)
238 const title = uiLayoutRow({ width: available, prefix: '', text: input.text, middle: input.middle })
239 return { ...layout, text: title.text }
240}
241
242const ROW_ICON_CELLS = 3
243const ROW_GAP_CELLS = 1
244const ROW_MIN_GUTTER_CELLS = 2
245// Desktop proportional fonts fit more average title characters than pane cells.
246const GUI_TITLE_CHARACTER_ALLOWANCE = 1.15
247
248// Reserve the close cell before allowing the left-hand actions to wrap.
249function expansionActions(ctx: Ctx, row: ScreenRow, items: ScreenAction[]): RenderElement | null {
250 const { Box, Button } = ctx.el
251 const close = items.find(item => item.action.type === 'expand' && item.action.id === row.id)
252 const left = items.filter(item => item !== close)
253 if (!EXPANSION_CLOSE_BUTTON) return actions(ctx, left)
254 return (
255 <Box key={`expansion-actions-${row.key}`} flexDirection="row" gap={1} marginLeft={2} flexShrink={0}>
256 <Box key={`expansion-actions-left-${row.key}`} flexGrow={1} flexShrink={1} minWidth={0}>
257 {actions({ ...ctx, width: Math.max(1, ctx.width - 4) }, left, { marginLeft: 0 })}
258 </Box>
259 <Box key={`expansion-close-cell-${row.key}`} width={1} flexShrink={0}>
260 <Button key={close?.key ?? `close-${row.key}`} plain label="✕"
261 onPress={() => ctx.act(close?.action ?? { type: 'expand', id: row.id })} />
262 </Box>
263 </Box>
264 )
265}
266
267function rowMetaLayout(ctx: Ctx, row: ScreenRow, indent: number) {
268 const metaParts = row.metaParts ?? []
269 let meta = row.meta
270 let parts = metaParts
271 const keepCounts = Boolean(metaParts.length)
272 let layout = layoutRow({ width: ctx.width, prefix: '', text: '', meta, metaParts: parts, right: row.right }, keepCounts)
273 const titleCells = (value: typeof layout) => Math.max(0,
274 ctx.width - indent - ROW_ICON_CELLS - ROW_GAP_CELLS * 2 - ROW_MIN_GUTTER_CELLS - (row.marker ? 2 : 0)
275 - [...value.metaParts.map(part => part.text), value.meta, value.right].filter(Boolean).reduce((sum, text, index) => sum + cellWidth(text!) + (index ? 1 : 0), 0))
276 // Free attribution first, then zero counts; preserve age after all other metadata.
277 if (titleCells(layout) < 24 && meta) {
278 meta = undefined
279 layout = layoutRow({ width: ctx.width, prefix: '', text: '', meta, metaParts: parts, right: row.right }, keepCounts)
280 }
281 if (titleCells(layout) < 24 && parts.length) {
282 parts = parts.filter(part => !/^0\p{L}$/u.test(part.text.trim()))
283 layout = layoutRow({ width: ctx.width, prefix: '', text: '', meta, metaParts: parts, right: row.right }, keepCounts)
284 }
285 // Text labels lack the native Button pill's left inset, so reserve its
286 // matching NBSP cell before truncating non-interactive titles.
287 const cells = Math.max(0, titleCells(layout) - (row.interactive ? 0 : 1))
288 const titleBudget = Math.max(1, Math.floor(cells * GUI_TITLE_CHARACTER_ALLOWANCE))
289 return { layout, titleBudget }
290}
291
292function truncateCells(value: string, budget: number, middle = false): string {
293 const width = Math.max(0, budget)
294 if (cellWidth(value) <= width) return value
295 if (width <= 1) return width === 0 ? '' : '…'
296 const room = width - 1
297 if (!middle) {
298 let out = ''
299 for (const char of value) {
300 if (cellWidth(out + char) > room) break
301 out += char
302 }
303 return `${out}…`
304 }
305 let left = ''
306 let right = ''
307 const leftRoom = Math.ceil(room / 2)
308 const rightRoom = Math.floor(room / 2)
309 for (const char of value) {
310 if (cellWidth(left + char) > leftRoom) break
311 left += char
312 }
313 for (const char of [...value].reverse()) {
314 if (cellWidth(char + right) > rightRoom) break
315 right = char + right
316 }
317 return `${left}…${right}`
318}
319
320function inlineMeta(ctx: Ctx, row: ScreenRow, layout: ReturnType<typeof layoutRow>): RenderElement | null {
321 const { Box, Text } = ctx.el
322 if (layout.metaParts.length) {
323 return (
324 <Box flexDirection="row" gap={1} flexShrink={0}>
325 {layout.metaParts.map(part => {
326 const source = row.metaParts?.[part.sourceIndex]
327 return (
328 <Text
329 key={`meta-${row.key}-${part.sourceIndex}`}
330 color={source?.tone ? toneColor(source.tone) : undefined}
331 dimColor={source?.dim}
332 >
333 {part.text}
334 </Text>
335 )
336 })}
337 {layout.right ? <Text dimColor>{layout.right}</Text> : null}
338 </Box>
339 )
340 }
341 if (!layout.meta && !layout.right) return null
342 return (
343 <Box flexDirection="row" gap={1} flexShrink={0}>
344 {layout.meta ? <Text dimColor wrap="truncate-end">{layout.meta}</Text> : null}
345 {layout.meta && layout.right ? <Text dimColor>·</Text> : null}
346 {layout.right ? <Text dimColor wrap="truncate-end">{layout.right}</Text> : null}
347 </Box>
348 )
349}
350
351function renderRow(ctx: Ctx, row: ScreenRow): RenderElement {
352 const content = renderRowContent(ctx, row)
353 if (!row.expandable || ctx.view.expanded !== row.id) return content
354 const { Box, Text } = ctx.el
355 return (
356 <Box key={`expansion-${row.key}`} flexDirection="column">
357 {content}
358 <Box key={`expansion-gap-${row.key}`} height={1} flexShrink={0}><Text>{''}</Text></Box>
359 </Box>
360 )
361}
362
363function renderRowContent(ctx: Ctx, row: ScreenRow): RenderElement {
364 const { Box, Text, Button } = ctx.el
365 if (row.setting) {
366 const state = row.meta ?? (row.checkbox === undefined ? '' : row.checkbox ? 'On' : 'Off')
367 const label = `${row.text}${state ? ` · ${state}` : ''}`
368 const action = row.actions?.[0]?.action
369 return <Button key={row.key} label={label} variant="secondary" onPress={() => action && ctx.act(action)} />
370 }
371 const rawGlyph = row.glyph ? glyph(ctx, row.glyph) : undefined
372 const g = rawGlyph ? { ...rawGlyph, char: rawGlyph.char.replace(/\uFE0F/g, '') } : undefined
373 const sourceMark = g ? undefined : row.sourceMark?.replace(/\uFE0F/g, '')
374 const indent = Math.max(0, row.depth ?? 0) * 2
375 const open = row.expandable && ctx.view.expanded === row.id
376 const headPress = row.interactive
377 ? () =>
378 ctx.act(
379 row.kind === 'event'
380 ? { type: 'expand', id: row.id }
381 : { type: 'expand', id: row.id },
382 )
383 : undefined
384 const icon = sourceMark ?? g?.char ?? (row.fresh ? GLYPH.fresh.char : open ? GLYPH.expanded.char : '')
385 const { layout: metaLayout, titleBudget } = rowMetaLayout(ctx, row, indent)
386 const rowTitle = truncateCells(row.text, titleBudget, row.kind === 'file')
387 const head = (
388 <Box key={`head-${row.key}`} flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0} marginLeft={indent}>
389 <Box width={3} flexShrink={0} alignItems="center" justifyContent="center">
390 <Text color={sourceMark ? row.sourceMarkColor : g?.color ?? (row.fresh ? GLYPH.fresh.color : open ? GLYPH.expanded.color : undefined)} bold={row.bold || open} dimColor={!icon}>{icon}</Text>
391 </Box>
392 {row.marker ? <Text color={row.marker === '!' ? C.decision : row.marker === '✦' ? C.goal : undefined} dimColor={row.marker === '·'} bold={row.marker !== '·'}>{row.marker}</Text> : null}
393 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
394 {headPress ? (
395 <Button
396 key={row.key}
397 plain
398 dimColor={row.dim}
399 label={rowTitle}
400 onPress={headPress}
401 />
402 ) : (
403 <Text bold={row.bold} dimColor={row.dim} italic={row.italic} wrap="truncate-end">
404 {'\u00a0'}{rowTitle}
405 </Text>
406 )}
407 </Box>
408 {inlineMeta(ctx, row, metaLayout)}
409 </Box>
410 )
411 if (row.kind === 'text')
412 return (
413 <Text key={row.key} bold={row.bold} dimColor={row.dim} wrap="wrap">
414 {row.text}
415 </Text>
416 )
417 if (row.kind === 'suggestion' && !row.expandable)
418 return (
419 <Box key={row.key} flexDirection="column" flexGrow={1} minWidth={0}>
420 {head}
421 {actions(ctx, row.actions ?? [])}
422 </Box>
423 )
424 if (row.kind === 'suggestion' || row.kind === 'recall') {
425 const extra = [
426 ...(row.actions ?? []),
427 { key: `close-${row.key}`, label: '✕', action: { type: 'expand', id: row.id } as const },
428 ]
429 return (
430 <Box key={row.key} flexDirection="column">
431 {head}
432 {open ? <Detail ctx={ctx} row={row} /> : null}
433 {open ? expansionActions(ctx, row, extra) : null}
434 </Box>
435 )
436 }
437 if (row.kind === 'activity') return <Box key={row.key} flexDirection="column" flexGrow={1} minWidth={0}>{head}</Box>
438 if (row.kind === 'detour')
439 return (
440 <Box key={row.key} flexDirection="column">
441 {head}
442 {open ? <Detail ctx={ctx} row={row} /> : null}
443 {open
444 ? expansionActions(ctx, row, [
445 ...(row.actions ?? []),
446 { key: `close-${row.key}`, label: '✕', action: { type: 'expand', id: row.id } },
447 ])
448 : null}
449 </Box>
450 )
451 if (row.kind === 'next')
452 return (
453 <Box key={row.key} flexDirection="column">
454 {head}
455 {open ? expansionActions(ctx, row, row.actions ?? []) : actions(ctx, row.actions ?? [])}
456 {open ? <Detail ctx={ctx} row={row} /> : null}
457 </Box>
458 )
459 if (!row.expandable)
460 return (
461 <Box key={row.key} flexDirection="column">
462 {head}
463 {actions(ctx, row.kind === 'event' ? [] : (row.actions ?? []))}
464 </Box>
465 )
466 if (!open)
467 return (
468 <Box key={row.key} flexDirection="column">
469 {head}
470 </Box>
471 )
472 const extra: ScreenAction[] =
473 row.kind === 'goal'
474 ? (row.actions ?? [])
475 : [
476 ...(row.actions ?? []),
477 {
478 key: `add-${row.key}`,
479 label: 'Chat ⇒',
480 action: { type: 'attach', ref: { kind: row.kind, id: row.id, text: row.fullText ?? row.text } },
481 },
482 { key: `close-${row.key}`, label: '✕', action: { type: 'expand', id: row.id } },
483 ]
484 return (
485 <Box key={row.key} flexDirection="column">
486 {head}
487 {row.suggestedGoals?.length ? null : row.kind === 'event' ? <EventDetail ctx={ctx} row={row} /> : <Detail ctx={ctx} row={row} />}
488 {row.suggestedGoals?.length
489 ? suggestedGoalBlock(ctx, row, extra)
490 : expansionActions(ctx, row, extra)}
491 </Box>
492 )
493}
494
495function suggestedGoalBlock(ctx: Ctx, row: ScreenRow, extra: ScreenAction[]): RenderElement {
496 const { Box, Text, Svg } = ctx.el
497 const inner = { ...ctx, width: Math.max(8, ctx.width - 6) }
498 const content = (
499 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
500 {(row.detail ?? []).map((line, index) => <Text key={`goal-detail-${index}`} dimColor wrap="wrap">{line}</Text>)}
501 <Text key="suggested-goals-heading" dimColor>Suggested goals:</Text>
502 <Box flexDirection="column" marginLeft={2}>
503 {(row.suggestedGoals ?? []).map(suggestion => (
504 <Box key={`suggested-${suggestion.key}`} flexDirection="column">
505 {renderRow(inner, suggestion)}
506 <Box key={`suggested-spacer-${suggestion.key}`} height={1} flexShrink={0} />
507 </Box>
508 ))}
509 </Box>
510 <Box key="suggested-goals-use">{expansionActions(inner, row, extra)}</Box>
511 </Box>
512 )
513 return (
514 <Box key="suggested-goals" flexDirection="row" marginLeft={2} flexShrink={0}>
515 <Box key="suggested-goals-guide" width={2} flexShrink={0}>
516 {Svg ? <Svg
517 source={'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 100" preserveAspectRatio="none">'
518 + '<path d="M0.5 0V100" stroke="#41414a" stroke-width="1" vector-effect="non-scaling-stroke"/></svg>'}
519 alt="Suggested goals guide"
520 width={1}
521 height={rowsOf(content, Math.max(8, ctx.width - 4)) * 16}
522 /> : null}
523 </Box>
524 {content}
525 </Box>
526 )
527}
528
529type DetailLine = { text: string; dim?: boolean }
530
531function eventDetailLines(row: ScreenRow): DetailLine[] {
532 return (row.detail?.length ? row.detail : ['No additional details recorded.']).flatMap((line, index) =>
533 line.split(/\r?\n/).map(text => ({ text, dim: index < 3 })),
534 )
535}
536
537function detailLayout(ctx: Ctx, prefix: string, lines: DetailLine[], renderLine?: (line: DetailLine) => RenderElement): RenderElement[] {
538 const { Text } = ctx.el
539 return lines.map((line, index) => guideRow(
540 ctx,
541 `${prefix}-${index}`,
542 renderLine?.(line) ?? <Text dimColor={line.dim} wrap="wrap">{line.text}</Text>,
543 ))
544}
545
546function guideRow(ctx: Ctx, key: string, content: RenderElement): RenderElement {
547 const { Box } = ctx.el
548 return (
549 <Box key={key} flexDirection="row" flexShrink={0} marginLeft={2}>
550 <Box flexGrow={1} flexShrink={1} minWidth={0}>{content}</Box>
551 </Box>
552 )
553}
554
555function eventGeometry(ctx: Ctx, row: ScreenRow) {
556 const { Text } = ctx.el
557 const marked = (line: DetailLine) => ['✻', '⌬', '□'].includes(line.text[0] ?? '') && line.text[1] === ' '
558 return detailLayout(ctx, `expanded-line-${row.id}`, eventDetailLines(row), line => <Text dimColor={line.dim} wrap="wrap">
559 {marked(line) ? <Text color={Object.values(SOURCE_MARK).find(source => source.mark === line.text[0])?.color}>{line.text[0]}</Text> : null}
560 {marked(line) ? line.text.slice(1) : line.text}
561 </Text>)
562}
563
564function EventDetail({ ctx, row }: { ctx: Ctx; row: ScreenRow }): RenderElement {
565 const { Box } = ctx.el
566 return (
567 <Box key={`expanded-detail-${row.id}`} flexDirection="column" marginLeft={2}>
568 {eventGeometry(ctx, row)}
569 </Box>
570 )
571}
572
573function Detail({ ctx, row }: { ctx: Ctx; row: ScreenRow }): RenderElement {
574 const { Box } = ctx.el
575 const lines = (row.detail?.length ? row.detail : ['No additional details recorded.']).flatMap(line => line.split(/\r?\n/).map(text => ({ text })))
576 return (
577 <Box flexDirection="column" marginLeft={2}>
578 {detailLayout(ctx, `detail-${row.key}`, lines)}
579 </Box>
580 )
581}
582
583function sectionExpansionId(section: ScreenSection): string {
584 return `section:${section.key}`
585}
586
587function helpGeometry(ctx: Ctx, section: ScreenSection) {
588 const { Text } = ctx.el
589 const items = section.help.map((line, index) =>
590 guideRow(ctx, `help-line-${section.key}-${index}`, <Text dimColor wrap="wrap">{line}</Text>),
591 )
592 return items
593}
594
595function sectionHelp(ctx: Ctx, section: ScreenSection): RenderElement | null {
596 if (ctx.view.expanded !== sectionExpansionId(section)) return null
597 const { Box, Text } = ctx.el
598 return (
599 <Box key={`help-${section.key}`} flexDirection="column" marginLeft={2}>
600 {helpGeometry(ctx, section)}
601 <Box key={`help-gap-${section.key}`} height={1} flexShrink={0}><Text>{''}</Text></Box>
602 </Box>
603 )
604}
605
606function renderSection(ctx: Ctx, section: ScreenSection): RenderElement {
607 const { Box, Text, Input } = ctx.el
608 const trailControls = section.key === 'events' ? trailToolbar(ctx) : null
609 const rows = section.rows.length
610 ? section.rows.map(row => renderRow(ctx, row))
611 : section.empty
612 ? [
613 <Text key={`${section.key}-empty`} dimColor wrap="wrap">
614 {section.empty}
615 </Text>,
616 ]
617 : []
618 const note = ctx.view.legend ? section.explain : undefined
619 const right = section.key === 'events' ? null : section.actions?.length ? (
620 actions(ctx, section.actions, { marginLeft: 0, gap: 0, flexWrap: 'nowrap' })
621 ) : section.right ? (
622 <Text dimColor wrap="truncate-end">
623 {section.right}
624 </Text>
625 ) : null
626 const showInput = Boolean(section.input && Input && (ctx.view.editingGoal || section.rows.length === 0))
627 return Section(
628 { el: ctx.el, width: ctx.width },
629 {
630 key: section.key,
631 heading: section.heading,
632 explain: note,
633 headingPress: () => ctx.act({ type: 'expand', id: sectionExpansionId(section) }),
634 expansion: sectionHelp(ctx, section) ?? undefined,
635 count: section.count,
636 right: right ?? undefined,
637 color: toneColor(section.tone),
638 dim: section.dim,
639 },
640 <Box flexDirection="column" flexGrow={1} minWidth={0}>
641 {section.dim ? (
642 <Text dimColor wrap="truncate-end">
643 {OBSERVER_NOTE}
644 </Text>
645 ) : null}
646 {trailControls}
647 {rows}
648 {showInput && section.input && Input ? (
649 <Input
650 key={section.input.key}
651 label={section.input.label}
652 placeholder={section.input.placeholder}
653 submitLabel={section.input.submitLabel}
654 value={section.input.value}
655 onSubmit={(value: string) => ctx.act({ type: 'goal', text: value })}
656 />
657 ) : null}
658 </Box>,
659 )
660}
661
662function screenFor(snapshot: AtlasSnapshot, view: ScreenView, now: number): ScreenModel {
663 const builders: Record<AtlasTab, ScreenBuilder> = { map: buildMap, trail: buildTrail, open: buildOpen, evidence: buildEvidence }
664 return builders[view.tab](snapshot, view, now)
665}
666
667export const LEGEND: [GlyphKey | '!' | '·', string][] = [
668 ['goal', 'your goal (confirmed)'],
669 ['suggestion', 'suggestion, needs you'],
670 ['currentTopic', 'current topic'],
671 ['detour', 'detour / possible detour'],
672 ['returned', 'returned'],
673 ['observedDecision', 'decision, not settled'],
674 ['settledDecision', 'settled decision'],
675 ['!', 'major decision'],
676 ['·', 'minor decision'],
677 ['checkpoint', 'checkpoint / evidence'],
678 ['openQuestion', 'open question'],
679 ['ok', 'done'],
680 ['fail', 'failed'],
681 ['editedFile', 'file edited'],
682 ['readFile', 'file read'],
683 ['fresh', 'before an icon: just changed'],
684 ['resolved', 'resolved question'],
685 ['next', 'next step'],
686 ['resume', 'resume earlier session'],
687 ['handoff', 'hand-off running'],
688 ['reportBack', 'report back'],
689]
690// Optional window arguments keep the shared dispatcher compatible; GUI always draws the full Legend.
691export function legendPanel(ctx: Ctx, _height?: number, _at?: number): RenderElement {
692 const { Box, Text, Button, Svg } = ctx.el
693 const content = (
694 <Box key="legend-content" flexDirection="column" flexShrink={0}>
695 {Svg ? <Svg
696 key="legend-separator"
697 source={'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 1" preserveAspectRatio="none">'
698 + '<path d="M0 0.5H100" stroke="#41414a" stroke-width="1"/></svg>'}
699 alt="Legend separator"
700 width={Math.max(1, ctx.width * 8)}
701 height={1}
702 /> : null}
703 <Box flexShrink={0}><Text bold>LEGEND</Text></Box>
704 <Box flexShrink={0}><Text wrap="wrap">
705 Marks: <Text color={SOURCE_MARK.person.color}>›</Text> you ·{' '}
706 <Text color={SOURCE_MARK.claude.color}>✻</Text> Claude · <Text color={SOURCE_MARK.codex.color}>⌬</Text> Codex ·{' '}
707 <Text color={SOURCE_MARK.other.color}>□</Text> other
708 </Text></Box>
709 <Box flexShrink={0}><Button key="legend-howto" plain
710 label={`${ctx.view.legendHowTo ? '▾' : '▸'} How to use`}
711 onPress={() => ctx.act({ type: 'legend-howto' })} /></Box>
712 {ctx.view.legendHowTo ? <Box flexDirection="column" marginLeft={2} flexShrink={0}>
713 <Box flexShrink={0}><Text dimColor wrap="wrap">1. Each row is one thing Atlas saw: an icon, a title, counts and age.</Text></Box>
714 <Box flexShrink={0}><Text dimColor wrap="wrap">2. Press a row to open its details; press it again to close.</Text></Box>
715 <Box flexShrink={0}><Text dimColor wrap="wrap">3. Buttons do something when pressed.</Text></Box>
716 <Box flexShrink={0}><Text dimColor wrap="wrap">4. Nothing is confirmed until you press Confirm; observed items stay auto.</Text></Box>
717 <Box flexDirection="row" gap={1} flexShrink={0}>
718 <Text dimColor>Observer mode:</Text>
719 {actions(
720 ctx,
721 [
722 {
723 key: 'observer-toggle',
724 label: ctx.mode === 'claude' ? 'Claude' : 'Engine only',
725 primary: ctx.mode === 'claude',
726 action: { type: 'toggle-observer' },
727 },
728 ],
729 { marginLeft: 0, gap: 0 },
730 )}
731 </Box>
732 <Box flexShrink={0}><Text wrap="wrap">
733 counts: <Text color={C.write}>e</Text> edits <Text color={C.read}>r</Text> reads{' '}
734 <Text color={C.path}>t</Text> topics <Text color={C.decision}>d</Text> decisions{' '}
735 <Text color={C.question}>q</Text> questions <Text color={C.checkpoint}>h</Text> hand-offs{' '}
736 <Text color={C.checkpoint}>b</Text> report-backs
737 </Text></Box>
738 </Box> : null}
739 <Box flexShrink={0}><Text wrap="wrap">✦ suggested / goal detected</Text></Box>
740 <Box flexDirection="row" flexGrow={1} flexShrink={1}>
741 {(ctx.width >= 64 ? [LEGEND.slice(0, Math.ceil(LEGEND.length / 2)), LEGEND.slice(Math.ceil(LEGEND.length / 2))] : [LEGEND]).map((column, columnIndex) => (
742 <Box key={`legend-columns-${columnIndex}`} flexDirection="column" flexGrow={1} flexShrink={1}>
743 {column.map(([name, meaning]) => {
744 const g = name === '!' || name === '·' ? { char: name, color: name === '!' ? C.decision : undefined } : glyph(ctx, name)
745 return (
746 <Box key={`lg-${name}`} flexDirection="row" gap={1} flexShrink={0}>
747 <Box width={3} flexShrink={0}><Text color={g.color} dimColor={name === '·'} bold={name !== '·'}>{g.char}</Text></Box>
748 <Text wrap="truncate-end">{meaning}</Text>
749 </Box>
750 )
751 })}
752 </Box>
753 ))}
754 </Box>
755 <Box flexShrink={0}><Text dimColor wrap="wrap">{`pane: ${ctx.width + 2} columns`}</Text></Box>
756 </Box>
757 )
758 const frame = (
759 <Box
760 key="legend-panel"
761 flexDirection="column"
762 flexShrink={0}
763 >
764 {content}
765 </Box>
766 )
767 return frame
768}
769function setupScreen(ctx: Ctx): RenderElement {
770 const { Box, Text } = ctx.el
771 return (
772 <Box key="setup-screen" flexDirection="column" marginTop={2}>
773 <Text bold color={C.goal}>
774 Set up Atlas
775 </Text>
776 <Text dimColor wrap="wrap">
777 Claude observer adds topics and path, possible detours, Claude-reported decisions and questions, a detected goal, and a suggested
778 next step.
779 </Text>
780 <Text dimColor wrap="wrap">
781 When something changes, it uses a small amount of Claude usage: one extra tool step plus a cached rules section.
782 </Text>
783 <Text dimColor wrap="wrap">
784 This choice applies in every project.
785 </Text>
786 <Text dimColor wrap="wrap">
787 Engine only is free and keeps activity, files, tests, commits, prompt trail, wording cues, recovery, chips and return packets.
788 </Text>
789 {ctx.setupDefault === 'engine' ? <Text dimColor>Default from /config: Engine only.</Text> : null}
790 <Box marginTop={1}>
791 {actions(
792 ctx,
793 [
794 { key: 'setup-claude', label: 'Use Claude observer', action: { type: 'set-observer', mode: 'claude' }, primary: true },
795 { key: 'setup-engine', label: 'Engine only (free)', action: { type: 'set-observer', mode: 'engine' } },
796 ],
797 { marginLeft: 0 },
798 )}
799 </Box>
800 </Box>
801 )
802}
803
804export function popupShell(ctx: Ctx, popup: ScreenPopup, _placement: { top?: number; bottom?: number; left?: number }): RenderElement {
805 const { Box } = ctx.el
806 return <Box flexDirection="column">{popup.rows.map(row => renderRow(ctx, row))}</Box>
807}
808
809function trailToolbar(ctx: Ctx): RenderElement {
810 const { Box, Button, Text } = ctx.el
811 const pages = [0, 1, 2].map(page => trailViewPopup(ctx.view.trailView, ctx.view.trailNewest, ctx.view.hidden ?? [], page))
812 const viewRows = pages[0]?.rows ?? []
813 const orderRow = pages[1]?.rows.find(row => row.actions?.some(item => item.key === 'trail-sort'))
814 const filterRows = pages[2]?.rows ?? []
815 const setting = (rows: ScreenRow[], key: string) => rows.find(row => row.actions?.some(item => item.key === key))
816 const story = setting(viewRows, 'trail-view-story')
817 const log = setting(viewRows, 'trail-view-log')
818 const reports = setting(filterRows, 'trail-filter-b-toggle')
819 const handoffs = setting(filterRows, 'trail-filter-h-toggle')
820 const button = (row: ScreenRow | undefined, key: string, label: string, primary = false, dimColor?: boolean) => {
821 const action = row?.actions?.[0]?.action
822 return <Button key={key} label={label} variant={primary ? 'primary' : 'secondary'} dimColor={dimColor ?? false} onPress={() => action && ctx.act(action)} />
823 }
824 const stateLabel = (row: ScreenRow | undefined) => `${row?.checkbox ? '○' : '✓'} ${row?.text ?? ''}`
825 const Select = (ctx.el as any).Select
826 const viewControl = Select
827 ? <Select key="trail-view-select" options={[{ value: 'story', label: 'Story' }, { value: 'log', label: 'Log' }]} value={ctx.view.trailView} onSelect={(value: string) => {
828 const row = value === 'log' ? log : story
829 const action = row?.actions?.[0]?.action
830 if (action) ctx.act(action)
831 }} />
832 : <Box flexDirection="row" gap={1}>
833 {button(story, 'trail-view-story', 'Story', story?.checkbox === true)}
834 {button(log, 'trail-view-log', 'Log', log?.checkbox === true)}
835 </Box>
836 return (
837 <Box key="trail-toolbar" flexDirection="row" flexWrap="wrap" gap={1} flexShrink={0}>
838 <Box flexDirection="row" flexWrap="wrap" gap={1}>
839 {viewControl}
840 {button(reports, 'trail-filter-b-toggle', stateLabel(reports), false, reports?.checkbox === true)}
841 {button(handoffs, 'trail-filter-h-toggle', stateLabel(handoffs), false, handoffs?.checkbox === true)}
842 </Box>
843 <Box flexGrow={1} />
844 {button(orderRow, 'trail-sort', orderRow?.meta === 'oldest first' ? 'Oldest ▲' : 'Newest ▼')}
845 </Box>
846 )
847}
848
849function renderBody(ctx: Ctx, model: ScreenModel): RenderElement {
850 const { Box, Button, Text } = ctx.el
851 const popup = ctx.view.popup
852 return (
853 <Box flexDirection="column">
854 {popup ? (
855 <Box key="terminal-popup-notice" flexDirection="row" flexWrap="wrap" gap={1} flexShrink={0}>
856 <Text dimColor>A panel from the terminal view is open</Text>
857 <Button
858 key="popup-close"
859 label="Close it"
860 onPress={() => ctx.act({ type: 'popup', popup: { kind: popup.kind, ...(popup.id ? { id: popup.id } : {}) } })}
861 />
862 </Box>
863 ) : null}
864 {model.sections.map(section => (
865 <Box key={`screen-${section.key}`} flexDirection="column" flexGrow={1} minWidth={0}>
866 {renderSection(ctx, section)}
867 </Box>
868 ))}
869 </Box>
870 )
871}
872function tabItems(ctx: Ctx, snapshot: AtlasSnapshot): BarItem[] {
873 const pending =
874 snapshot.suggestions.length + snapshot.decisions.filter(item => item.status === 'observed').length + openQuestions(snapshot).length
875 return [
876 {
877 key: 'tab-map',
878 label: 'Map',
879 short: 'Map',
880 compact: 'M',
881 hotkey: 'm',
882 active: ctx.view.tab === 'map',
883 activeColor: TAB_ACCENT.map,
884 onPress: () => ctx.act({ type: 'tab', tab: 'map' }),
885 },
886 {
887 key: 'tab-trail',
888 label: 'Trail',
889 short: 'Trail',
890 compact: 'T',
891 hotkey: 't',
892 active: ctx.view.tab === 'trail',
893 activeColor: TAB_ACCENT.trail,
894 onPress: () => ctx.act({ type: 'tab', tab: 'trail' }),
895 },
896 {
897 key: 'tab-open',
898 label: pending ? `Open ${pending}` : 'Open',
899 short: pending ? `Open ${pending}` : 'Open',
900 compact: 'O',
901 hotkey: 'o',
902 active: ctx.view.tab === 'open',
903 activeColor: TAB_ACCENT.open,
904 onPress: () => ctx.act({ type: 'tab', tab: 'open' }),
905 },
906 {
907 key: 'tab-evidence',
908 label: 'Evidence',
909 short: 'Evid',
910 compact: 'E',
911 hotkey: 'e',
912 active: ctx.view.tab === 'evidence',
913 activeColor: TAB_ACCENT.evidence,
914 onPress: () => ctx.act({ type: 'tab', tab: 'evidence' }),
915 },
916 ]
917}
918function tabBar(ctx: Ctx, snapshot: AtlasSnapshot): RenderElement {
919 const { Box, Button, Text, Svg } = ctx.el
920 const items = tabItems(ctx, snapshot)
921 const labels = items.map(item => (ctx.width < 40 ? item.short : undefined) ?? item.label)
922 const labelWidth = Math.max(...labels.map(label => label!.length))
923 return (
924 <Box key="tab-bar" flexDirection="column" flexShrink={0} minWidth={0}>
925 <Box flexDirection="row" gap={1} flexWrap="wrap" flexShrink={0}>
926 {items.map((item, index) => (
927 <Box key={`tab-${item.key}`} flexDirection="column" flexGrow={1} flexShrink={0} alignItems="center">
928 <Button key={item.key} label={`${'\u00a0'.repeat(Math.floor((labelWidth - labels[index]!.length) / 2))}${labels[index]!}${'\u00a0'.repeat(Math.ceil((labelWidth - labels[index]!.length) / 2))}`} hotkey={item.hotkey} variant={item.active ? 'primary' : 'secondary'} onPress={item.onPress} />
929 </Box>
930 ))}
931 <Box key="bar-legend" flexDirection="column" flexGrow={0} flexShrink={0}>
932 <Button
933 key="bar-legend"
934 label="Legend"
935 hotkey="l"
936 variant={ctx.view.legend ? 'primary' : 'secondary'}
937 onPress={() => ctx.act({ type: 'legend' })}
938 />
939 {ctx.view.legend ? <Text color={TAB_ACCENT[ctx.view.tab]} bold>━━━━━━</Text> : null}
940 </Box>
941 <Box key="bar-mark" flexDirection="column" flexGrow={0} flexShrink={0}>
942 <Button key="mark" label="+ Mark" hotkey="k" variant="secondary" onPress={() => ctx.act({ type: 'mark' })} />
943 </Box>
944 </Box>
945 {Svg ? <Svg
946 key="tab-baseline"
947 source={`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 8" preserveAspectRatio="none"><path d="M0 7H100" stroke="${TAB_ACCENT[ctx.view.tab]}" stroke-width="2"/></svg>`}
948 alt="Tab strip baseline"
949 width={Math.max(1, ctx.width * 8)}
950 height={8}
951 /> : <Text dimColor>{'_'.repeat(Math.max(1, ctx.width - 2))}</Text>}
952 </Box>
953 )
954}
955function scanBanner(ctx: Ctx): RenderElement | null {
956 const visible = ctx.scan.active || (Boolean(ctx.scan.result) && ctx.now - ctx.scan.resultAt < SCAN_RESULT_MS)
957 if (!visible) return null
958 return ctx.live('scan', [{ segs: [{ t: ctx.scan.active ? 'Mapping earlier conversation…' : (ctx.scan.result ?? '') }] }], {
959 ...ctx.scan,
960 result: ctx.scan.active ? null : ctx.scan.result,
961 now: ctx.now,
962 })
963}
964
965export function pane(input: PaneCtx, snapshot: AtlasSnapshot): {
966 tree: RenderElement
967 maxScroll: number
968 maxPopupScroll: number
969 maxLegendScroll: number
970 maxExpandedScroll: number
971} {
972 const ctx: Ctx = input
973 const { Box, Text } = ctx.el
974 if (ctx.view.setup)
975 return {
976 tree: (
977 <Box flexDirection="column" paddingX={1}>
978 {setupScreen(ctx)}
979 </Box>
980 ),
981 maxScroll: 0,
982 maxPopupScroll: 0,
983 maxExpandedScroll: 0,
984 maxLegendScroll: 0,
985 }
986 const model = screenFor(snapshot, { ...ctx.view, mode: ctx.mode }, ctx.now)
987 const scan = scanBanner(ctx)
988 const legend = ctx.view.legend ? legendPanel(ctx) : null
989 const body = renderBody(ctx, model)
990 const tree = (
991 <Box key="pane-root" flexDirection="column" minWidth={0} paddingX={1}>
992 {tabBar(ctx, snapshot)}
993 <Box height={1} flexShrink={0}>
994 <Text>{''}</Text>
995 </Box>
996 {legend}
997 <Box flexDirection="column">{body}</Box>
998 {scan}
999 </Box>
1000 )
1001 return { tree, maxScroll: 0, maxPopupScroll: 0, maxLegendScroll: 0, maxExpandedScroll: 0 }
1002}
1003
1004export function oneLine(snapshot: AtlasSnapshot): string {
1005 const current = snapshot.currentTopicId ? snapshot.topics.find(topic => topic.id === snapshot.currentTopicId) : undefined
1006 if (snapshot.detour) return `↳ ${snapshot.detour.reason}`
1007 if (current) return `● ${current.title}`
1008 if (snapshot.goal) return `◎ ${snapshot.goal.text}`
1009 return 'Atlas'
1010}
1011export { ago }
1012
1013
1014type ActionItem = { key: string; label: string; primary?: boolean; role?: 'dismiss'; onPress: () => void }
1015type ActionGroupOptions = { marginLeft?: number; gap?: number; flexWrap?: 'wrap' | 'nowrap' }
1016function Section(ctx: UiContext, section: UiSection, children: RenderElement): RenderElement {
1017 const { Box, Text, Button } = ctx.el
1018 return (
1019 <Box key={section.key} flexDirection="column" minWidth={0} marginTop={1}>
1020 <Box flexDirection="row" gap={1} flexShrink={0} minWidth={0}>
1021 <Box flexDirection="row" flexGrow={0} flexShrink={1} minWidth={0} overflow="hidden">
1022 <Text bold color={section.color} dimColor={section.dim} wrap="truncate-end">
1023 {section.heading}
1024 </Text>
1025 </Box>
1026 {section.headingPress ? (
1027 <Button
1028 key={`${section.key}-heading`}
1029 variant="secondary"
1030 dimColor={section.dim}
1031 label="?"
1032 onPress={section.headingPress}
1033 />
1034 ) : null}
1035 <Box flexGrow={1} flexShrink={1} minWidth={0} />
1036 {section.count ? (
1037 <Box flexShrink={0}><Text dimColor>{section.count}</Text></Box>
1038 ) : null}
1039 {section.right ? (
1040 <Box flexDirection="row" gap={1} flexShrink={0}>
1041 {typeof section.right === 'string' ? <Text dimColor wrap="truncate-end">{section.right}</Text> : section.right}
1042 </Box>
1043 ) : null}
1044 </Box>
1045 {section.explain ? (
1046 <Text dimColor italic wrap="wrap">
1047 {section.explain}
1048 </Text>
1049 ) : null}
1050 {section.expansion}
1051 {children}
1052 </Box>
1053 )
1054}
1055
1056const surfaceGlyphs: GlyphSet = {
1057 goal: '◎', suggestion: '○', currentTopic: '●', detour: '↳', returned: '↩',
1058 observedDecision: '◇', settledDecision: '◆', checkpoint: '⚑', openQuestion: '?', resolved: '✓',
1059 ok: '✓', fail: '×', editedFile: '✎', readFile: '·', fresh: '✦', expanded: '▾', prompt: '•',
1060 next: '▸', resume: '◎', handoff: '→', reportBack: '←',
1061}
1062
1063function ActionGroup(
1064 ctx: UiContext,
1065 items: ActionItem[],
1066 options: ActionGroupOptions = {},
1067): RenderElement {
1068 const { Box, Button } = ctx.el
1069 const marginLeft = options.marginLeft ?? 2
1070 const gap = options.gap ?? 1
1071 const flexWrap = options.flexWrap ?? 'wrap'
1072 return (
1073 <Box flexDirection="row" gap={gap} marginLeft={marginLeft} flexWrap={flexWrap}>
1074 {items.map(item => (
1075 <Button
1076 key={item.key}
1077 label={item.label}
1078 variant={item.primary ? 'primary' : 'secondary'}
1079 {...(item.role ? { role: item.role } : {})}
1080 onPress={() => item.onPress()}
1081 />
1082 ))}
1083 </Box>
1084 )
1085}
1086
1087