SLOPSHOPPER

conversation-atlas

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…

newpanespinnerguardcommandtoast
★ 4v0.1.0MITupdated 2026-10-09NeelAPatel/Claude-Mod-ConversationAtlas
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · conversation-atlas
│ ┃ Atlas ✕ › fix the failing auth t╭──────────────────────────────────────────╮ │ ┃ ───────────────── Conversation Atlas ────── │ conversation-atlas │ │ ┃ ⏺ Read(src/auth.ts) │ Atlas ready: /atlas or the footer button │ │ ┃ ⎿ Read 6 lines ╰──────────────────────────────────────────╯ │ ┃ Set up Atlas ⏺ Update(src/auth.ts) │ ┃ Claude observer adds topics and path, ⎿ Added 2 lines, removed 1 line │ ┃ possible detours, Claude-reported ⏺ Bash(bun test) │ ┃ decisions and questions, a detected goal, ⎿ 3 pass, 1 fail │ ┃ and a suggested next step. │ ┃ When something changes, it uses a small ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ amount of Claude usage: one extra tool │ ┃ step plus a cached rules section. ✻ Worked for 42s · done 4:20 PM │ ┃ This choice applies in every project. │ ┃ Engine only is free and keeps activity, › /atlas │ ┃ files, tests, commits, prompt trail, ⎿ conversation-atlas: Atlas opened. │ ┃ wording cues, recovery, chips and return │ ┃ packets. │ ┃ │ ┃ [ Use Claude observer ] [Engine only (free) │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts [ Atlas · 1 to review ] ⟨Claude Code's own drawing⟩

Draws

Pane · Atlas
───────────────── Conversation Atlas ───────────────── Set up Atlas Claude observer adds topics and path, possible detours, Claude-reported decisions and questions, a detected goal, and a suggested next step. When something changes, it uses a small amount of Claude usage: one extra tool step plus a cached rules section. This choice applies in every project. Engine only is free and keeps activity, files, tests, commits, prompt trail, wording cues, recovery, chips and return packets. [ Use Claude observer ] [Engine only (free)]
README

🗺️ Conversation Atlas

A live map of your Claude Code session: where you started, where you are, and how to get back.

License: MIT Version Claude Code Telemetry Status

<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:

  • 🎯 your goal, and the path of topics that led here
  • ↳ detours you took, with a snapshot of where you left off
  • ◆ decisions and open questions you would otherwise lose
  • 📍 checkpoints (commits, passing tests, your own marks) and the files in play
  • ▸ what to resume next

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 /atlas command.

Installation

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.

Feature tour

1. Set up

On first launch the pane asks how Atlas should observe. Nothing costs Claude usage until you choose.

  • Claude observer (minimal usage cost, read below): Claude reports topic shifts, decisions and questions through a tiny tool.
  • Engine only (free, no usage cost): Atlas uses just files, activity, tests, commits and your own wording.

Change it any time with /atlas observer [claude|engine].

2. Goal, detour, return

  • /atlas goal ship the login refactor sets the goal. Atlas also shows the goal it detected; Use this as my goal makes it yours.
  • Goal suggestions and observed alternatives carry auto: Atlas's guess, not yet yours. ✦ after a goal icon marks what this turn is about. It stays on your confirmed goal for one diverging turn and moves to an alternative after two consecutive observed turns on that topic; a matching topic returns it to your goal. Switch to this confirms an alternative. Dismiss hides it for this session view, including after observation or goal changes. Observation never changes your goal or adds a focus event to Trail. ✦ before an icon still means just changed.
  • /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.

3. The pane

TabKeyWhat it shows
MapmGoal, current topic path, detours, live activity, working set, resume next
TrailtTopic tree and a timeline of prompts, shifts, decisions, detours, checkpoints
OpenoEverything waiting on you: suggestions, decisions to settle, open questions
EvidenceeCheckpoints, 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.

4. Recovering and scanning earlier conversations

Atlas can pick up a chat it wasn't watching, or one you left yesterday.

SituationWhat happensCost
Atlas installed or reloaded mid-chat, or you resume an old chatAtlas replays the earlier messages: your prompts, files read and edited, test and commit checkpoints, a goal suggestion, and a question left openFree
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 tabOne mostly cached request
A new session in the same projectAtlas offers to Resume the last goalFree
Older sessions/atlas recover lists them; /atlas recover 2 resumes number 2. Evidence → Earlier sessions does the same with buttonsFree

Two ways to resume an earlier session:

  • Resume this brings back its goal, next step and settled decisions and keeps your current map.
  • Resume full (Atlas saves only) replaces the current map with the saved one, after a confirm: /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.

Commands and configuration

Everything is under /atlas (/atlas help lists them).

CommandDescription
/atlasOpen 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 returnEnd the detour and send the return packet
/atlas promotePromote the detour to the goal
/atlas scanMap 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 setupShow the setup screen again
/atlas resetClear this session's map (saved files are kept)

Settings live in /config → conversation-atlas, or pluginConfigs in your settings:

SettingValuesDefaultEffect
observerclaude and engine, engine onlyclaude and enginePreselected choice on the setup screen. After setup, your stored choice wins
scanOnLaunchengine, claude, offengineWhat Atlas does when it joins a chat that already has history

Privacy and cost

  • Local only. Atlas makes no network calls and has no telemetry. It saves to Claude Code's plugin store and to <project>/.claude/atlas/ (add that to .gitignore if you don't want session maps committed).
  • Engine-only mode costs nothing, and neither does anything before you pick a mode.
  • Claude observer mode has a small usage cost. It adds one cached rules section to the system prompt and a short 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.
  • What reaches Claude: the rules section (Claude mode), a one-line note of your confirmed goal and detour (Claude mode), a pending return packet, and chips you left in your message. Observed items are never presented as settled facts.

Limitations

  • Engine-only mode has no topic path; topic quality depends on Claude calling observe.
  • Wording cues like "btw" or "quick tangent" are English-only and only create suggestions.
  • One detour at a time; nested tangents show as topics inside it.
  • Matching a return to an earlier topic is fuzzy. A wrong match moves the observed path, never your intent.
  • Docking needs the fullscreen layout and a wide terminal; scroll limits are estimates.
  • Recovery reads at most 80 files per folder and lists 12 sessions.

Roadmap

Ideas that build directly on what Atlas already captures:

  • ☐ End-of-session recap: an opt-in summary of goal, decisions and next step, shown before it is saved as a resume note
  • ☐ Smarter resume: open a new session with the recap and return packet ready to review
  • ☐ Prompt and output summaries when you open an event in Trail (desktop first)
  • ☐ Decision ledger export to a project file Claude can read at session start
  • ☐ Checkpoint detail: attach test output or a diff stat to a checkpoint
  • ☐ Topic to transcript links: jump from a pane topic to the messages it covers

Contributing

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.

License

MIT © 2026 NeelAPatel.

Source 18 files
hooks/register.tsx 1162 lines
1// 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}
1162
hooks/activity.ts 107 lines
1// 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}
107
hooks/live.tsx 110 lines
1// 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
110
hooks/delegation.ts 152 lines
1// 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}
152
hooks/model.ts 1085 lines
1// 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}
1085
hooks/scan.ts 106 lines
1// 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}
106
hooks/recall.ts 126 lines
1// 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}
126
hooks/view.tsx 21 lines
1import 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}
21
hooks/ui/shared.tsx 360 lines
1// 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
360
hooks/screens/types.ts 148 lines
1// 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
148
hooks/render-tui.tsx 1519 lines
1// 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: number
hooks/render-gui.tsx 1087 lines
1// 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