SLOPSHOPPER

jarvis-cockpit

Jarvis cockpit for the Claude Code desktop app: Recap (today, last session and next step; while working, what Jarvis is doing), Diary, To Do, Files, Reading…

newpanebandguardcommandtoast
★ 3v0.8.0MITupdated 2026-10-16Erionis/Jarvis-Lite/plugins/jarvis-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jarvis-cockpit
│ ┃ Jarvis ✕ › fix the failing auth test and add an audit log call │ ┃ The cockpit is designed for the desktop app │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /jarvis │ ⎿ jarvis-cockpit: Cockpit opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ jarvis-cockpit: ◆ Jarvis · ✕ build · 08:53

Draws

Pane · Jarvis
The cockpit is designed for the desktop app
README

Jarvis Lite

<img src="assets/brand/logo.svg" alt="Jarvis Lite" width="640">

Your personal AI assistant. Easy to start. Yours to evolve.

Jarvis Lite turns a compatible AI agent into a personal assistant that remembers who you are, how you work, what matters to you, and where you left off.

It helps you organize ideas, decisions, tasks, and knowledge; keeps continuity across sessions; and creates safe local restore points. Everything lives in readable Markdown files you own and can change at any time.

Download the ready-to-use ZIP, open the folder in Codex, Claude Code, OpenCode, or another compatible AI agent, and say Start Jarvis. Jarvis gets to know you one step at a time and shapes the workspace around your real needs.

<a href="https://github.com/Erionis/Jarvis-Lite/releases/latest/download/Jarvis-Lite.zip"> <img src="assets/brand/download-button.svg" alt="Download Jarvis Lite" width="430"> </a>

[!IMPORTANT] Open the complete Jarvis-Lite folder in your AI runtime. Jarvis lives in this workspace: if Codex, Claude, or another agent is opened in a different folder, it cannot load your Jarvis identity, memory, instructions, or continuity. That is a normal AI session—not Jarvis.

Is it for you?

Jarvis Lite is a good fit if you want:

  • a personal assistant that remembers your context and picks up where you left off;
  • your notes, decisions, and tasks as plain Markdown you own and can edit anywhere;
  • to start with almost no structure and add it only when a real need appears.

It is not a hosted service, a sync or backup system, an AI model, or a fixed project template — and not a standalone app: you use it through Codex, Claude Code, OpenCode, or another compatible agent runtime.

Download and start

The release ZIP is the simplest path. You do not need Git or Python to get the workspace:

  1. Download Jarvis-Lite.zip from the latest published release using the button above.
  2. Extract the ZIP into a new location. Keep the complete Jarvis-Lite folder; do not merge it into an existing workspace.
  3. Open that folder in Codex, Claude Code, OpenCode, or another compatible agent runtime, and set the extracted Jarvis-Lite folder as the working folder — in the Claude desktop app with the working directory button, in the Codex desktop app as its workspace.
  4. Say Start Jarvis in your language.

[!NOTE] You do not need Git to start. If you want to install it beforehand, use the official guide for macOS or Windows. Otherwise, let Jarvis handle it during first run: it checks whether Git is available, explains how Git enables local restore points, and asks before attempting the official installation for your operating system.

The button resolves to the latest published release. The release page also provides Jarvis-Lite.zip.sha256 for an optional integrity check.

Build from source

Contributors and technical users can build the same workspace locally with Git and Python 3:

git clone https://github.com/Erionis/Jarvis-Lite.git
cd Jarvis-Lite
python3 scripts/build_starter.py --output dist

Open dist/Jarvis-Lite/ in your agent. On Windows, use py -3 when that is the installed Python launcher.

See Get started with Jarvis Lite for both acquisition paths, the exact staged workspace tree, and the four first-run decisions.

Already have a Jarvis?

If you already run a Jarvis from before Lite, don't unzip this over it and don't run an incremental update against it. Instead, adopt the pieces you want from a published Lite release — a one-time, review-each-change process that leaves your workspace intact.

  1. Open your existing Jarvis folder in your agent (Codex, Claude Code, OpenCode, or another compatible runtime).
  2. Use the latest published release as your source: the latest release. It is a fixed, verifiable snapshot, so what you review is exactly what you adopt.
  3. Ask it to read skills/adopt-capability/SKILL.md at that release's tag first: it is the adoption contract, and it lives in the repository, not in the release ZIP. Then ask it to adopt the useful capabilities. Jarvis proposes what to take, shows how each item is classified, and asks you to approve every change.

Your workspace stays yours: identity, memory, Diary, handoffs, Inbox, local extensions, and Git history are never overwritten — at most Jarvis asks to keep and adapt where something overlaps.

See Updates and adoption for the full adopt-capability walkthrough and its boundaries.

What appears in the workspace

Jarvis-Lite/
├── 00 - Inbox/       material without a confirmed destination
├── 01 - Diary/       curated daily history
├── 99 - Jarvis/      memory, continuity, and system support
├── CLAUDE.md         local context and capability map
└── To Do.md          current and near-future work

Runtime and update support also live in hidden package directories. First run adds Identity and Archive; optional domain folders appear only when explicitly approved. The getting-started guide shows the complete pre- and post-first-run shape.

A day with Jarvis

You can talk to Jarvis in plain sentences; slash aliases are optional (see daily use).

A quick example. You open the workspace and say:

You: Give me a briefing.

Jarvis: Two open items in Future work, and an active handoff on the pricing note. Nothing new in Inbox. I'd resume the pricing note — that's where you stopped yesterday. That, or the open items first?

Everything Jarvis reads and writes is a Markdown file you can open yourself.

You sayJarvis does
“Give me a briefing.”Reads your declared evidence and recommends one grounded focus. Read-only.
“Work on this note.”Resolves and edits the one authoritative source for that task.
“Save and close.”Records the result, updates future work and any handoff, and offers a local restore point.

That's the daily spine. The daily-use guide adds remembering facts, handoffs across sessions, and the read-only health check.

Recommended setup

Jarvis Lite works with different compatible agents, but the quality of its memory, continuity, and multi-step work depends heavily on the model and reasoning level you choose.

For the intended Jarvis experience:

  • Run a desktop app in its coding mode: the Claude desktop app in Code mode, or the Codex desktop app in Codex mode — the mode that opens your workspace folder and runs multi-step work. Both links are the official app downloads.
  • Use Claude Opus 5 with High reasoning, or Codex with High reasoning. Lighter configurations may work, but can be less consistent on long, context-heavy, or multi-step tasks.
  • In Claude Code, select Auto mode when working in a trusted workspace. In the CLI, press Shift+Tab until auto mode on appears, or start Claude with claude --permission-mode auto. Auto mode reduces approval interruptions while keeping background safety checks.
  • Start with “Give me a briefing” and finish meaningful sessions with “Save and close” to preserve continuity.
  • Keep passwords, API keys, and other secrets outside Markdown files. Use your operating system keychain, environment variables, or a secret manager.

Included and deliberately omitted

The starter includes exactly seven installed skills:

  • briefing — grounded, read-only session orientation;
  • first-run — conversational onboarding and a verified local restore point;
  • save-session — Diary, future-work, handoff, and local-recovery closure;
  • handoff — living continuity across sessions;
  • jarvis-memory — preview-first Identity and durable-memory curation;
  • jarvis-doctor — a read-only contract and readiness audit;
  • jarvis-update — release-aware updates with scoped recovery.

Inbox organization is normal Jarvis work and needs no separate skill. Lite does not impose a fixed project hierarchy or silently add optional extensions. adopt-capability remains repository-only for existing non-Lite Jarvis installations; the public updates guide explains that boundary. The external references page names optional projects without treating them as installation, compatibility, or trust decisions.

Local-first safety

Identity, memory, work, history, handoffs, Inbox, local extensions, and Git history remain owned by your workspace. Jarvis previews protected memory changes and asks before important, destructive, installation, or external actions. Updates create scoped recovery before changing managed files.

A local restore point supports comparison and recovery; it is not a backup. Jarvis uses Git behind the scenes for this local history, but does not create an online repository or publish your files. If Git is missing, Jarvis explains what it is and asks before trying to install it. Local files do not imply local model inference: that depends on the runtime and model provider you use.

Cockpit (optional, Claude Code desktop app on macOS)

The cockpit is an optional panel beside the chat. It shows today, the last session, and the next step to resume, then your To Do, Diary, workspace files, and Jarvis's memories.

First run offers to install the cockpit. To install it yourself, run claude plugin install jarvis-cockpit --marketplace Erionis/Jarvis-Lite --scope project from the workspace folder, then open the workspace in the desktop app. The panel opens at your first message; /jarvis brings it back if you close it. You can change the mascot: ask Jarvis.

It makes no model calls and keeps a short record of recent sessions on your Mac; see docs/cockpit.md.

macOS only for now, in the Claude Code desktop app.

Compatibility and prerequisites

The generated package contains physical copies of installed skills in .agents/skills/ for Codex and .claude/skills/ for Claude Code; OpenCode understands both locations. There are no symlinks, so ordinary ZIP extraction works across macOS and Windows.

  • Markdown/filesystem read and write access is the daily minimum.
  • The source builder and update helpers use the Python 3 standard library.
  • Git and Python are not required to download or open the release ZIP. Jarvis handles local restore-point setup during first run and asks before any system installation.

Building with Jarvis

If you use Jarvis to prototype apps, code, or new products, create a lab/ folder and exclude it from the Jarvis workspace Git history:

Jarvis-Lite/
└── lab/                  ignored by the Jarvis workspace repository
    ├── first-project/    its own Git repository
    └── second-project/   its own Git repository

Add /lab/ to the root .gitignore, then initialize each project inside it as an independent Git repository. This keeps Jarvis memory and workspace history separate from the source code and history of each project.

Choose your path

You areContinue with
New to Jarvis LiteGetting started
Using Lite day to dayDaily use
Updating Lite or bringing a capability to another JarvisUpdates and adoption
Exploring optional toolsExtensions and external references
ContributingContribution workflow
Maintaining the repository or acting as a coding agentArchitecture and maintainer guide

Development, provenance, and license

Changes follow issue → short-lived branch → pull request → CI → squash merge. See CONTRIBUTING.md for the workflow, provenance for the reuse boundary, and the MIT License for licensing terms.

Brand assets and their minimal usage notes live in assets/brand/.

Source 55 files
hooks/register.tsx 1424 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3import type { Draft, FileOp, Figure, GitView, MemoriesState, Motion, Numstat, Reading, SessionRecord, Sources, Tab, TodoState, Tree, TurnEnd } from '../types'
4import { addTouched, addWritten, pushActivity, statusText } from './feed'
5import { ancestors, emptyTree, isTextFile, parentOf, toggleExpanded } from './tree'
6import { basename, dirname, errText, isoDay, oneLine } from './format'
7import { parseStatus } from './git'
8import { drawFiles } from './files'
9import { drawReader } from './readingView'
10import { EMPTY_READING, editBlock, isDirty, pressedRel } from './reader'
11import type { EditorPost } from './editor'
12import { memoryDir, parseCapabilities, userName } from './sources'
13import { detectLang, strings, type Strings } from './i18n'
14import { EN } from './strings.en'
15import { classify, isEditable, resolve } from './classify'
16import { toActivities, type ToolEvent } from './tracker'
17import { drawElsewhere, drawFault, drawPane, migrateTab } from './pane'
18import { drawReopen } from './hint'
19import { drawRecap } from './recap'
20import { activityLog, bindRun, dropRun, endRun, liveRuns, pruneRuns, reconcileRuns, sortWritten, startRun, turnFiles } from './recapModel'
21import { IDLE, SLEEP_MS, TRANSIENT_MS, stepMascot, type MascotEvent } from './mascot'
22import { motionMs, walkEnd } from './stage'
23import { TERMINAL } from './terminalFigure'
24import { FIGURE_DIR, FIGURE_FILES, MAX_FIGURE_CHARS, buildFigure, fileOf, type FigureFile } from './customFigure'
25import { WALK_MIN_COLUMNS } from './chrome'
26import { NO_TALK, dismiss, expire, greet, holdTyping, isTyped, noteWrites, onPrompt, onTurnEnd, replay, setup, talkMs, visible, type Bubble, type Talk } from './bubble'
27import { MAX_READ_BYTES, NO_SESSIONS, handoffMeta, loadSnapshot, type SessionsInput } from './snapshot'
28import { KEEP_SESSIONS, SESSION_KEY, addSubagent, asRecord, closeRecord, diaryDaysOf, endRecordTurn, noteRecordWrites, onPromptRecord, pastSessions, promptLine, recordWritten, staleKeys } from './sessionModel'
29import { WRITES } from './theme'
30import { COMMAND, PANE, TITLE } from './state'
31import { clickthrough } from './clickthrough'
32import { copyName, moveTarget as typedTarget } from './fileops'
33import { EMPTY_TODO, todoLabel, toggleItem } from './todoModel'
34import { abs, cellOf, changes, same, shaped, type Cell, type ClockPort, type FsPort, type RunPort } from './ports'
35import { flight, oneAtATime } from './flight'
36import { loadText } from './textfile'
37import { addTodo, cancelAdd, checkTodo, confirmTodo, loadTodo, openAdd, removeTodo, todoEntry, typeTodo, writeTodo, type TodoDeps } from './todoFlow'
38import { drawTodo } from './todo'
39import { dropIndex, loadDir as loadDirFlow, markStale, measureDirs as measureDirsFlow, moveText, queryText, refreshGit as refreshGitFlow, resetMove, resetQuery, runGit as runGitFlow, search as searchFlow, typeMove, typeQuery, type FilesDeps } from './filesFlow'
40import { EMPTY_MEMORIES, buildMemories, isGroup, memoryNames, parseGitDates } from './memories'
41import { drawMemories } from './memoriesView'
42import { EMPTY_DIARY, defaultDay, diaryNotes } from './diary'
43import { drawDiary } from './diaryView'
44import { resetSettle, settleWrites, type SettleDeps } from './settle'
45import { cancelDraft, closeReading as closeReadingFlow, conflictAct, draftView, editorFault, editorMessage, followOwnWrite, followWrites, goBack, openReading as openReadingFlow, pendingAct, pressLink, saveDraft, setReaderMode, startEdit as startEditFlow, syncDraft, type ReadingDeps } from './readingFlow'
46
47// The only owner of the atoms: the `plugin validate` scan only sees atoms defined in the file that reads or writes
48// them, not imported ones.
49const feedAtom = atom({ plugin: 'jarvis-cockpit', key: 'feed' } as const, [])
50const snapshotAtom = atom({ plugin: 'jarvis-cockpit', key: 'snapshot' } as const, null)
51const turnAtom = atom({ plugin: 'jarvis-cockpit', key: 'turn' } as const, 0)
52const touchedAtom = atom({ plugin: 'jarvis-cockpit', key: 'touched' } as const, [])
53const agentRunsAtom = atom({ plugin: 'jarvis-cockpit', key: 'agentRuns' } as const, {})
54const mascotAtom = atom({ plugin: 'jarvis-cockpit', key: 'mascot' } as const, IDLE)
55const workingAtom = atom({ plugin: 'jarvis-cockpit', key: 'working' } as const, false)
56const turnAtAtom = atom({ plugin: 'jarvis-cockpit', key: 'turnAt' } as const, null as number | null)
57const endsAtom = atom({ plugin: 'jarvis-cockpit', key: 'ends' } as const, [])
58const paneOpenAtom = atom({ plugin: 'jarvis-cockpit', key: 'paneOpen' } as const, false)
59const tabAtom = atom({ plugin: 'jarvis-cockpit', key: 'tab' } as const, 'recap')
60const openedAtom = atom({ plugin: 'jarvis-cockpit', key: 'opened' } as const, false)
61const treeAtom = atom({ plugin: 'jarvis-cockpit', key: 'tree' } as const, { nodes: [], loaded: [], expanded: [], broken: [], cursor: '', selected: '', query: '', hits: [], showHidden: false, showSizes: false, error: null })
62const gitAtom = atom({ plugin: 'jarvis-cockpit', key: 'git' } as const, null)
63const writtenAtom = atom({ plugin: 'jarvis-cockpit', key: 'written' } as const, {})
64const logOpenAtom = atom({ plugin: 'jarvis-cockpit', key: 'logOpen' } as const, false)
65const sizesAtom = atom({ plugin: 'jarvis-cockpit', key: 'sizes' } as const, {})
66const numstatAtom = atom({ plugin: 'jarvis-cockpit', key: 'numstat' } as const, null)
67const draftAtom = atom({ plugin: 'jarvis-cockpit', key: 'draft' } as const, null)
68const readingAtom = atom({ plugin: 'jarvis-cockpit', key: 'reading' } as const, { path: null, back: [], mode: 'text', text: null, mtimeMs: 0, size: null, isTooBig: false, error: null, updatedAt: null, diff: null })
69const fileOpAtom = atom({ plugin: 'jarvis-cockpit', key: 'fileOp' } as const, null as FileOp | null)
70const talkAtom = atom({ plugin: 'jarvis-cockpit', key: 'talk' } as const, NO_TALK)
71const NO_MOTION: Motion = { until: 0, at: 0, isRunning: false }
72const motionAtom = atom({ plugin: 'jarvis-cockpit', key: 'motion' } as const, NO_MOTION)
73const todoAtom = atom({ plugin: 'jarvis-cockpit', key: 'todo' } as const, EMPTY_TODO)
74const memoriesAtom = atom({ plugin: 'jarvis-cockpit', key: 'memories' } as const, EMPTY_MEMORIES)
75const diaryAtom = atom({ plugin: 'jarvis-cockpit', key: 'diary' } as const, EMPTY_DIARY)
76const promptsAtom = atom({ plugin: 'jarvis-cockpit', key: 'prompts' } as const, [])
77const sessionAtom = atom({ plugin: 'jarvis-cockpit', key: 'session' } as const, null as SessionRecord | null)
78const recapViewAtom = atom({ plugin: 'jarvis-cockpit', key: 'recapView' } as const, 'auto')
79const figureAtom = atom({ plugin: 'jarvis-cockpit', key: 'figure' } as const, TERMINAL)
80const figureNotesAtom = atom({ plugin: 'jarvis-cockpit', key: 'figureNotes' } as const, [] as string[])
81
82// Recap, Diary, To Do, Files, Reading, Memories: Diary, To Do and Memories only if their sources are declared.
83function tabsFor(roles: Readonly<Record<string, string>>): Tab[] {
84  return [
85    'recap',
86    ...(roles['Daily history'] !== undefined ? ['diary' as const] : []),
87    ...(roles['Future work'] !== undefined ? ['todo' as const] : []),
88    'files', 'reading',
89    ...(memoryDir(roles) !== null ? ['memories' as const] : []),
90  ]
91}
92// `$.agent.list()` statuses in which a subagent is still working.
93const LIVE: ReadonlySet<string> = new Set(['pending', 'running', 'waiting'])
94
95let sources: Sources | null = null
96// For the provisional key in agent.spawn: the subagents of one workflow share the tool_use_id.
97let spawnSeq = 0
98// Turn ends kept for the Activity log.
99const MAX_ENDS = 50
100// Successful Bash commands that earn a celebration at the end of the turn: a test suite or a commit.
101const WIN = /(^|[\s;&|(])(git\s+commit|claude\s+plugin\s+test|(npm|pnpm|yarn|bun)\s+(run\s+)?test|bun\s+test|pytest|vitest|jest|go\s+test|cargo\s+test)\b/
102
103// Context for the model, never drawn: so it stays out of the catalog.
104// Same sentence as filetree: "this" or "it" in the chat points to the file selected in the panel.
105const SELECTED = 'The user has this file selected in the file tree; "this" or "it" in the prompt likely refers to it: '
106
107// Every notice of the cockpit goes through here: one line, whatever the text carries (an error text may hold newlines).
108const toast = ($: EngineInterface, text: string): void => $.ui.toast(oneLine(text))
109
110// The ports (ports.ts): closures over `$` for the modules that cannot see it. One for each atom, with the atom written
111// by name: the `plugin validate` scan follows them.
112const fsPort = ($: EngineInterface): FsPort => ({
113  read: p => $.fs.read(p).then(String),
114  readBytes: p => $.fs.read(p, { as: 'bytes' }).then(b => b.base64),
115  stat: (p, opts) => $.fs.stat(p, opts),
116  write: (p, text) => $.fs.write(p, text),
117  list: p => $.fs.list(p),
118  exists: p => $.fs.exists(p),
119})
120const clockPort = ($: EngineInterface): ClockPort => ({ now: () => $.clock.now(), after: (ms, fn) => { $.clock.after(ms, fn) } })
121const runPort = ($: EngineInterface): RunPort => (argv, opts) => $.process.run(argv, opts)
122// The tree with the current shape (the other object atoms go through `shaped` with their empty value).
123const asTree = (t: Tree | null | undefined): Tree => shaped(emptyTree(), t)
124const todoCell = ($: EngineInterface): Cell<TodoState> => cellOf(async () => shaped(EMPTY_TODO, await read($, todoAtom)), fn => update($, todoAtom, t => fn(shaped(EMPTY_TODO, t))))
125const todoDeps = ($: EngineInterface): TodoDeps => ({ fs: fsPort($), clock: clockPort($), sources: () => getSources($), todo: todoCell($), wrote: rel => todoWrote($, rel) })
126const treeCell = ($: EngineInterface): Cell<Tree> => cellOf(async () => asTree(await read($, treeAtom)), fn => update($, treeAtom, t => fn(asTree(t))))
127const gitCell = ($: EngineInterface): Cell<GitView | null> => cellOf(async () => (await read($, gitAtom)) ?? null, fn => update($, gitAtom, fn))
128const numstatCell = ($: EngineInterface): Cell<Numstat | null> => cellOf(async () => (await read($, numstatAtom)) ?? null, fn => update($, numstatAtom, fn))
129const sizesCell = ($: EngineInterface): Cell<Record<string, number | null>> => cellOf(async () => (await read($, sizesAtom)) ?? {}, fn => update($, sizesAtom, fn))
130// The ports that both the Files and Reading flows ask for: files, processes, clock and the vault's sources.
131const basePorts = ($: EngineInterface) => ({ fs: fsPort($), run: runPort($), clock: clockPort($), sources: () => getSources($) })
132const filesDeps = ($: EngineInterface): FilesDeps => ({
133  ...basePorts($),
134  tree: treeCell($), git: gitCell($), numstat: numstatCell($), sizes: sizesCell($),
135  inView: async () => migrateTab(await read($, tabAtom)) === 'files',
136})
137
138const figureCell = ($: EngineInterface): Cell<Figure> => cellOf(async () => (await read($, figureAtom)) ?? TERMINAL, fn => update($, figureAtom, f => fn(f ?? TERMINAL)))
139const readingCell = ($: EngineInterface): Cell<Reading> => cellOf(async () => shaped(EMPTY_READING, await read($, readingAtom)), fn => update($, readingAtom, r => fn(shaped(EMPTY_READING, r))))
140const draftCell = ($: EngineInterface): Cell<Draft | null> => cellOf(async () => (await read($, draftAtom)) ?? null, fn => update($, draftAtom, fn))
141// The memories atom with the shape of today (like `shaped`); groups expanded, and memories listed, under a group
142// name from an older version are dropped.
143const asMemories = (m: MemoriesState | null | undefined): MemoriesState => {
144  const cur = shaped(EMPTY_MEMORIES, m)
145  return { ...cur, expanded: cur.expanded.filter(isGroup), list: cur.list === null ? null : cur.list.filter(item => isGroup(item?.group)) }
146}
147const memoriesCell = ($: EngineInterface): Cell<MemoriesState> => cellOf(async () => asMemories(await read($, memoriesAtom)), fn => update($, memoriesAtom, m => fn(asMemories(m))))
148const readingDeps = ($: EngineInterface): ReadingDeps => ({
149  ...basePorts($),
150  reading: readingCell($), draft: draftCell($), tree: treeCell($),
151  tab: async () => migrateTab(await read($, tabAtom)) ?? 'recap',
152  setTab: tab => setTab($, tab),
153  toast: text => toast($, text),
154  fault: () => mascotStep($, { kind: 'fault' }),
155  wrote: async rel => afterWrite($, await getSources($), rel),
156  openInApp: rel => openInFiles($, rel),
157})
158const settleDeps = ($: EngineInterface): SettleDeps => ({
159  clock: clockPort($),
160  loaded: async () => (await read($, treeAtom))?.loaded ?? [],
161  loadDir: rel => loadDir($, rel),
162  refreshGit: opts => refreshGit($, opts),
163  measureDirs: () => measureDirs($),
164  snapshot: () => refresh($),
165  todoPath: async () => (await read($, todoAtom))?.path ?? null,
166  checkTodo: () => checkTodo(todoDeps($)),
167  memDir: async () => (await read($, memoriesAtom))?.dir ?? null,
168  loadMemories: () => loadMemories($),
169  diaryRoot: async () => {
170    const [s, d] = await Promise.all([getSources($), read($, diaryAtom)])
171    return (d?.notes ?? null) === null ? null : s.roles['Daily history'] ?? null
172  },
173  loadDiary: () => loadDiary($),
174  loadFigure: () => loadFigure($),
175})
176
177async function getSources($: EngineInterface): Promise<Sources> {
178  if (sources) return sources
179  const root = await $.session.root()
180  const path = `${root}/CLAUDE.md`
181  const md = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
182  const parsed = parseCapabilities(md, root)
183  // The Identity source, if declared, can name the language (detectLang); a missing or unreadable one is just empty.
184  const soulRel = parsed.roles['Identity']
185  const soul = soulRel === undefined ? '' : await $.fs.read(`${root}/${soulRel}`).then(String, () => '')
186  sources = { ...parsed, lang: detectLang(md, soul) }
187  return sources
188}
189
190// The catalog of the cockpit's language, for what is drawn or registered outside a draw that already has the
191// sources; English while they cannot be read.
192async function catalog($: EngineInterface): Promise<Strings> {
193  try { return strings((await getSources($)).lang) } catch { return EN }
194}
195
196// Switches tab; the choice is kept in later sessions too.
197async function setTab($: EngineInterface, tab: Tab): Promise<void> {
198  try {
199    await update($, tabAtom, () => tab)
200    await $.store.set('tab', tab).catch(() => {})
201    if (tab === 'files') enterFiles($)
202    if (tab === 'diary') void loadDiary($)
203    if (tab === 'todo') void loadTodo(todoDeps($))
204    if (tab === 'memories') void loadMemories($)
205  } catch {}
206}
207
208// The reload button on the problem card: back to the Recap; Reading, the file operation and the bubble are reset. The
209// draft stays: it may hold unsaved text.
210async function recoverPane($: EngineInterface): Promise<void> {
211  resetMove()
212  await Promise.all([
213    setTab($, 'recap'),
214    update($, readingAtom, () => EMPTY_READING).catch(() => {}),
215    update($, fileOpAtom, () => null).catch(() => {}),
216    update($, talkAtom, () => NO_TALK).catch(() => {}),
217  ])
218}
219
220// On entering the Files tab: git (with the list of ignored paths) and the sizes to measure.
221function enterFiles($: EngineInterface): void {
222  void refreshGit($, { ignored: true })
223  void measureDirs($)
224}
225
226// The saved tab, if it still exists (Diary, To Do and Memories go away when their source is no longer
227// declared); an id from an older version is migrated (migrateTab).
228async function loadTab($: EngineInterface): Promise<void> {
229  try {
230    const [saved, s] = await Promise.all([$.store.get('tab').catch(() => undefined), getSources($)])
231    const tab = migrateTab(saved)
232    if (tab === null || !tabsFor(s.roles).includes(tab)) return
233    await update($, tabAtom, () => tab)
234    if (tab === 'files') enterFiles($)
235    if (tab === 'diary') void loadDiary($)
236    if (tab === 'todo') void loadTodo(todoDeps($))
237    if (tab === 'memories') void loadMemories($)
238  } catch {}
239}
240
241// The Files tab lives in filesFlow.ts: here the same functions, bound to the ports of this `$`.
242const runGit = ($: EngineInterface, root: string, args: readonly string[], timeoutMs = 5000) => runGitFlow(runPort($), root, args, timeoutMs)
243const loadDir = ($: EngineInterface, rel: string): Promise<void> => loadDirFlow(filesDeps($), rel)
244const refreshGit = ($: EngineInterface, opts: { ignored?: boolean } = {}): Promise<void> => refreshGitFlow(filesDeps($), opts)
245const measureDirs = ($: EngineInterface): Promise<void> => measureDirsFlow(filesDeps($))
246const openReading = ($: EngineInterface, rel: string, back: string[]): Promise<boolean> => openReadingFlow(readingDeps($), rel, back)
247const startEdit = ($: EngineInterface): Promise<void> => startEditFlow(readingDeps($))
248const closeReading = ($: EngineInterface, rel: string): Promise<void> => closeReadingFlow(readingDeps($), rel)
249
250// At startup: the saved open folders and switches, the root, those folders, the git state.
251async function initFiles($: EngineInterface): Promise<void> {
252  try {
253    const [saved, hidden, sizes] = await Promise.all(['expanded', 'hidden', 'sizes'].map(k => $.store.get(k).catch(() => undefined)))
254    const expanded = Array.isArray(saved) ? saved.filter((x): x is string => typeof x === 'string') : []
255    await update($, treeAtom, t => ({ ...asTree(t), expanded, showHidden: hidden === true, showSizes: sizes === true }))
256    await loadDir($, '')
257    for (const d of expanded) await loadDir($, d)
258  } catch {}
259  await refreshGit($, { ignored: true })
260  await measureDirs($)
261}
262
263async function toggleDir($: EngineInterface, id: string): Promise<void> {
264  try {
265    const t = await update($, treeAtom, cur => toggleExpanded(asTree(cur), id))
266    await $.store.set('expanded', t.expanded).catch(() => {})
267    if (t.expanded.includes(id) && !t.loaded.includes(id)) await loadDir($, id)
268    await measureDirs($)
269  } catch {}
270}
271
272// Click on a row: a folder opens or closes, a text file opens in Reading, an image, a PDF or another binary is
273// selected, a symbolic link is followed.
274async function pressRow($: EngineInterface, id: string): Promise<void> {
275  try {
276    const t = asTree(await read($, treeAtom))
277    const node = t.nodes.find(n => n.id === id)
278    if (node?.isLink === true) return await followLink($, id)
279    const kind = node?.kind ?? (t.hits.includes(id) ? 'file' : undefined)
280    if (kind === 'dir') return await toggleDir($, id)
281    if (kind !== 'file') return
282    if (isTextFile(id)) {
283      if (await openReading($, id, [])) await setTab($, 'reading')
284    } else await update($, treeAtom, cur => ({ ...asTree(cur), cursor: id, selected: id }))
285  } catch {}
286}
287
288// Shows a path in the Files tab: opens the folders that contain it (and the path itself, if it is a folder) and
289// highlights it; the filter is cleared.
290async function reveal($: EngineInterface, rel: string, isDir: boolean): Promise<void> {
291  try {
292    const chain = [...ancestors(rel), ...(isDir ? [rel] : [])]
293    resetQuery()
294    const t = await update($, treeAtom, t0 => { const cur = asTree(t0); return { ...cur, query: '', hits: [], cursor: rel, ...(isDir ? {} : { selected: rel }), expanded: [...new Set([...cur.expanded, ...chain])] } })
295    await $.store.set('expanded', t.expanded).catch(() => {})
296    for (const d of chain) if (!t.loaded.includes(d)) await loadDir($, d)
297    await setTab($, 'files')
298    await measureDirs($)
299  } catch {}
300}
301
302// From the Recap and the other tabs: a text file (Markdown, code, configuration) opens in Reading; images, PDFs and
303// other binaries are shown and selected in the Files tab.
304async function openFromRecap($: EngineInterface, rel: string): Promise<void> {
305  if (!isTextFile(rel)) return await reveal($, rel, false)
306  if (await openReading($, rel, [])) await setTab($, 'reading')
307}
308
309// A symbolic link goes where it points: inside the vault a folder is shown in the Files tab, a text file opens in
310// Reading, a binary is selected; outside the vault there is only a notice.
311async function followLink($: EngineInterface, id: string): Promise<void> {
312  const t = await catalog($)
313  try {
314    const s = await getSources($)
315    const [st, rootSt] = await Promise.all([$.fs.stat(abs(s, id), { resolve: true }), $.fs.stat(s.root, { resolve: true })])
316    const root = rootSt.realPath ?? s.root
317    const real = st.realPath
318    if (real === undefined || st.kind === 'other') {
319      toast($, t.toasts.linkNowhere)
320      return
321    }
322    if (!real.startsWith(`${root}/`)) {
323      toast($, t.toasts.linkOutside)
324      return
325    }
326    const rel = real.slice(root.length + 1)
327    if (st.kind === 'dir') return await reveal($, rel, true)
328    if (isTextFile(rel)) {
329      if (await openReading($, rel, [])) await setTab($, 'reading')
330      return
331    }
332    await reveal($, rel, false)
333    await update($, treeAtom, t => ({ ...asTree(t), selected: rel }))
334  } catch {
335    toast($, t.toasts.linkNowhere)
336  }
337}
338
339// The `.` and Σ switches of the bar: remembered across sessions.
340async function toggleTree($: EngineInterface, key: 'hidden' | 'sizes'): Promise<void> {
341  try {
342    const t = await update($, treeAtom, t0 => { const cur = asTree(t0); return key === 'hidden' ? { ...cur, showHidden: !cur.showHidden } : { ...cur, showSizes: !cur.showSizes } })
343    await $.store.set(key, key === 'hidden' ? t.showHidden : t.showSizes).catch(() => {})
344    await measureDirs($)
345  } catch {}
346}
347
348// ⊟: closes all folders.
349async function collapseAll($: EngineInterface): Promise<void> {
350  try {
351    await update($, treeAtom, cur => ({ ...asTree(cur), expanded: [] }))
352    await $.store.set('expanded', []).catch(() => {})
353  } catch {}
354}
355
356async function copyPath($: EngineInterface, rel: string): Promise<void> {
357  try {
358    const t = await catalog($)
359    const done = await $.ui.copy({ text: rel })
360    toast($, done.isCopied ? t.toasts.copied(rel) : t.toasts.cannotCopy)
361  } catch {}
362}
363
364// Trash, move and duplicate: only on an unprotected vault file with no draft to save.
365// The reason why not, or null.
366async function opBlock($: EngineInterface, s: Sources, rel: string): Promise<string | null> {
367  const t = strings(s.lang)
368  const cls = classify(resolve(s.root, rel), s, t)
369  if (cls === null) return t.toasts.outsideVault
370  if (cls.isProtected) return t.toasts.protectedFile(basename(rel))
371  // The draft goes through the flow: the user's last keystroke may not be in the atom yet.
372  const draft = await syncDraft(readingDeps($))
373  if (draft !== null && draft.path === rel && isDirty(draft)) return t.toasts.saveFirst
374  return null
375}
376
377// Runs a command on the file (trash, mv, cp, mkdir); the reason if it fails, or null.
378async function runOp($: EngineInterface, s: Sources, argv: string[]): Promise<string | null> {
379  try {
380    const r = await $.process.run(argv, { cwd: s.root, timeoutMs: 10_000 })
381    return r.exitCode === 0 ? null : (r.stderr.trim() || strings(s.lang).toasts.commandFailed(argv[0] ?? '', r.exitCode))
382  } catch (err) {
383    return errText(err)
384  }
385}
386
387// After an action: the touched folders that are already open are read again, and so is git; the filter index
388// restarts; the new file is shown (or the selection is cleared). The file open in Reading follows the move or closes.
389async function afterOp($: EngineInterface, dirs: string[], show: string, gone: string | null): Promise<void> {
390  dropIndex()
391  const t = asTree(await read($, treeAtom))
392  for (const d of new Set(dirs)) if (t.loaded.includes(d)) await loadDir($, d)
393  if (show !== '') await reveal($, show, false)
394  else await update($, treeAtom, x => ({ ...asTree(x), selected: '', cursor: '' }))
395  const rd = shaped(EMPTY_READING, await read($, readingAtom))
396  if (gone !== null && rd.path === gone) {
397    if (show !== '' && gone !== show) await openReading($, show, rd.back)
398    else await closeReading($, gone)
399    await setTab($, 'files')
400  }
401  void refreshGit($)
402}
403
404async function startOp($: EngineInterface, kind: FileOp['kind']): Promise<void> {
405  try {
406    const path = (await read($, treeAtom))?.selected ?? ''
407    if (path === '') return
408    const block = await opBlock($, await getSources($), path)
409    if (block !== null) return toast($, block)
410    resetMove()
411    await update($, fileOpAtom, () => (kind === 'trash' ? { kind, path } : { kind, path, text: path }))
412  } catch {}
413}
414
415async function trashFile($: EngineInterface): Promise<void> {
416  try {
417    const op = await read($, fileOpAtom)
418    await update($, fileOpAtom, () => null)
419    if (op?.kind !== 'trash') return
420    const s = await getSources($)
421    const block = await opBlock($, s, op.path)
422    if (block !== null) return toast($, block)
423    const err = await runOp($, s, ['/usr/bin/trash', '-s', abs(s, op.path)])
424    const t = strings(s.lang)
425    if (err !== null) return toast($, t.toasts.cannotTrash(err))
426    toast($, t.toasts.trashed(basename(op.path)))
427    await afterOp($, [parentOf(op.path)], '', op.path)
428  } catch {}
429}
430
431// Enter in the move field: on an error the field stays and the notice says why.
432async function moveFile($: EngineInterface, text: string): Promise<void> {
433  try {
434    const op = await read($, fileOpAtom)
435    if (op?.kind !== 'move') return
436    const from = op.path
437    const t = await catalog($)
438    const target = typedTarget(text, from, t)
439    if ('error' in target) return toast($, target.error)
440    const s = await getSources($)
441    const block = await opBlock($, s, from)
442    if (block !== null) return toast($, block)
443    const dest = classify(resolve(s.root, target.rel), s, t)
444    if (dest === null) return toast($, t.toasts.onlyInVault)
445    if (dest.isProtected) return toast($, t.toasts.notThere(target.rel))
446    if (await $.fs.exists(abs(s, target.rel))) return toast($, t.toasts.exists(target.rel))
447    const dir = parentOf(target.rel)
448    if (dir !== '' && !(await $.fs.exists(abs(s, dir)))) {
449      const err = await runOp($, s, ['mkdir', '-p', abs(s, dir)])
450      if (err !== null) return toast($, t.toasts.cannotCreate(dir, err))
451    }
452    const err = await runOp($, s, ['mv', '-n', abs(s, from), abs(s, target.rel)])
453    if (err !== null) return toast($, t.toasts.cannotMove(err))
454    resetMove()
455    await update($, fileOpAtom, () => null)
456    toast($, t.toasts.moved(target.rel))
457    await afterOp($, [parentOf(from), dir], target.rel, from)
458  } catch {}
459}
460
461async function duplicateFile($: EngineInterface): Promise<void> {
462  try {
463    const from = (await read($, treeAtom))?.selected ?? ''
464    if (from === '') return
465    const s = await getSources($)
466    const block = await opBlock($, s, from)
467    if (block !== null) return toast($, block)
468    const dir = parentOf(from)
469    const names = new Set((await $.fs.list(abs(s, dir))).map(e => e.name))
470    const t = strings(s.lang)
471    const to = copyName(from, names, t)
472    const err = await runOp($, s, ['cp', '-n', abs(s, from), abs(s, to)])
473    if (err !== null) return toast($, t.toasts.cannotDuplicate(err))
474    toast($, t.toasts.created(basename(to)))
475    await afterOp($, [dir], to, null)
476  } catch {}
477}
478
479async function openInFiles($: EngineInterface, rel: string): Promise<void> {
480  const t = await catalog($)
481  try {
482    const s = await getSources($)
483    const r = await $.mcp.call('ccd_view', 'show_pane', { pane: 'file', path: abs(s, rel) })
484    if (r.isError) toast($, t.toasts.cannotOpenPane)
485  } catch {
486    toast($, t.toasts.paneUnavailable)
487  }
488}
489
490const memoriesFlight = flight()
491const diaryFlight = flight()
492const figureFlight = flight()
493
494// After a cockpit write: the To Do tab reloads at once (it is the user's own gesture); git, tree, sizes, tabs and
495// the Recap snapshot follow the file when the writes settle. The old size stays on screen until the new one arrives.
496async function afterWrite($: EngineInterface, s: Sources, rel: string): Promise<void> {
497  markStale([rel])
498  const isTodo = rel === s.roles['Future work']
499  settleWrites(settleDeps($), [{ verb: 'edit', path: rel }], { snapshot: isTodo, measure: true })
500  if (isTodo && (await read($, todoAtom))?.path === rel) await loadTodo(todoDeps($))
501}
502
503// After a write from the To Do tab: Reading and the draft follow the file (readingFlow), then git, tree and
504// Recap.
505async function todoWrote($: EngineInterface, rel: string): Promise<void> {
506  await followOwnWrite(readingDeps($), rel)
507  await afterWrite($, await getSources($), rel)
508}
509
510// With the To Do tab in view, a click in the panel and the start and end of a turn check whether another chat has
511// written the file (a stat): if so, the tab reloads.
512async function checkTodoInView($: EngineInterface): Promise<void> {
513  try {
514    const [tab, isOpen] = await Promise.all([read($, tabAtom), read($, paneOpenAtom)])
515    if (migrateTab(tab) === 'todo' && isOpen === true) await checkTodo(todoDeps($))
516  } catch {}
517}
518
519// The Memories tab reads the folder again: each file on its own (those over the limit get no text), and git log and
520// status on the folder together. Without git, or with a truncated log, new/changed come from the file dates.
521// One reload at a time: a single `git log` at once.
522function loadMemories($: EngineInterface): Promise<void> {
523  return oneAtATime(memoriesFlight, () => readMemories($))
524}
525
526async function readMemories($: EngineInterface): Promise<void> {
527  try {
528    const s = await getSources($)
529    const dir = memoryDir(s.roles)
530    if (dir === null) return
531    let entries: Awaited<ReturnType<EngineInterface['fs']['list']>>
532    try {
533      entries = await $.fs.list(abs(s, dir))
534    } catch (err) {
535      await memoriesCell($).set(m => ({ ...m, dir, list: null, error: errText(err) }))
536      return
537    }
538    const names = memoryNames(entries, dir, s.roles)
539    const [log, status, nowMs, files] = await Promise.all([
540      runGit($, s.root, ['log', '-z', '--format=%ct', '--name-only', '--', dir], 10_000),
541      runGit($, s.root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', dir]),
542      $.clock.now(),
543      Promise.all(names.map(async name => {
544        const e = entries.find(x => x.name === name)
545        const text = e === undefined || e.size > MAX_READ_BYTES ? null : await $.fs.read(abs(s, `${dir}/${name}`)).then(String, () => null)
546        return { name, text, mtimeMs: e?.mtimeMs ?? 0 }
547      })),
548    ])
549    const inDir = (p: string): boolean => dirname(p) === dir || dirname(p).endsWith(`/${dir}`)
550    const git = log === null || log.isTruncated ? null : {
551      dates: parseGitDates(log.stdout, dir),
552      dirty: new Set(Object.keys(parseStatus(status?.stdout ?? '').changed).filter(inDir).map(basename)),
553    }
554    const list = buildMemories(files, git, nowMs)
555    const has = (f: string): boolean => list.some(x => x.file === f)
556    // A memory that has disappeared closes its view and leaves the stack.
557    await memoriesCell($).set(m => ({ ...m, dir, list, error: null, open: m.open !== null && has(m.open) ? m.open : null, back: m.back.filter(has) }))
558  } catch {}
559}
560
561// The Diary tab looks for the notes up to today (the year/month folders and the diary root) and reads the one for the
562// requested day, or the one already shown if it still exists, or the last one before today. The note changes in the
563// tab only once it has been read: no loading text between one note and the next. One reload at a time.
564function loadDiary($: EngineInterface, day: string | null = null): Promise<void> {
565  return oneAtATime(diaryFlight, () => readDiary($, day))
566}
567
568async function readDiary($: EngineInterface, want: string | null): Promise<void> {
569  try {
570    const s = await getSources($)
571    const root = s.roles['Daily history']
572    if (root === undefined) return
573    const cur = shaped(EMPTY_DIARY, await read($, diaryAtom))
574    const list = (dir: string) => $.fs.list(abs(s, dir))
575    let top: Awaited<ReturnType<typeof list>>
576    try {
577      top = await list(root)
578    } catch (err) {
579      if (changes(cur, { notes: null, error: errText(err) })) await update($, diaryAtom, d => ({ ...shaped(EMPTY_DIARY, d), notes: null, error: errText(err) }))
580      return
581    }
582    const today = isoDay(await $.clock.now())
583    const subdirs = async (dir: string, name: RegExp): Promise<string[]> =>
584      (await list(dir).catch(() => [])).filter(x => x.kind === 'dir' && name.test(x.name)).map(x => `${dir}/${x.name}`)
585    const months = (await Promise.all((await subdirs(root, /^\d{4}$/)).map(y => subdirs(y, /^\d{2}$/)))).flat()
586    const found = await Promise.all(months.map(async dir => ({ dir, names: (await list(dir).catch(() => [])).map(x => x.name) })))
587    const notes = diaryNotes([...found, { dir: root, names: top.filter(x => x.kind === 'file').map(x => x.name) }], today)
588    const has = (day: string | null): day is string => day !== null && notes.some(n => n.day === day)
589    const day = has(want) ? want : has(cur.day) ? cur.day : defaultDay(notes, today)
590    const note = notes.find(n => n.day === day)
591    const loaded = note === undefined ? { text: null, isTooBig: false, error: null } : await loadText(fsPort($), abs(s, note.path))
592    // The same notes as before stay the same array: a reload that changes nothing does not redraw.
593    const unchanged = cur.notes !== null && cur.notes.length === notes.length && cur.notes.every((n, i) => n.day === notes[i]?.day && n.path === notes[i]?.path)
594    const next = { notes: unchanged ? cur.notes : notes, day: note?.day ?? null, text: loaded.text, isTooBig: loaded.isTooBig, error: loaded.error }
595    if (changes(cur, next)) await update($, diaryAtom, d => ({ ...shaped(EMPTY_DIARY, d), ...next }))
596  } catch {}
597}
598
599// The Diary button of the Recap's Last session (recapOpen.tsx): that day's note in the Diary tab.
600async function openDiary($: EngineInterface, day: string): Promise<void> {
601  try {
602    await loadDiary($, day)
603    await setTab($, 'diary')
604  } catch {}
605}
606
607// A link in the Diary and To Do tabs: a text file opens in Reading, a binary in the Files pane.
608async function tabLink($: EngineInterface, href: string): Promise<void> {
609  try {
610    const rel = pressedRel(href, (await getSources($)).root)
611    if (rel !== null && isTextFile(rel)) await openFromRecap($, rel)
612    else await pressLink(readingDeps($), href)
613  } catch {}
614}
615
616// The edit-the-file button: the Future work file in the Reading editor.
617async function editTodo($: EngineInterface): Promise<void> {
618  try {
619    const s = await getSources($)
620    const rel = s.roles['Future work']
621    if (rel === undefined || !(await openReading($, rel, []))) return
622    await setTab($, 'reading')
623    await startEdit($)
624  } catch {}
625}
626
627// The past sessions of the registry, most recent first: read from the store once and kept here (drawing never reads
628// the store); session.start and session.end make them load again. null: not read yet.
629let pastCache: SessionRecord[] | null = null
630// The last record written to the store, as JSON: it is written again only if it changed.
631let savedJson = ''
632
633// The registry for the snapshot: the latest past sessions (without the current one) and the days of their diary notes.
634// A store that does not answer (the keys, or even a single read) gives an empty or incomplete registry that is not
635// kept in `pastCache`: it is tried again at the next refresh.
636async function registry($: EngineInterface): Promise<SessionsInput> {
637  try {
638    if (pastCache === null) {
639      const keys = (await $.store.keys()).filter(k => k.startsWith(SESSION_KEY))
640      let failed = false
641      const records = (await Promise.all(keys.map(k => $.store.get(k).then(asRecord, () => { failed = true; return null })))).filter((r): r is SessionRecord => r !== null)
642      let id: string | null = null
643      try { id = await $.session.id() } catch {}
644      const past = pastSessions(records, id)
645      if (!failed) pastCache = past
646      return { sessions: past, diaryDays: diaryDaysOf(past) }
647    }
648    return { sessions: pastCache, diaryDays: diaryDaysOf(pastCache) }
649  } catch {
650    return NO_SESSIONS
651  }
652}
653
654// The record in the store under `session:<id>`, only if it changed since the last successful write. A refused write
655// stops nothing: the record stays in the state and is tried again next time. Without an id nothing is written.
656async function saveSession($: EngineInterface, rec: SessionRecord | null | undefined): Promise<void> {
657  if (rec == null || rec.id === '') return
658  const json = JSON.stringify(rec)
659  if (json === savedJson) return
660  try {
661    await $.store.set(`${SESSION_KEY}${rec.id}`, rec)
662    savedJson = json
663  } catch {}
664}
665
666// The record the store already has for this session (a session resumed in a new process), reopened: endAt goes back
667// to null. null if there is none, it cannot be read or it belongs to another session.
668async function storedSession($: EngineInterface, id: string): Promise<SessionRecord | null> {
669  try {
670    const rec = asRecord(await $.store.get(`${SESSION_KEY}${id}`))
671    return rec === null || rec.id !== id ? null : { ...rec, endAt: null }
672  } catch {
673    return null
674  }
675}
676
677// At session start the store keeps the KEEP_SESSIONS most recent sessions: the other `session:` keys are deleted,
678// including those of other sessions (they are old). The other keys (tab, expanded…) are not touched.
679async function startSessions($: EngineInterface): Promise<void> {
680  try {
681    const keys = (await $.store.keys()).filter(k => k.startsWith(SESSION_KEY))
682    if (keys.length <= KEEP_SESSIONS) return
683    const entries = await Promise.all(keys.map(async key => ({ key, value: await $.store.get(key).catch(() => undefined) })))
684    for (const key of staleKeys(entries)) await $.store.delete(key).catch(() => {})
685  } catch {}
686}
687
688// A submitted prompt: the start of its turn in the log, the Recap going back to following the session, and the session
689// record, which is born with the first prompt and goes to the store at once. Never throws.
690async function notePrompt($: EngineInterface, turn: number, text: string): Promise<void> {
691  try {
692    const at = await $.clock.now()
693    await update($, promptsAtom, p => [...(p ?? []), { turn, at, text: promptLine(text) }].slice(-MAX_ENDS))
694    if ((await read($, recapViewAtom)) === 'open') await update($, recapViewAtom, () => 'auto' as const)
695    const held = await read($, sessionAtom)
696    let id = ''
697    try { id = await $.session.id() } catch {}
698    // An id that does not answer while the session is open: the record's own id stays (a new record without an id would
699    // lose the old one).
700    if (id === '' && held != null && held.endAt === null) id = held.id
701    // Another session in the same process (after /clear or /resume the id changes without a session.start).
702    const isOther = id !== '' && held != null && held.id !== id
703    // The first prompt of a process, or of another session, picks up the store's record if the session was resumed.
704    const stored = id !== '' && (held == null || isOther) ? await storedSession($, id) : null
705    // The written files are those of the session: another session does not inherit those of the closed one, a resumed
706    // one finds its own (so the turn end, which takes them from `written`, does not lose them).
707    if (isOther || stored !== null) await update($, writtenAtom, w => ({ ...(stored === null ? {} : recordWritten(stored)), ...(isOther ? {} : w ?? {}) }))
708    const rec = await update($, sessionAtom, r => onPromptRecord(r != null && r.id === id ? r : stored ?? r ?? null, id, at, text))
709    if (rec !== null && rec.prompts === 1) await saveSession($, rec)
710  } catch {}
711}
712
713// Topic and status of the handoff written in the session, from its frontmatter (within MAX_READ_BYTES); null if it
714// cannot be read.
715async function handoffMetaOf($: EngineInterface, file: string): Promise<{ topic: string; status: string } | null> {
716  try {
717    const p = abs(await getSources($), file)
718    if ((await $.fs.stat(p)).size > MAX_READ_BYTES) return null
719    return handoffMeta(String(await $.fs.read(p)), file)
720  } catch {
721    return null
722  }
723}
724
725// Recomputes the snapshot; never throws: a caller using `void` leaves no rejection.
726async function refresh($: EngineInterface): Promise<void> {
727  try {
728    const s = await getSources($)
729    const at = await $.clock.now()
730    // The registry comes before the snapshot: it says which diary notes to read.
731    const reg = await registry($)
732    const snap = await loadSnapshot({
733      exists: p => $.fs.exists(p), read: p => $.fs.read(p), list: p => $.fs.list(p), stat: p => $.fs.stat(p),
734      blame: async p => {
735        const r = await $.process.run(['git', 'blame', '--line-porcelain', '--', p], { cwd: s.root, timeoutMs: 3000 })
736        return r.exitCode === 0 && !r.isStdoutTruncated ? r.stdout : ''
737      },
738    }, s, at, reg)
739    // The same snapshot, taken at another time, is not written again.
740    const cur = await read($, snapshotAtom)
741    if (cur == null || !same({ ...cur, takenAt: 0 }, { ...snap, takenAt: 0 })) await update($, snapshotAtom, () => snap)
742  } catch {}
743}
744
745// The mascot: an event moves it to another state, and it is written only if it changes. `done` and `error` go back by
746// themselves after TRANSIENT_MS, `idle` falls asleep after SLEEP_MS: a tick with $.clock.after. Never throws.
747async function mascotStep($: EngineInterface, ev: MascotEvent): Promise<void> {
748  try {
749    const [cur, working, runs, at] = await Promise.all([read($, mascotAtom), read($, workingAtom), read($, agentRunsAtom), $.clock.now()])
750    const ctx = { working: working ?? false, agents: liveRuns(runs ?? {}) }
751    // No change, no write (update always writes and would trigger a redraw).
752    const shown = shaped(IDLE, cur)
753    if (stepMascot(shown, ev, ctx, at) === shown) return
754    // The step is recomputed inside the updater, on the value that is really there: two steps at once are not lost. The
755    // return tick starts only if this step wrote the entry into done/error.
756    let entered = false
757    let rested = false
758    // A new scene: how long it moves (walk, hop, party, shake) and whether the walk is visible.
759    let moves = 0
760    let walks = false
761    await update($, mascotAtom, now => {
762      const was = shaped(IDLE, now)
763      const moved = stepMascot(was, ev, ctx, at)
764      entered = moved !== was && ev.kind !== 'tick' && (moved.state === 'done' || moved.state === 'error')
765      rested = moved.state === 'idle' && was.state !== 'idle'
766      const isNew = moved.since !== was.since
767      moves = isNew ? motionMs(moved, canWalk()) : 0
768      walks = isNew && walkEnd(moved, canWalk()) !== null
769      return moved
770    })
771    if (entered) $.clock.after(TRANSIENT_MS, () => { void mascotStep($, { kind: 'tick' }) })
772    if (rested) $.clock.after(SLEEP_MS, () => { void mascotStep($, { kind: 'tick' }) })
773    await animate($, moves)
774    // Walking and typing never together: a cheer phrase typed halfway starts over on arrival.
775    if (walks) await talkStep($, t => t)
776  } catch {}
777}
778
779// Whether the panel is narrower than WALK_MIN_COLUMNS (as of the last draw): there the mascot does not walk, so the
780// walk takes no steps. Before the first draw it is assumed wide.
781let isNarrow = false
782const canWalk = (): boolean => !isNarrow
783
784// The steps: while the mascot moves (or types) the panel redraws every STEP_MS with the image for that instant, then
785// the chain stops with a last step at rest. Never an interactive frame: it would reload and flicker on every step.
786// One chain at a time; one that has been stalled for too long (module reloaded halfway) starts again. No steps while
787// the panel is closed: nothing moving can be seen, and the panel that opens takes up the missing ones (resumeMotion).
788// They never throw.
789const STEP_MS = 160
790async function animate($: EngineInterface, ms: number): Promise<void> {
791  if (ms <= 0) return
792  try {
793    if ((await read($, paneOpenAtom)) !== true) return
794    const at = await $.clock.now()
795    let isStart = false
796    await update($, motionAtom, m => {
797      const was = shaped(NO_MOTION, m)
798      isStart = !was.isRunning || at - was.at > 4 * STEP_MS
799      return { until: Math.max(was.until, at + ms), at: isStart ? at : was.at, isRunning: true }
800    })
801    if (isStart) stepLater($)
802  } catch {}
803}
804
805function stepLater($: EngineInterface): void {
806  $.clock.after(STEP_MS, () => {
807    void (async () => {
808      // A chain already running stops if the panel has closed: no steps while the panel is closed. The panel that opens
809      // takes it up again (resumeMotion).
810      const [now, isOpen] = await Promise.all([$.clock.now(), read($, paneOpenAtom)])
811      let isMore = false
812      await update($, motionAtom, m => {
813        const was = shaped(NO_MOTION, m)
814        isMore = isOpen === true && now < was.until
815        return { ...was, at: now, isRunning: isMore }
816      })
817      if (isMore) stepLater($)
818    })().catch(() => {})
819  })
820}
821
822// The bubble: a pure step on `talk`, written only if it changes. A cheer phrase said now takes its start time and is
823// typed out, after the walk in progress (holdTyping); a bubble with a button appears whole, without steps. A new
824// expiry removes the phrase after CHEER_MS. Never throws.
825async function talkStep($: EngineInterface, fn: (t: Talk) => Talk): Promise<void> {
826  try {
827    const [cur, mascot, at] = await Promise.all([read($, talkAtom), read($, mascotAtom), $.clock.now()])
828    const walk = walkEnd(shaped(IDLE, mascot), canWalk())
829    const step = (was: Talk): Talk => {
830      const next = fn(was)
831      const b = next.bubble
832      // A new cheer phrase takes the time it starts: the drawing types it out only from then on.
833      const stamped = b !== null && isTyped(b) && b.id !== was.bubble?.id && b.at === undefined ? { ...next, bubble: { ...b, at } } : next
834      return holdTyping(stamped, at, walk)
835    }
836    const shown = shaped(NO_TALK, cur)
837    if (step(shown) === shown) return
838    let timed = null as Bubble | null
839    let said = null as Bubble | null
840    await update($, talkAtom, t => {
841      const was = shaped(NO_TALK, t)
842      const next = step(was)
843      const b = next.bubble
844      const isNew = b !== null && b.id !== was.bubble?.id
845      // Said now (new, said again or held until arrival): waves the arm and types out the text.
846      said = b !== null && isTyped(b) && b.at !== undefined && (isNew || b.at !== was.bubble?.at) ? b : null
847      // A new expiry (new phrase, greeting said again or held) starts its timer.
848      timed = b !== null && b.until !== undefined && (isNew || b.until !== was.bubble?.until) ? b : null
849      return next
850    })
851    const s = said
852    if (s !== null && s.at !== undefined) await animate($, s.at - at + talkMs(s.text))
853    const b = timed
854    if (b !== null && b.until !== undefined) {
855      $.clock.after(Math.max(b.until - at, 0), () => {
856        void $.clock.now().then(now => talkStep($, t => expire(t, now)), () => {})
857      })
858    }
859  } catch {}
860}
861
862// The bubble's button: prepares the command or opens the file, then the bubble closes.
863async function bubbleAct($: EngineInterface, b: Bubble): Promise<void> {
864  if (b.action?.send !== undefined) await send($, b.action.send)
865  if (b.action?.open !== undefined) await openFromRecap($, b.action.open)
866  await talkStep($, t => dismiss(t, b.id))
867}
868
869// Reads the mascot folder and swaps the stage's figure; one read at a time, so a write during a read is not
870// lost. A refused file is reported once in each session. Never throws.
871function loadFigure($: EngineInterface): Promise<void> {
872  return oneAtATime(figureFlight, () => readFigure($))
873}
874
875async function readFigure($: EngineInterface): Promise<void> {
876  try {
877    const s = await getSources($)
878    const dir = abs(s, FIGURE_DIR)
879    const names = (await $.fs.exists(dir)) ? new Set((await $.fs.list(dir)).map(e => e.name)) : new Set<string>()
880    const texts: Partial<Record<FigureFile, string | null>> = {}
881    for (const f of FIGURE_FILES) {
882      if (!names.has(fileOf(f))) continue
883      const p = `${dir}/${fileOf(f)}`
884      // A file that cannot be statted counts as missing. A UTF-8 character is at most four bytes: a bigger file
885      // cannot fit the limit, so it is not read.
886      const size = await $.fs.stat(p).then(x => x.size, () => undefined)
887      if (size === undefined) continue
888      if (size > MAX_FIGURE_CHARS * 4) { texts[f] = null; continue }
889      const text = await $.fs.read(p).then(String, () => undefined)
890      if (text !== undefined) texts[f] = text
891    }
892    const { figure, refused } = buildFigure(texts)
893    await figureCell($).set(() => figure)
894    const seen = (await read($, figureNotesAtom)) ?? []
895    const fresh = refused.map(r => ({ ...r, key: `${r.file}:${r.reason}` })).filter(r => !seen.includes(r.key))
896    const t = strings(s.lang)
897    for (const r of fresh) toast($, t.figure.refused(r.file, r.reason))
898    if (fresh.length > 0) await update($, figureNotesAtom, n => [...(n ?? []), ...fresh.map(r => r.key)])
899  } catch {}
900}
901
902// The session greeting with the user's first name; without declared capabilities (or with the Lite marker) the
903// invitation to set up.
904async function hello($: EngineInterface): Promise<void> {
905  try {
906    const s = await getSources($)
907    // The user's own name, as given to Jarvis (CLAUDE.md, then the identity); if missing, the git name and then the
908    // name of the Mac account (`id -F`).
909    const text = async (rel: string | undefined): Promise<string> =>
910      rel === undefined ? '' : await $.fs.read(abs(s, rel)).then(String, () => '')
911    let who = userName(await text('CLAUDE.md'), await text(s.roles['Identity'])) ?? ''
912    if (who === '') who = (await runGit($, s.root, ['config', 'user.name']))?.stdout.trim() ?? ''
913    if (who === '') who = await $.process.run(['id', '-F'], { cwd: s.root, timeoutMs: 2000 }).then(r => (r.exitCode === 0 ? r.stdout.trim() : ''), () => '')
914    const name = who === '' ? null : who
915    // To set up: a single bubble, greeting and invitation together.
916    const t = strings(s.lang)
917    await talkStep($, talk => (!s.hasTable || s.isOnboarding === true ? setup(greet(talk, name, t), t, name) : greet(talk, name, t)))
918  } catch {}
919}
920
921// The last line of the log without the time (HH:MM and the space), or null if the log is empty.
922const lastLine = (log: readonly string[]): string | null => { const l = log[log.length - 1]; return l === undefined ? null : l.slice(6) }
923
924// Opens the panel and, if the engine places it, remembers that (the line under the prompt then drops ✦ Jarvis).
925// Throws if $.ui.open throws: the caller decides.
926async function openPane($: EngineInterface): Promise<void> {
927  const r = await $.ui.open({ id: PANE, title: TITLE })
928  if (r.isPlaced) {
929    await update($, paneOpenAtom, () => true)
930    const now = await $.clock.now()
931    await talkStep($, t => replay(t, now))
932    await resumeMotion($)
933  }
934}
935
936// The panel opens or widens: the steps still missing for the current scene and phrase (while the panel is closed
937// animate takes none, and in the narrow panel neither does the walk). Never throws.
938async function resumeMotion($: EngineInterface): Promise<void> {
939  try {
940    const [m, t, now] = await Promise.all([read($, mascotAtom), read($, talkAtom), $.clock.now()])
941    const mascot = shaped(IDLE, m)
942    const b = shaped(NO_TALK, t).bubble
943    const talking = b !== null && isTyped(b) && b.at !== undefined ? b.at + talkMs(b.text) : 0
944    await animate($, Math.max(mascot.since + motionMs(mascot, canWalk()), talking) - now)
945    // Widening the panel halfway through a turn restarts the walk while a phrase may already be typing (started while
946    // narrow, when there was no walk): holdTyping puts it after the arrival.
947    await talkStep($, t => t)
948  } catch {}
949}
950
951// ✦ Jarvis: if the engine still has the panel (the app may have hidden its tab) it closes and reopens it, so the app
952// brings it back into view; otherwise it opens it.
953async function reopenPane($: EngineInterface): Promise<void> {
954  const isListed = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
955  if (isListed) await $.ui.close({ id: PANE }).catch(() => {})
956  await openPane($)
957}
958
959async function send($: EngineInterface, text: string): Promise<void> {
960  try {
961    if ((await $.prompt.read()).text.trim() === '') await $.prompt.fill({ text })
962    else toast($, (await catalog($)).toasts.promptNotEmpty)
963  } catch {}
964}
965
966// The actions on the selected file are drawn only for an unprotected vault file.
967function selOps(s: Sources, hasSources: boolean, sel: string): boolean {
968  if (!hasSources || sel === '') return false
969  const cls = classify(resolve(s.root, sel), s, strings(s.lang))
970  return cls !== null && !cls.isProtected
971}
972
973// The first click on a panel without keyboard focus becomes a press (clickthrough.ts). `paneHeld` is the last
974// `isFocused` of the draw: gaining or losing keyboard focus redraws the panel.
975const paneClicks = clickthrough()
976let paneHeld = false
977
978// A draw that throws does not make the panel disappear: a minimal card with the reload button (recoverPane).
979async function fault($: EngineInterface, resolved: Elements['desktop'], message: string): Promise<RenderElement> {
980  const t = await catalog($)
981  const drawing = paneClicks.draw(resolved)
982  const tree = drawFault(drawing.el, message, () => { void recoverPane($) }, t)
983  drawing.done()
984  return tree
985}
986
987// The panel: reads the atoms and draws the header, the tabs and the active tab. If it throws, the guard in the Pane
988// hook draws the minimal card (fault).
989async function drawCockpit($: EngineInterface, el: Elements['desktop'], columns: number): Promise<RenderElement> {
990  const [snapshot, feed, turn, runs, tab, tree, gitView, written, reading, srcs, nowMs, sizes, numstat, draft, mascot, working, ends, fileOp, turnAt, talk, figure] = await Promise.all([
991    read($, snapshotAtom), read($, feedAtom), read($, turnAtom), read($, agentRunsAtom), read($, tabAtom),
992    read($, treeAtom), read($, gitAtom), read($, writtenAtom), read($, readingAtom), getSources($).catch(() => null), $.clock.now(),
993    read($, sizesAtom), read($, numstatAtom), read($, draftAtom), read($, mascotAtom), read($, workingAtom), read($, endsAtom), read($, fileOpAtom),
994    read($, turnAtAtom), read($, talkAtom), read($, figureAtom),
995    // Read so the panel redraws at every step of the mascot (animate).
996    read($, motionAtom),
997  ])
998  const todo = shaped(EMPTY_TODO, await read($, todoAtom))
999  const memories = asMemories(await read($, memoriesAtom))
1000  const diary = shaped(EMPTY_DIARY, await read($, diaryAtom))
1001  const logOpen = await read($, logOpenAtom)
1002  const rd = shaped(EMPTY_READING, reading)
1003  const tr = asTree(tree)
1004  // The draft with the editor's latest text, even if the atom does not have it yet.
1005  const shownDraft = draftView(draft ?? null)
1006  // Without readable sources (root or CLAUDE.md not reachable) the open file in Reading is still drawn, just without
1007  // a role.
1008  const rs: Sources = srcs ?? { root: '', roles: {}, hasTable: false, lang: 'en' }
1009  const t = strings(rs.lang)
1010  const tabs = tabsFor(rs.roles)
1011  // A tab that no longer exists (source removed from CLAUDE.md) gives way to the Recap.
1012  const saved = migrateTab(tab)
1013  const active = saved !== null && tabs.includes(saved) ? saved : 'recap'
1014  // Prompts, session and face are only needed by the Recap: the other tabs do not redraw on every prompt.
1015  const [prompts, session, face] = active === 'recap' ? await Promise.all([read($, promptsAtom), read($, sessionAtom), read($, recapViewAtom)]) : [[], null, 'auto' as const]
1016  const loops = rs.roles['Future work']
1017  const todoName = loops === undefined ? t.tabs.todo : todoLabel(loops, t)
1018  const cls = rd.path === null || srcs === null ? undefined : classify(resolve(rs.root, rd.path), rs, t)
1019  const body = active === 'files' ? drawFiles(el, {
1020    t, tree: tr, query: queryText(tr), git: gitView ?? null, numstat: numstat ?? null, sizes: sizes ?? {}, written: written ?? {}, nowMs,
1021    press: id => { void pressRow($, id) },
1022    copy: id => { void copyPath($, id) },
1023    openInApp: id => { void openInFiles($, id) },
1024    typeQuery: q => { typeQuery(filesDeps($), q) },
1025    search: q => { void searchFlow(filesDeps($), q) },
1026    toggleHidden: () => { void toggleTree($, 'hidden') },
1027    toggleSizes: () => { void toggleTree($, 'sizes') },
1028    collapseAll: () => { void collapseAll($) },
1029    unselect: () => { void update($, treeAtom, t => ({ ...asTree(t), selected: '' })).catch(() => {}) },
1030    op: fileOp ?? null,
1031    moveText: fileOp?.kind === 'move' ? moveText(fileOp) : '',
1032    ops: selOps(rs, srcs !== null, tr.selected) ? {
1033      start: kind => { void startOp($, kind) },
1034      duplicate: () => { void duplicateFile($) },
1035      confirmTrash: () => { void trashFile($) },
1036      moveInput: typeMove,
1037      moveSubmit: text => { void moveFile($, text) },
1038      cancel: () => { resetMove(); void update($, fileOpAtom, () => null).catch(() => {}) },
1039    } : null,
1040  })
1041    : active === 'reading' ? drawReader(el, {
1042      reading: rd, t, root: rs.root, nowMs,
1043      role: cls?.role,
1044      // Without sources, outside the vault (classify null) or protected (except the Future work file) the file cannot
1045      // be edited in the panel.
1046      editBlocked: editBlock(rd, !isEditable(cls, t), t),
1047      writtenAt: rd.path === null ? undefined : (written ?? {})[rd.path],
1048      draft: shownDraft !== null && shownDraft.path === rd.path ? shownDraft : null,
1049      pending: shownDraft !== null && shownDraft.pendingOpen === rd.path && shownDraft.path !== rd.path ? basename(shownDraft.path) : null,
1050      saveDraft: () => { void saveDraft(readingDeps($)) },
1051      cancelDraft: () => { void cancelDraft(readingDeps($)) },
1052      conflictAct: a => { void conflictAct(readingDeps($), a) },
1053      pendingAct: a => { void pendingAct(readingDeps($), a) },
1054      strayDraft: rd.path === null && shownDraft !== null && isDirty(shownDraft) ? basename(shownDraft.path) : null,
1055      toDraft: () => { void pendingAct(readingDeps($), 'back') },
1056      back: () => { void goBack(readingDeps($)) },
1057      folder: rel => { void reveal($, rel, true) },
1058      setMode: m => { void (m === 'edit' ? startEdit($) : setReaderMode(readingDeps($), m)) },
1059      link: href => { void pressLink(readingDeps($), href) },
1060      openInApp: () => { if (rd.path !== null) void openInFiles($, rd.path) },
1061    })
1062    : active === 'diary' ? drawDiary(el, {
1063      diary, t, root: rs.root, nowMs,
1064      show: day => { void loadDiary($, day) },
1065      link: href => { void tabLink($, href) },
1066      read: path => { void openFromRecap($, path) },
1067    })
1068    : active === 'todo' ? drawTodo(el, {
1069      todo, t, label: todoName, root: rs.root, entry: todoEntry(),
1070      toggle: it => { void writeTodo(todoDeps($), text => toggleItem(text, it)) },
1071      confirm: key => { void confirmTodo(todoDeps($), key) },
1072      remove: it => { void removeTodo(todoDeps($), it) },
1073      link: href => { void tabLink($, href) },
1074      startAdd: head => { void openAdd(todoDeps($), head) },
1075      type: typeTodo,
1076      submit: (head, text) => { void addTodo(todoDeps($), head, text) },
1077      cancelAdd: () => { void cancelAdd(todoDeps($)) },
1078      edit: () => { void editTodo($) },
1079      read: () => { if (loops !== undefined) void openFromRecap($, loops) },
1080    })
1081    : active === 'memories' ? drawMemories(el, {
1082      memories, t, columns,
1083      open: file => { void update($, memoriesAtom, m => ({ ...asMemories(m), open: file, back: [] })).catch(() => {}) },
1084      follow: file => { void update($, memoriesAtom, m0 => { const m = asMemories(m0); return { ...m, open: file, back: m.open === null ? m.back : [...m.back, m.open] } }).catch(() => {}) },
1085      back: () => { void update($, memoriesAtom, m0 => { const m = asMemories(m0); return { ...m, open: m.back[m.back.length - 1] ?? null, back: m.back.slice(0, -1) } }).catch(() => {}) },
1086      toggleGroup: g => { void update($, memoriesAtom, m0 => { const m = asMemories(m0); return { ...m, expanded: m.expanded.includes(g) ? m.expanded.filter(x => x !== g) : [...m.expanded, g] } }).catch(() => {}) },
1087      // In Reading, read-only: the memories folder is protected.
1088      read: file => { if (memories.dir !== null) void openFromRecap($, `${memories.dir}/${file}`) },
1089      // Like the bubble buttons: prepares the prompt, the user writes the rest.
1090      ask: m => { void send($, t.memories.askPrompt(m.name)) },
1091    })
1092    : drawRecap(el, {
1093      snapshot, t, feed: feed ?? [], prompts: prompts ?? [], session: session ?? null, face: face ?? 'auto', written: written ?? {}, runs: runs ?? {}, ends: ends ?? [], working: working ?? false, turnAt: turnAt ?? null, roles: rs.roles, nowMs, columns,
1094      openFile: rel => { void openFromRecap($, rel) },
1095      openFolder: rel => { void reveal($, rel, true) },
1096      openTodo: () => { if (tabs.includes('todo')) void setTab($, 'todo') },
1097      openDiary: day => { void openDiary($, day) },
1098      logOpen: logOpen ?? false,
1099      toggleLog: () => { void update($, logOpenAtom, x => !(x ?? false)).catch(() => {}) },
1100      showOpen: () => { void update($, recapViewAtom, () => 'open' as const).catch(() => {}) },
1101      showWork: () => { void update($, recapViewAtom, () => 'auto' as const).catch(() => {}) },
1102      // A slash command cannot be submitted by plugins: the button prepares the text, Enter sends it.
1103      // An existing prompt draft is never overwritten: better a notice.
1104      send: text => { void send($, text) },
1105    })
1106  return drawPane(el, {
1107    columns,
1108    t,
1109    header: {
1110      mascot: shaped(IDLE, mascot), figure: figure ?? TERMINAL, t, working: working ?? false, turnAt: turnAt ?? null, nowMs,
1111      bubble: visible(shaped(NO_TALK, talk), nowMs),
1112      bubbleAct: b => { void bubbleAct($, b) },
1113      bubbleClose: b => { void talkStep($, t => dismiss(t, b.id)) },
1114    },
1115    tabs,
1116    tab: active,
1117    setTab: t => { void setTab($, t) },
1118    todoLabel: todoName,
1119    hasDraft: isDirty(shownDraft),
1120    body,
1121  })
1122}
1123
1124export const register: Register = on => {
1125  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
1126    const wasHeld = paneHeld
1127    const r = await next(e)
1128    if (e.origin.kind === 'person' && r.deny === undefined) paneClicks.focus(e.element, wasHeld, await $.clock.now())
1129    void checkTodoInView($)
1130    return r
1131  }).catch(($, e, next) => next(e))
1132  on('ui.press', { requestId: PANE }, async ($, e, next) => {
1133    if (paneClicks.isEcho(e.element, await $.clock.now())) return { element: e.element }
1134    return next(e)
1135  }).catch(($, e, next) => next(e))
1136  on('prompt.submit', async ($, e, next) => {
1137    const turn = await update($, turnAtom, t => t + 1)
1138    await notePrompt($, turn, e.text)
1139    await talkStep($, t => onPrompt(t, e.text))
1140    let context = e.context
1141    try {
1142      // First request of the session: the panel opens at the side, once only. It answers a user input, so the engine
1143      // places it at any width; if closed by hand it stays closed.
1144      if (!(await read($, openedAtom))) {
1145        await update($, openedAtom, () => true)
1146        void openPane($).catch(() => {})
1147      }
1148      const sel = (await read($, treeAtom))?.selected ?? ''
1149      if (sel !== '') {
1150        const s = await getSources($)
1151        if (await $.fs.exists(abs(s, sel))) context = [...(e.context ?? []), SELECTED + abs(s, sel)]
1152      }
1153    } catch {}
1154    return next(context === e.context ? e : { ...e, context })
1155  }).catch(($, e, next) => next(e))
1156
1157  on('tool.call', async ($, e, next) => {
1158    const ev = e as unknown as ToolEvent
1159    let cwd = ''
1160    let existed: boolean | undefined
1161    try {
1162      cwd = await $.session.cwd()
1163      const target = typeof ev.file_path === 'string' ? ev.file_path : undefined
1164      if (ev.tool === 'Write' && target) existed = await $.fs.exists(resolve(cwd, target))
1165    } catch {}
1166
1167    const ran = await next(e)
1168
1169    try {
1170      // A failing Bash keeps its activities: grep with no match, diff with exit 1, a late step after `sed -i`.
1171      if (ran.deny !== undefined || (ran.isError === true && ev.tool !== 'Bash')) return ran
1172      // Tests passed or commit succeeded (exit 0): it celebrates at the end of the turn, even outside the vault.
1173      if (ev.tool === 'Bash' && ran.isError !== true && typeof ev.command === 'string' && WIN.test(ev.command)) await mascotStep($, { kind: 'win' })
1174      const [s, turn, at] = await Promise.all([getSources($), read($, turnAtom), $.clock.now()])
1175      let agent: string | undefined
1176      if (ev.agentId) agent = (await $.agent.list()).find(x => x.id === ev.agentId)?.type ?? 'subagent'
1177      const t = strings(s.lang)
1178      const acts = toActivities(ev, { sources: s, t, cwd: cwd || s.root, turn, at, existed, agent })
1179      // The mascot reads, writes or thinks (a call with no vault file, like a web search).
1180      await mascotStep($, { kind: 'tool', verb: acts.find(a => WRITES.has(a.verb))?.verb ?? acts[0]?.verb ?? 'shell' })
1181      if (acts.length === 0) return ran
1182      const feed = await update($, feedAtom, f => acts.reduce(pushActivity, f))
1183      $.ui.status(statusText(feed, turn))
1184      const writes = acts.filter(a => WRITES.has(a.verb))
1185      if (writes.length > 0 && ev.agentId === undefined) await talkStep($, talk => noteWrites(talk, writes, t))
1186      if (writes.length > 0) {
1187        await update($, writtenAtom, w => addWritten(w ?? {}, writes))
1188        // A handoff or a diary note that was written goes into the session record. In its own try: if it throws, the
1189        // rest (index, folders, Reading, settle) still runs.
1190        try {
1191          const rec = await read($, sessionAtom)
1192          if (rec != null && noteRecordWrites(rec, writes, at, s.roles) !== rec) await update($, sessionAtom, x => (x == null ? null : noteRecordWrites(x, writes, at, s.roles)))
1193        } catch {}
1194        dropIndex()
1195        // The touched folders (and the ones containing them) are measured again at the end of the turn; meanwhile the
1196        // previous value stays.
1197        markStale(writes.flatMap(a => (a.from !== undefined ? [a.path, a.from] : [a.path])))
1198        // Reading and the draft follow the written files at once (readingFlow.followWrites); tree, git, tabs and
1199        // Recap when the writes settle (settle.ts).
1200        const following = followWrites(readingDeps($), writes)
hooks/feed.ts 68 lines
1import type { Activity, Touched } from '../types'
2import { basename, hhmm } from './format'
3import { GLYPH, WRITES } from './theme'
4import { PROTECTED_ROLES, roleLabel } from './sources'
5import type { Strings } from './strings'
6
7export const MAX_FEED = 200
8
9const coalesces = (x: Activity, y: Activity): boolean =>
10  (y.verb === 'read' || y.verb === 'search') &&
11  x.verb === y.verb && x.path === y.path && x.turn === y.turn &&
12  x.agent === y.agent && x.detail === y.detail
13
14export function pushActivity(feed: readonly Activity[], a: Activity): Activity[] {
15  const last = feed[feed.length - 1]
16  if (last && coalesces(last, a)) {
17    return [...feed.slice(0, -1), { ...last, at: a.at, count: last.count + a.count }]
18  }
19  return [...feed, a].slice(-MAX_FEED)
20}
21
22export function statusText(feed: readonly Activity[], turn: number): string | undefined {
23  const lastWrite = [...feed].reverse().find(x => x.turn === turn && WRITES.has(x.verb))
24  const pick = lastWrite ?? feed[feed.length - 1]
25  if (!pick) return undefined
26  const d = pick.detail ?? ''
27  // At the root the basename would be `.`: for shell and search we show what was run or searched.
28  const what = pick.verb === 'shell' ? (d.length > 30 ? `${d.slice(0, 29)}…` : d)
29    : pick.verb === 'search' && pick.path === '.' && d ? `"${d}"`
30    : basename(pick.path)
31  return `◆ Jarvis · ${GLYPH[pick.verb]} ${what} · ${hhmm(pick.at)}`
32}
33
34// Labels of the protected sources, as the feed writes them (in the catalog's language); the profile and the core included.
35const protectedLabels = (t: Strings): ReadonlySet<string> => new Set([t.log.profile, t.log.core, ...PROTECTED_ROLES.map(r => roleLabel(r, t))])
36
37// The second-brain sources written in this session: a move counts for the source it came from too.
38export function addTouched(list: readonly Touched[], acts: readonly Activity[], t: Strings): Touched[] {
39  const out = list.map(x => ({ ...x }))
40  const isProtectedLabel = protectedLabels(t)
41  for (const a of acts) {
42    if (!WRITES.has(a.verb)) continue
43    for (const role of [a.role, a.fromRole]) {
44      if (role === undefined) continue
45      const hit = out.find(x => x.role === role)
46      if (hit) hit.count += 1
47      else out.push({ role, count: 1, isProtected: isProtectedLabel.has(role) })
48    }
49  }
50  return out
51}
52
53// Files written by Jarvis in this session with the time of the last write: a deleted file drops out, a moved
54// one changes name.
55export function addWritten(w: Readonly<Record<string, number>>, acts: readonly Activity[]): Record<string, number> {
56  const out: Record<string, number> = { ...w }
57  for (const a of acts) {
58    if (!WRITES.has(a.verb) || a.path === '.') continue
59    if (a.verb === 'delete') {
60      delete out[a.path]
61      continue
62    }
63    if (a.verb === 'move' && a.from !== undefined) delete out[a.from]
64    out[a.path] = a.at
65  }
66  return out
67}
68
hooks/tree.ts 100 lines
1// Lazy tree of the vault. The pure parts come from filetree (MIT, Kurt Buhler; see LICENSE-filetree), hooks/tree.ts,
2// simplified: ids relative to the root, no themes or Windows drives.
3import type { Tree, TreeNode } from '../types'
4import { basename, daysBetween, hhmm } from './format'
5import type { DateWords } from './strings'
6
7export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; isLink: boolean; size?: number }
8export type Row = { node: TreeNode; depth: number; open: boolean }
9
10const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
11
12export function emptyTree(): Tree {
13  return { nodes: [], loaded: [], expanded: [], broken: [], cursor: '', selected: '', query: '', hits: [], showHidden: false, showSizes: false, error: null }
14}
15
16export const joinRel = (dir: string, name: string): string => (dir === '' ? name : `${dir}/${name}`)
17export const parentOf = (id: string): string => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : '')
18// The folders that contain `id`, from the outermost: 'a/b/c.md' → ['a', 'a/b'].
19export function ancestors(id: string): string[] {
20  const out: string[] = []
21  for (let d = parentOf(id); d !== ''; d = parentOf(d)) out.unshift(d)
22  return out
23}
24export const isMarkdown = (path: string): boolean => /\.md$/i.test(path)
25// Read as text in Reading: everything except images, PDFs, archives, Office documents, audio and video, fonts,
26// databases and model weights, compiled binaries. It is a denylist, so any extension not on the list opens in Reading.
27const BINARY = /\.(png|jpe?g|gif|webp|bmp|ico|tiff?|heic|heif|avif|svg|psd|ai|sketch|fig|pdf|zip|gz|tgz|bz2|xz|7z|rar|tar|zst|dmg|iso|pkg|docx?|xlsx?|pptx?|odt|ods|odp|key|numbers|pages|mp3|wav|m4a|aac|flac|ogg|opus|mp4|mov|avi|mkv|webm|m4v|ttf|otf|woff2?|eot|db|sqlite3?|parquet|feather|arrow|pkl|pickle|npy|npz|pt|pth|bin|safetensors|onnx|gguf|h5|ckpt|exe|dll|so|dylib|o|a|class|jar|wasm|pyc)$/i
28export const isTextFile = (path: string): boolean => !BINARY.test(path) && !/(^|\/)\.DS_Store$/.test(path)
29// `path` is under `dir`; the root '' contains everything.
30export const under = (dir: string, path: string): boolean => (dir === '' ? path !== '' : path.startsWith(`${dir}/`))
31
32export function toNodes(dir: string, list: readonly Entry[]): TreeNode[] {
33  return list
34    .filter(x => x.kind !== 'other' || x.isLink)
35    .map((x): TreeNode => ({ id: joinRel(dir, x.name), parent: dir, name: x.name, kind: x.kind === 'dir' ? 'dir' : 'file', mtimeMs: x.mtimeMs, size: x.kind === 'file' ? x.size ?? 0 : 0, isLink: x.isLink }))
36    .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
37}
38
39// Replaces the children of `dir`; children that are gone leave with their descendants, also from the expanded and loaded lists.
40export function withChildren(t: Tree, dir: string, list: readonly Entry[]): Tree {
41  const kids = toNodes(dir, list)
42  const ids = new Set(kids.map(k => k.id))
43  const gone = t.nodes.filter(n => n.parent === dir && !ids.has(n.id)).map(n => n.id)
44  const isGone = (id: string): boolean => gone.some(g => id === g || under(g, id))
45  return {
46    ...t,
47    nodes: [...t.nodes.filter(n => n.parent !== dir && !isGone(n.id)), ...kids],
48    loaded: [...t.loaded.filter(d => d !== dir && !isGone(d)), dir],
49    expanded: t.expanded.filter(d => !isGone(d)),
50    broken: t.broken.filter(d => d !== dir),
51  }
52}
53
54export function visibleRows(t: Tree, ignored: ReadonlySet<string>): Row[] {
55  const kids = new Map<string, TreeNode[]>()
56  for (const n of t.nodes) {
57    if (!t.showHidden && (n.name.startsWith('.') || ignored.has(n.id))) continue
58    const list = kids.get(n.parent)
59    if (list) list.push(n)
60    else kids.set(n.parent, [n])
61  }
62  const open = new Set(t.expanded)
63  const rows: Row[] = []
64  const walk = (dir: string, depth: number): void => {
65    for (const n of kids.get(dir) ?? []) {
66      const isOpen = n.kind === 'dir' && open.has(n.id)
67      rows.push({ node: n, depth, open: isOpen })
68      if (isOpen) walk(n.id, depth + 1)
69    }
70  }
71  walk('', 0)
72  return rows
73}
74
75export function toggleExpanded(t: Tree, id: string): Tree {
76  const isOpen = t.expanded.includes(id)
77  return { ...t, cursor: id, expanded: isOpen ? t.expanded.filter(x => x !== id) : [...t.expanded, id] }
78}
79
80export function dirCounts(dir: string, written: readonly string[], changed: readonly string[]): { written: number; changed: number } {
81  return { written: written.filter(p => under(dir, p)).length, changed: changed.filter(p => under(dir, p)).length }
82}
83
84// The age of a file for the Files column and the meta line: the time today, then days, then months.
85export function ageLabel(mtimeMs: number, nowMs: number, dates: DateWords): string {
86  if (mtimeMs <= 0) return ''
87  const days = daysBetween(mtimeMs, nowMs)
88  if (days <= 0) return hhmm(mtimeMs)
89  return days < 60 ? dates.shortDays(days) : dates.shortMonths(Math.floor(days / 30))
90}
91
92// Search by file name over the whole vault; paths in hidden folders stay out until they are shown.
93export function searchHits(paths: readonly string[], query: string, limit: number, showHidden: boolean): string[] {
94  const q = query.trim().toLowerCase()
95  if (q === '') return []
96  return paths
97    .filter(p => (showHidden || !p.split('/').some(part => part.startsWith('.'))) && basename(p).toLowerCase().includes(q))
98    .slice(0, limit)
99}
100
hooks/format.ts 88 lines
1import type { DateWords } from './strings'
2
3const pad = (n: number): string => String(n).padStart(2, '0')
4
5// Cut with an ellipsis at the n-th position, on code points (an emoji is not split).
6export function clip(s: string, n: number): string {
7  const chars = [...s]
8  return chars.length > n ? `${chars.slice(0, n - 1).join('')}…` : s
9}
10
11// Among the control characters the engine draws only tab and newline: a Markdown or Code with a \r (a file
12// written on Windows) made the whole pane get rejected. The \r becomes a line break, the others become �.
13export const drawable = (s: string): string => s.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '\uFFFD')
14// A string from a file or a file name that goes into a Text, a Button or an Svg: on one line, with no control
15// characters. A name like macOS's `Icon\r` made the whole pane get rejected.
16export const oneLine = (s: string): string => drawable(s).replace(/[\n\t]/g, ' ')
17
18// A text from a file that goes into a button or block key: the engine rejects control characters in a key (a
19// Markdown with `\x1b` in the text of an entry made the whole pane get rejected), but `oneLine` merges them
20// into a single � and two different texts would get the same key: a click would go to the wrong row. Here
21// every control (tab and newline included) becomes the visible escape `\u001b` and the backslash is doubled:
22// no control in the result and two different texts never give the same key.
23export const keyText = (s: string): string =>
24  s.replace(/[\\\u0000-\u001f\u007f-\u009f]/g, c => (c === '\\' ? '\\\\' : `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`))
25
26// The key of a text in a list: the text (`keyText`), and from the second copy on the number after the two
27// characters `\n` (backslash and n). After `keyText` a backslash is always followed by another backslash or by
28// `u`, so that mark never appears in a text key and two text-copy pairs are never confused. A click presses
29// the entry that was seen, even if another one is added above it.
30export function copyKeys(): (text: string) => string {
31  const seen = new Map<string, number>()
32  return text => {
33    const base = keyText(text)
34    const n = (seen.get(base) ?? 0) + 1
35    seen.set(base, n)
36    return n === 1 ? base : `${base}\\n${n}`
37  }
38}
39
40// The message of an error, whatever was thrown.
41export const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
42export const basename = (p: string): string => p.slice(p.lastIndexOf('/') + 1)
43export const dirname = (p: string): string => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')
44export const hhmm = (ms: number): string => { const d = new Date(ms); return `${pad(d.getHours())}:${pad(d.getMinutes())}` }
45// The day and month words come from the language's catalog (strings.*.ts), never from Intl: the result is the same on every machine.
46export const dayLabel = (ms: number, w: DateWords): string => { const d = new Date(ms); return `${w.days[d.getDay()]} ${d.getDate()} ${w.months[d.getMonth()]}` }
47// "Sunday 11 October" or "Domenica 11 ottobre": the weekday and the full date.
48export const longDay = (ms: number, w: DateWords): string => { const d = new Date(ms); return `${w.daysLong[d.getDay()]} ${d.getDate()} ${w.monthsLong[d.getMonth()]}` }
49export const isoDay = (ms: number): string => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
50export const shortDay = (iso: string, w: DateWords): string => { const [, m = 0, d] = iso.split('-').map(Number); return `${d} ${w.months[m - 1]}` }
51
52export function daysBetween(fromMs: number, toMs: number): number {
53  const a = new Date(fromMs); const b = new Date(toMs)
54  const start = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()
55  const end = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
56  return Math.round((end - start) / 86_400_000)
57}
58
59// How long something lasts: "45s", "3m", "2h10".
60export function duration(ms: number): string {
61  if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s`
62  const min = Math.floor(ms / 60_000)
63  return min < 60 ? `${min}m` : `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
64}
65
66export const weekday = (iso: string, w: DateWords): string => { const [y = 0, m = 1, d = 1] = iso.split('-').map(Number); return w.days[new Date(y, m - 1, d).getDay()] ?? '' }
67
68// How long a turn lasted, with the seconds: "45s", "3m10", "4h48".
69export function span(ms: number): string {
70  const s = Math.max(0, Math.floor(ms / 1000))
71  if (s < 60) return `${s}s`
72  const min = Math.floor(s / 60)
73  return min < 60 ? `${min}m${pad(s % 60)}` : `${Math.floor(min / 60)}h${pad(min % 60)}`
74}
75
76// The day of a time relative to today: "today", "yesterday", the weekday within six days ("Mon 12"),
77// then the short date ("8 Oct"); in Italian "oggi", "ieri", "lun 12", "8 ott".
78export function relDay(ms: number, nowMs: number, w: DateWords): string {
79  const days = daysBetween(ms, nowMs)
80  if (days <= 0) return w.today
81  if (days === 1) return w.yesterday
82  const iso = isoDay(ms)
83  return days <= 6 ? `${weekday(iso, w)} ${new Date(ms).getDate()}` : shortDay(iso, w)
84}
85
86// The local midnight of an ISO day.
87export const dayMs = (iso: string): number => { const [y = 0, m = 1, d = 1] = iso.split('-').map(Number); return new Date(y, m - 1, d).getTime() }
88
hooks/git.ts 87 lines
1// Read-only git state of the vault. The parsing comes from filetree (MIT, Kurt Buhler; see LICENSE-filetree), simplified:
2// status, ls-files, log, diff and numstat, with paths relative to the repo root.
3import type { DiffView, GitView, Numstat } from '../types'
4
5export type Change = 'new' | 'mod' | 'del'
6export type DiffSource = 'untracked' | 'uncommitted' | 'committed'
7
8// Beyond this length the diff is cut: a drawing carries at most 100 000 characters.
9export const MAX_DIFF_CHARS = 80_000
10
11// `git status --porcelain=v1 -z --ignored=traditional --untracked-files=normal` from the vault root.
12export function parseStatus(stdout: string): GitView {
13  const changed: Record<string, Change> = {}
14  const ignored: string[] = []
15  const parts = stdout.split('\0')
16  for (let i = 0; i < parts.length; i++) {
17    const rec = parts[i] ?? ''
18    if (rec.length < 4 || rec.startsWith('## ')) continue
19    const xy = rec.slice(0, 2)
20    const path = rec.slice(3).replace(/\/$/, '')
21    // A rename or copy carries the original path in the next record.
22    if (xy[0] === 'R' || xy[0] === 'C') i++
23    if (xy === '!!') {
24      ignored.push(path)
25      continue
26    }
27    changed[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') ? 'del' : 'mod'
28  }
29  return { changed, ignored }
30}
31
32// `git ls-files -co --exclude-standard -z`: a truncated output loses the last path, which may be cut in half.
33export function parseLsFiles(stdout: string, isTruncated: boolean): string[] {
34  const paths = stdout.split('\0').filter(Boolean)
35  if (isTruncated) paths.pop()
36  return paths
37}
38
39// The status of the file alone decides where the diff comes from: empty (or ignored) means the last commit.
40export function diffSource(statusOfFile: string): DiffSource {
41  const rec = statusOfFile.split('\0')[0] ?? ''
42  if (rec.startsWith('??')) return 'untracked'
43  return rec.length >= 4 && !rec.startsWith('!!') ? 'uncommitted' : 'committed'
44}
45
46// `git log -1 --format=%H%x00%ct%x00%s -- <file>`; empty if the file has no history.
47export function parseLogHead(stdout: string): { hash: string; at: number; subject: string } | null {
48  const [hash = '', ct = '', subject = ''] = stdout.trim().split('\0')
49  if (!/^[0-9a-f]{7,64}$/.test(hash)) return null
50  return { hash, at: Number(ct) * 1000, subject }
51}
52
53// A diff cut in the middle of a hunk is not drawn: it is cut before the hunk that overflows.
54export function clipDiff(text: string, max: number): { text: string; isCut: boolean } {
55  if (text.length <= max) return { text, isCut: false }
56  // The `\n` of the cut stays in the text: the cut falls at max - 1 so that the result is within max.
57  const cut = text.lastIndexOf('\n@@', max - 1)
58  return { text: cut > 0 ? text.slice(0, cut + 1) : '', isCut: true }
59}
60
61export function toDiffView(source: DiffSource, diffText: string | null, head: { at: number; subject: string } | null): DiffView {
62  if (source === 'untracked') return { kind: 'untracked' }
63  if (diffText === null) return { kind: 'error' }
64  const { text, isCut } = clipDiff(diffText, MAX_DIFF_CHARS)
65  if (source === 'uncommitted') return { kind: 'uncommitted', text, isCut }
66  if (head === null) return { kind: 'none' }
67  return { kind: 'commit', text, isCut, at: head.at, subject: head.subject }
68}
69
70// `git diff --numstat -z HEAD`: "add\tdel\tpath" for each file; a rename has an empty path and carries source
71// and destination in the next two records; a binary has "-" and counts zero.
72export function parseNumstat(stdout: string): Numstat {
73  const out: Numstat = {}
74  const parts = stdout.split('\0')
75  for (let i = 0; i < parts.length; i++) {
76    const m = (parts[i] ?? '').match(/^(-|\d+)\t(-|\d+)\t(.*)$/)
77    if (!m) continue
78    let path = m[3] ?? ''
79    if (path === '') {
80      path = parts[i + 2] ?? ''
81      i += 2
82    }
83    if (path !== '') out[path] = { add: m[1] === '-' ? 0 : Number(m[1]), del: m[2] === '-' ? 0 : Number(m[2]) }
84  }
85  return out
86}
87
hooks/files.tsx 260 lines
1import type { Color, Elements, RenderElement } from 'claude-code'
2import type { FileOp, GitView, Numstat, Tree, TreeNode } from '../types'
3import { basename, dirname, hhmm, keyText, oneLine } from './format'
4import { fileIcon, type IconName } from './icons'
5import { formatSize } from './sizes'
6import { ageLabel, dirCounts, visibleRows } from './tree'
7import type { Strings } from './strings'
8import { card, dot, icon, pill, rule, type El } from './chrome'
9import { DIM, FADED, HOVER_BG, ORANGE, PILL_BG, SEG_ACTIVE, SELECTED_BG } from './theme'
10
11// Careful: `h` is the injected JSX factory, never use it as a variable name.
12// The Files tab: Box rows with hover, Svg icon, name as a flat Button, actions on mouse hover.
13type FilesEl = El & Pick<Elements['desktop'], 'Input'>
14
15// Beyond these rows the tree is cut: the rest is found with the filter.
16export const MAX_ROWS = 200
17
18// Pure view-model: register.tsx reads the atoms and passes values and callbacks.
19export type FilesView = {
20  tree: Tree
21  // The catalog of the cockpit's language.
22  t: Strings
23  // The filter text as the user is typing it; the results follow tree.query.
24  query: string
25  git: GitView | null
26  numstat: Numstat | null
27  // Folder sizes from `du`: absent = being computed, null = failed.
28  sizes: Readonly<Record<string, number | null>>
29  written: Readonly<Record<string, number>>
30  nowMs: number
31  press: (id: string) => void
32  copy: (id: string) => void
33  openInApp: (id: string) => void
34  // A key in the filter: the search starts shortly after the last key.
35  typeQuery: (text: string) => void
36  // Enter in the filter: the search starts at once.
37  search: (query: string) => void
38  toggleHidden: () => void
39  toggleSizes: () => void
40  collapseAll: () => void
41  unselect: () => void
42  // Trash or move in progress on the selected file.
43  op: FileOp | null
44  // The text of the Move field as the user is typing it.
45  moveText: string
46  // Actions on the selected file; null for a protected file or one outside the vault (then the footer has no actions).
47  ops: FileOps | null
48}
49
50export type FileOps = {
51  start: (kind: FileOp['kind']) => void
52  duplicate: () => void
53  confirmTrash: () => void
54  // A key in the Move field: it only updates the text, with no redraw.
55  moveInput: (text: string) => void
56  moveSubmit: (text: string) => void
57  cancel: () => void
58}
59
60export type FileRow = {
61  id: string
62  name: string
63  // In a search: the result's folder, in gray before the name.
64  dir: string
65  depth: number
66  kind: 'dir' | 'file'
67  isOpen: boolean
68  isBroken: boolean
69  icon: IconName
70  // File: 1 if written by Jarvis in the session; closed folder: how many written files are under it.
71  written: number
72  // Closed folder: modified, uncommitted files under it.
73  changed: number
74  add: number
75  del: number
76  // Size with Σ, otherwise age; '' if there is nothing to say.
77  right: string
78}
79
80function rightText(v: FilesView, node: TreeNode | undefined, kind: 'dir' | 'file', id: string): string {
81  if (kind === 'dir') {
82    if (!v.tree.showSizes) return ''
83    if (!(id in v.sizes)) return '…'
84    const n = v.sizes[id]
85    return n === null || n === undefined ? '—' : formatSize(n, v.t.files.decimalMark)
86  }
87  if (node === undefined) return ''
88  return v.tree.showSizes ? formatSize(node.size, v.t.files.decimalMark) : ageLabel(node.mtimeMs, v.nowMs, v.t.dates)
89}
90
91function toRow(v: FilesView, node: TreeNode | undefined, id: string, depth: number, isOpen: boolean, dir: string): FileRow {
92  const kind = node?.kind ?? 'file'
93  // The name as it is drawn: macOS's `Icon\r` made the whole pane get rejected.
94  const name = oneLine(node?.name ?? basename(id))
95  const isLink = node?.isLink ?? false
96  const ns = v.numstat?.[id]
97  const closedDir = kind === 'dir' && !isOpen
98  const c = closedDir ? dirCounts(id, Object.keys(v.written), Object.keys(v.git?.changed ?? {})) : { written: 0, changed: 0 }
99  return {
100    id, name, dir, depth, kind, isOpen,
101    isBroken: v.tree.broken.includes(id),
102    icon: fileIcon(name, kind, isOpen, isLink),
103    written: kind === 'file' ? (v.written[id] !== undefined ? 1 : 0) : c.written,
104    changed: c.changed,
105    add: ns?.add ?? 0,
106    del: ns?.del ?? 0,
107    right: rightText(v, node, kind, id),
108  }
109}
110
111// The rows to draw (at most MAX_ROWS) and how many are left out.
112export function fileRows(v: FilesView): { rows: FileRow[]; more: number } {
113  const t = v.tree
114  const byId = new Map(t.nodes.map(n => [n.id, n]))
115  const all: FileRow[] = t.query.trim() !== ''
116    ? t.hits.map(id => toRow(v, byId.get(id), id, 0, false, dirname(id)))
117    : visibleRows(t, new Set(v.git?.ignored ?? [])).map(r => toRow(v, r.node, r.node.id, r.depth, r.open, ''))
118  return { rows: all.slice(0, MAX_ROWS), more: Math.max(0, all.length - MAX_ROWS) }
119}
120
121function rowView(el: FilesEl, v: FilesView, r: FileRow): RenderElement {
122  const { Box, Text, Button } = el
123  const isActive = v.tree.cursor === r.id
124  return (
125    <Box key={`row:${keyText(r.id)}`} flexDirection="row" alignItems="center" gap={1} paddingLeft={r.depth * 2} backgroundColor={isActive ? SELECTED_BG : undefined} hover={{ backgroundColor: isActive ? SELECTED_BG : HOVER_BG }}>
126      <Box width={1} flexShrink={0}>
127        {r.kind === 'dir' ? <Button key={`t:${keyText(r.id)}`} plain dimColor label={r.isOpen ? '▾' : '▸'} onPress={() => v.press(r.id)} /> : <Text> </Text>}
128      </Box>
129      <Box flexShrink={0}>{icon(el, r.icon, v.t.files.icons[r.icon])}</Box>
130      <Box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
131        {r.dir !== '' && <Text color={FADED} wrap="truncate-start">{`${oneLine(r.dir)}/`}</Text>}
132        <Button key={`r:${keyText(r.id)}`} plain label={r.name} onPress={() => v.press(r.id)} />
133      </Box>
134      <Box flexDirection="row" gap={1} flexShrink={0} display="none" hover={{ display: 'flex' }}>
135        <Button key={`app:${keyText(r.id)}`} plain dimColor label={v.t.files.openInApp} onPress={() => v.openInApp(r.id)} />
136        <Button key={`cp:${keyText(r.id)}`} plain dimColor label={v.t.files.copyPath} onPress={() => v.copy(r.id)} />
137      </Box>
138      <Box flexDirection="row" gap={1} flexShrink={0}>
139        {r.isBroken && <Text color="warning">⚠</Text>}
140        {r.written > 0 && (r.kind === 'dir' ? <Text color={ORANGE}>{`● ${r.written}`}</Text> : dot(el, ORANGE))}
141        {r.changed > 0 && pill(el, `M:${r.changed}`)}
142        {r.add > 0 && pill(el, `+${r.add}`, 'success')}
143        {r.del > 0 && pill(el, `−${r.del}`, 'error')}
144        {r.right !== '' && <Text color={DIM}>{r.right}</Text>}
145      </Box>
146    </Box>
147  )
148}
149
150// Flat toggle of the bar: faint gray background when it is on.
151function toggle(el: FilesEl, key: string, label: string, isOn: boolean, onPress: () => void): RenderElement {
152  const { Box, Button } = el
153  return (
154    <Box flexShrink={0} paddingX={1} backgroundColor={isOn ? SEG_ACTIVE : undefined}>
155      <Button key={key} plain label={label} onPress={onPress} />
156    </Box>
157  )
158}
159
160// "modified at 14:31" for today, "modified 3d ago" before that; nothing if unknown.
161function ago(mtimeMs: number, nowMs: number, t: Strings): string {
162  const label = ageLabel(mtimeMs, nowMs, t.dates)
163  if (label === '') return ''
164  return label.includes(':') ? t.files.modifiedAt(label) : t.files.modifiedAgo(label)
165}
166
167// Pill-shaped button of the footer: faint gray background, darker under the mouse.
168function chip(el: FilesEl, key: string, label: string, onPress: () => void, color?: Color): RenderElement {
169  const { Box, Text, Button } = el
170  return (
171    <Box key={`chip:${key}`} flexShrink={0} paddingX={1} backgroundColor={PILL_BG} hover={{ backgroundColor: SEG_ACTIVE }}>
172      <Button key={key} plain label={label} onPress={onPress}><Text color={color}>{label}</Text></Button>
173    </Box>
174  )
175}
176
177// Footer: a thin rule, then the selected file as a card. Icon and name with a × on the right; below, size,
178// age and who wrote it; then the pill actions (move, duplicate, trash). Trash asks for confirmation in their
179// place; move becomes a field with the path.
180function footer(el: FilesEl, v: FilesView): RenderElement {
181  const { Box, Text, Button, Input } = el
182  const sel = v.tree.selected
183  const t = v.t
184  if (sel === '') return <Text color={DIM}>{t.files.noSelection}</Text>
185  const ops = v.ops
186  const op = v.op?.path === sel ? v.op : null
187  const node = v.tree.nodes.find(n => n.id === sel)
188  const w = v.written[sel]
189  const selIcon = fileIcon(basename(sel), 'file', false, node?.isLink === true)
190  const meta = [oneLine(dirname(sel)), node === undefined ? '' : formatSize(node.size, t.files.decimalMark), w !== undefined ? t.files.writtenByJarvis(hhmm(w)) : ago(node?.mtimeMs ?? 0, v.nowMs, t)].filter(Boolean)
191  const actions = ops === null ? null
192    : op?.kind === 'trash' ? (
193      <Box flexDirection="row" alignItems="center" gap={1} flexWrap="wrap">
194        <Text color="warning">{t.files.trashAsk}</Text>
195        {chip(el, 'trash-yes', t.files.trashYes, ops.confirmTrash, 'error')}
196        {chip(el, 'trash-no', t.files.cancel, ops.cancel)}
197      </Box>
198    )
199    : op?.kind === 'move' ? (
200      <Box flexDirection="column">
201        <Box flexDirection="row" alignItems="center" gap={1}>
202          <Box flexGrow={1} flexShrink={1} minWidth={0}>
203            <Input key="move" label="→ " value={v.moveText} onInput={ops.moveInput} onSubmit={ops.moveSubmit} />
204          </Box>
205          {chip(el, 'op-cancel', t.files.cancel, ops.cancel)}
206        </Box>
207        <Text color={FADED} wrap="truncate-end">{t.files.moveHint}</Text>
208      </Box>
209    )
210    : (
211      <Box flexDirection="row" gap={1} flexWrap="wrap">
212        {chip(el, 'op-move', t.files.move, () => ops.start('move'))}
213        {chip(el, 'op-copy', t.files.duplicate, ops.duplicate)}
214        {chip(el, 'op-trash', t.files.trash, () => ops.start('trash'))}
215      </Box>
216    )
217  return (
218    <Box flexDirection="column">
219      <Box flexDirection="row">{rule(el)}</Box>
220      <Box flexDirection="row" alignItems="center" gap={1} marginTop={1}>
221        <Box flexShrink={0}>{icon(el, selIcon, t.files.icons[selIcon])}</Box>
222        <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden"><Text bold wrap="truncate-end">{oneLine(basename(sel))}</Text></Box>
223        <Box flexShrink={0}><Button key="unselect" plain dimColor label="×" onPress={() => v.unselect()} /></Box>
224      </Box>
225      {meta.length > 0 && <Box paddingLeft={3}><Text color={DIM} wrap="truncate-end">{meta.join(' · ')}</Text></Box>}
226      {actions !== null && <Box paddingLeft={3} marginTop={1}>{actions}</Box>}
227    </Box>
228  )
229}
230
231export function drawFiles(el: FilesEl, v: FilesView): RenderElement {
232  const { Box, Text, Input } = el
233  const t = v.tree
234  const words = v.t.files
235  const isSearching = t.query.trim() !== ''
236  const { rows, more } = fileRows(v)
237  const body = t.error !== null ? card(el, { title: words.unreadableTitle, tone: 'error' }, <Text color="error">{oneLine(words.unreadable(t.error))}</Text>)
238    : isSearching && t.hits.length === 0 ? <Box marginTop={1}><Text color={DIM}>{words.noMatch(t.query.trim())}</Text></Box>
239    : (
240      <Box flexDirection="column" marginTop={1}>
241        {rows.map(r => rowView(el, v, r))}
242        {more > 0 && <Text color={DIM}>{words.moreRows(more)}</Text>}
243      </Box>
244    )
245  return (
246    <Box flexDirection="column" paddingX={1} marginTop={1}>
247      <Box flexDirection="row" alignItems="center" gap={1}>
248        <Box flexGrow={1} flexShrink={1} minWidth={0}>
249          <Input key="q" label="⌕ " placeholder={words.filter} value={v.query} onInput={v.typeQuery} onSubmit={v.search} />
250        </Box>
251        {toggle(el, 'hidden', '.', t.showHidden, v.toggleHidden)}
252        {toggle(el, 'sizes', 'Σ', t.showSizes, v.toggleSizes)}
253        {toggle(el, 'collapse', '⊟', false, v.collapseAll)}
254      </Box>
255      {body}
256      <Box marginTop={1}>{footer(el, v)}</Box>
257    </Box>
258  )
259}
260
hooks/readingView.tsx 223 lines
1import type { Elements, RenderElement } from 'claude-code'
2import type { DiffView, Draft, Reading } from '../types'
3import { basename, clip, dayLabel, hhmm, oneLine } from './format'
4import { drawable, isBinaryText, isDirty, rewriteLinks, splitFrontmatter } from './reader'
5import { ageLabel, ancestors, isMarkdown } from './tree'
6import type { Strings } from './strings'
7import { card, pill, segmented, type El } from './chrome'
8import type { EditorProps } from './editor'
9import { DIM, FADED, TEXT } from './theme'
10
11// Careful: `h` is the injected JSX factory, never use it as a variable name.
12type ReaderEl = El & Pick<Elements['desktop'], 'Markdown' | 'Code' | 'Client'>
13
14// How long the meta line says "updated just now" after a Jarvis write.
15const FRESH_MS = 60_000
16// A frontmatter value longer than this is cut in the pill.
17const PILL_CHARS = 40
18// Fixed height of the editor Client: the 28 rows of the window plus two spare for the cursor row
19// that wraps.
20const EDITOR_ROWS = 30
21
22export type ReaderMode = 'text' | 'diff' | 'edit'
23export type ConflictAct = 'overwrite' | 'reload' | 'diff'
24export type PendingAct = 'save' | 'discard' | 'back'
25
26// Pure view-model: register.tsx reads the atoms and passes values and callbacks.
27export type ReaderView = {
28  reading: Reading
29  // The catalog of the cockpit's language.
30  t: Strings
31  root: string
32  role: string | undefined
33  // Why Edit is off (protected file, too long); null if it can be edited.
34  editBlocked: string | null
35  writtenAt: number | undefined
36  // The draft of the open file, if there is one.
37  draft: Draft | null
38  nowMs: number
39  // The name of the file with an unsaved draft, when Edit was asked for here; otherwise null.
40  pending: string | null
41  saveDraft: () => void
42  cancelDraft: () => void
43  conflictAct: (a: ConflictAct) => void
44  pendingAct: (a: PendingAct) => void
45  // No file open but a dirty draft elsewhere: the name of its file, to go back to it; otherwise null.
46  strayDraft: string | null
47  toDraft: () => void
48  back: () => void
49  folder: (rel: string) => void
50  setMode: (mode: ReaderMode) => void
51  link: (href: string) => void
52  openInApp: () => void
53}
54
55function metaLine(v: ReaderView): string {
56  const r = v.reading
57  const w = v.t.reading
58  const when = r.updatedAt !== null && v.nowMs - r.updatedAt < FRESH_MS ? w.updatedNow
59    : v.writtenAt !== undefined ? w.modifiedByJarvis(hhmm(v.writtenAt))
60    : (() => { const age = ageLabel(r.mtimeMs, v.nowMs, v.t.dates); return age === '' ? '' : age.includes(':') ? v.t.files.modifiedAt(age) : v.t.files.modifiedAgo(age) })()
61  return oneLine([when, v.role ?? ''].filter(Boolean).join(' · '))
62}
63
64function diffBody(el: ReaderEl, d: DiffView | null, t: Strings): RenderElement {
65  const { Box, Text, Code } = el
66  const w = t.reading.diff
67  if (d === null) return <Text color={DIM}>{w.loading}</Text>
68  if (d.kind === 'untracked') return <Text color={DIM}>{w.untracked}</Text>
69  if (d.kind === 'none') return <Text color={DIM}>{w.none}</Text>
70  if (d.kind === 'error') return <Text color="error">{w.error}</Text>
71  const head = d.kind === 'uncommitted' ? w.uncommitted : oneLine(w.lastCommit(`${dayLabel(d.at, t.dates)} ${hhmm(d.at)}`, d.subject))
72  return (
73    <Box flexDirection="column">
74      <Text color={DIM} wrap="truncate-end">{head}</Text>
75      {d.text.trim() === '' ? <Text color={DIM}>{w.noDifference}</Text> : <Code source={drawable(d.text)} format="diff" />}
76      {d.isCut && <Text color="warning">{w.cut}</Text>}
77    </Box>
78  )
79}
80
81// The file is not drawn here: the reason and the button that opens it in the Files pane.
82function notHere(el: ReaderEl, v: ReaderView, why: string): RenderElement {
83  const { Box, Text, Button } = el
84  return (
85    <Box flexDirection="column">
86      <Text color={DIM}>{why}</Text>
87      <Button key="reader-open-app" label={v.t.files.openInApp} onPress={() => v.openInApp()} />
88    </Box>
89  )
90}
91
92// A .md as Markdown (clickable internal links); code and other text files colored by the engine,
93// with line numbers.
94function textBody(el: ReaderEl, v: ReaderView, path: string): RenderElement {
95  const { Text, Markdown, Code } = el
96  const r = v.reading
97  const w = v.t.reading
98  if (r.error !== null) return <Text color="error">{oneLine(w.unreadable(r.error))}</Text>
99  if (r.isTooBig) return notHere(el, v, w.tooLong)
100  if (r.text === null) return <Text color={DIM}>{w.loading}</Text>
101  if (isBinaryText(r.text)) return notHere(el, v, w.notText)
102  if (!isMarkdown(path)) return <Code source={drawable(r.text)} path={path} startLine={1} />
103  const links = rewriteLinks(splitFrontmatter(r.text).body, path, v.root)
104  return <Markdown key="doc" text={links.text} pressableLinks={links.internal} onLinkPress={link => v.link(link.href)} />
105}
106
107// The file changed under the draft: choose whether to write it anyway, reload, or look at the diff.
108function conflictCard(el: ReaderEl, v: ReaderView, d: Draft): RenderElement {
109  const { Box, Text, Button, Code } = el
110  const w = v.t.reading
111  const title = d.conflict === 'jarvis' ? w.conflict.jarvis : w.conflict.disk
112  return card(el, { title, tone: 'warning' }, [
113    <Text color={DIM} wrap="wrap">{w.conflict.keep}</Text>,
114    <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
115      <Button key="conflict-overwrite" plain label={w.conflict.overwrite} onPress={() => v.conflictAct('overwrite')} />
116      <Button key="conflict-reload" plain dimColor label={w.conflict.reload} onPress={() => v.conflictAct('reload')} />
117      <Button key="conflict-diff" plain dimColor label={d.conflictDiff === null ? w.conflict.showDiff : w.conflict.hideDiff} onPress={() => v.conflictAct('diff')} />
118    </Box>,
119    d.conflictDiff !== null && (d.conflictDiff === '' ? <Text color={DIM}>{w.diff.noDifference}</Text> : <Code source={drawable(d.conflictDiff)} format="diff" />),
120    d.conflictDiff !== null && d.conflictDiffCut && <Text color={DIM}>{w.conflict.diffCut}</Text>,
121  ])
122}
123
124// Edit asked for on a file while there is an unsaved draft on another one.
125function pendingCard(el: ReaderEl, v: ReaderView, name: string): RenderElement {
126  const { Box, Text, Button } = el
127  const w = v.t.reading.pending
128  return card(el, { title: w.title(oneLine(name)), tone: 'warning' }, [
129    <Text color={DIM} wrap="wrap">{w.hint}</Text>,
130    <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
131      <Button key="pending-save" plain label={w.save} onPress={() => v.pendingAct('save')} />
132      <Button key="pending-discard" plain dimColor label={w.discard} onPress={() => v.pendingAct('discard')} />
133      <Button key="pending-back" plain dimColor label={w.back} onPress={() => v.pendingAct('back')} />
134    </Box>,
135  ])
136}
137
138// Edit: the editor (a Client with a fixed height and full width) and the draft bar.
139function editBody(el: ReaderEl, v: ReaderView, d: Draft): RenderElement {
140  const { Box, Text, Button, Client } = el
141  const dirty = isDirty(d)
142  const w = v.t.reading.edit
143  return (
144    <Box flexDirection="column">
145      {d.error !== null && card(el, { title: w.notSaved, tone: 'error' }, <Text color="error" wrap="wrap">{oneLine(d.error)}</Text>)}
146      {d.conflict !== null && conflictCard(el, v, d)}
147      <Client key="editor" module="./editor.tsx" props={{ text: d.text, row: d.row, col: d.col, gen: d.gen } satisfies EditorProps} width="100%" height={EDITOR_ROWS} />
148      <Box flexDirection="row" justifyContent="space-between" alignItems="center" marginTop={1}>
149        <Box flexShrink={1} minWidth={0} overflow="hidden">
150          <Text color={DIM} wrap="truncate-end">{dirty && <Text color="warning">● </Text>}{dirty ? w.unsaved : w.saved}</Text>
151        </Box>
152        <Box flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
153          <Button key="edit-cancel" plain dimColor label={d.confirmDiscard ? w.confirmDiscard : w.cancel} onPress={() => v.cancelDraft()} />
154          <Button key="edit-save" plain label={w.save} onPress={() => v.saveDraft()} />
155        </Box>
156      </Box>
157    </Box>
158  )
159}
160
161// Path: ← (link history), then each folder as a button that opens it in the Files tab.
162function crumbs(el: ReaderEl, v: ReaderView, path: string): RenderElement {
163  const { Box, Text, Button } = el
164  const dirs = ancestors(path)
165  return (
166    <Box flexDirection="row" alignItems="center" flexShrink={1} minWidth={0} overflow="hidden">
167      <Box flexShrink={0} marginRight={1}><Button key="back" plain label="←" onPress={() => v.back()} /></Box>
168      {dirs.map((d, i) => (
169        <Box flexDirection="row" flexShrink={1} minWidth={0}>
170          <Button key={`crumb-${i}`} plain dimColor label={oneLine(basename(d))} onPress={() => v.folder(d)} />
171          <Text color={FADED}>{' / '}</Text>
172        </Box>
173      ))}
174      <Text color={TEXT} bold wrap="truncate-end">{oneLine(basename(path))}</Text>
175    </Box>
176  )
177}
178
179export function drawReader(el: ReaderEl, v: ReaderView): RenderElement {
180  const { Box, Text, Button } = el
181  const r = v.reading
182  const w = v.t.reading
183  if (r.path === null) {
184    return (
185      <Box flexDirection="column" paddingX={1}>
186        {card(el, { title: w.noFile }, [
187          <Text color={DIM}>{w.pickFile}</Text>,
188          v.strayDraft !== null && (
189            <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
190              <Text color={DIM} wrap="truncate-end">{w.pending.title(oneLine(v.strayDraft))}</Text>
191              <Button key="reader-to-draft" plain label={w.pending.back} onPress={() => v.toDraft()} />
192            </Box>
193          ),
194        ])}
195      </Box>
196    )
197  }
198  // Frontmatter pills for Markdown only: in any other file the lines between --- are code.
199  const meta = r.text === null || !isMarkdown(r.path) ? [] : splitFrontmatter(r.text).meta
200  const modes: { id: ReaderMode; label: string }[] = [{ id: 'text', label: w.modes.text }, { id: 'diff', label: w.modes.diff }, ...(v.editBlocked === null ? [{ id: 'edit' as const, label: w.modes.edit }] : [])]
201  const body = r.mode === 'diff' ? diffBody(el, r.diff, v.t) : r.mode === 'edit' && v.draft !== null ? editBody(el, v, v.draft) : textBody(el, v, r.path)
202  return (
203    <Box flexDirection="column" paddingX={1} marginTop={1}>
204      {crumbs(el, v, r.path)}
205      <Box flexDirection="row" alignItems="center" justifyContent="space-between" marginTop={1}>
206        <Box flexDirection="row" alignItems="center" gap={1} flexShrink={1} minWidth={0}>
207          <Box flexShrink={0}>{segmented(el, m => `mode-${m}`, modes, r.mode, m => v.setMode(m))}</Box>
208          {v.editBlocked !== null && <Box flexShrink={1} minWidth={0} overflow="hidden"><Text color={DIM} wrap="truncate-end">{w.editOff(v.editBlocked)}</Text></Box>}
209        </Box>
210        <Box flexShrink={0} marginLeft={1}><Button key="open-app" plain label="↗" onPress={() => v.openInApp()} /></Box>
211      </Box>
212      {meta.length > 0 && (
213        <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
214          {meta.map(m => pill(el, `${m.key}: ${clip(oneLine(m.value), PILL_CHARS)}`))}
215        </Box>
216      )}
217      <Text color={DIM} wrap="truncate-end">{metaLine(v)}</Text>
218      {v.pending !== null && pendingCard(el, v, v.pending)}
219      <Box marginTop={1} flexDirection="column">{body}</Box>
220    </Box>
221  )
222}
223
hooks/reader.ts 142 lines
1import type { Draft, Reading } from '../types'
2import { drawable } from './format'
3import type { Strings } from './strings'
4import { isMarkdown } from './tree'
5
6export { drawable }
7
8// U+FFFD in the text read: the file is not UTF-8 and rewriting it would lose the bytes that cannot be read.
9export const isNotUtf8 = (text: string): boolean => text.includes('\uFFFD')
10
11export const EMPTY_READING: Reading = { path: null, back: [], mode: 'text', text: null, mtimeMs: 0, size: null, isTooBig: false, error: null, updatedAt: null, diff: null }
12
13// Beyond this length a file is not rendered: a drawing carries at most 100 000 characters.
14export const MAX_RENDER_CHARS = 80_000
15
16// The props of the editor Client (serialized text included) stay under 100 000 characters: beyond this margin
17// Edit turns off.
18export const EDIT_MAX_JSON = 90_000
19
20// The line ending of a text: '\r\n' if every line ends that way, '\n' if none does; null if they are mixed or
21// there is a lone \r. The editor works with `\n` and the draft puts the file's own ending back on save.
22export function eolOf(text: string): '\n' | '\r\n' | null {
23  const crlf = text.split('\r\n').length - 1
24  const lf = text.split('\n').length - 1
25  const cr = text.split('\r').length - 1
26  if (cr !== crlf) return null
27  if (crlf === 0) return '\n'
28  return crlf === lf ? '\r\n' : null
29}
30
31// Why an open file cannot be edited in the pane; null if it can. `isProtected` comes from classify: protected
32// sources, CLAUDE.md, excluded files or files outside the vault. Code and the other text files are read-only.
33export function editBlock(r: Reading, isProtected: boolean, t: Strings): string | null {
34  const why = t.reading.blocked
35  if (isProtected) return why.protected
36  if (r.path !== null && !isMarkdown(r.path)) return why.notMarkdown
37  if (r.isTooBig || (r.text !== null && JSON.stringify(r.text).length > EDIT_MAX_JSON)) return why.tooBig
38  if (r.text === null) return why.notRead
39  if (isNotUtf8(r.text)) return why.notUtf8
40  if (eolOf(r.text) === null) return why.mixedEol
41  return null
42}
43
44// A NUL does not belong in a text file: Reading does not draw it.
45export const isBinaryText = (s: string): boolean => s.includes('\u0000')
46
47// A draft with unsaved changes.
48export const isDirty = (d: Draft | null): boolean => d !== null && d.text !== d.base
49
50export type Doc = { meta: { key: string; value: string }[]; body: string }
51
52// Removes the quotes and reduces markdown links to their text.
53function clean(v: string): string {
54  return v.trim().replace(/^(["'])(.*)\1$/, '$2').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
55}
56
57// Simple YAML frontmatter: `key: value` and `- item` lists joined by commas; the rest is the body.
58export function splitFrontmatter(md: string): Doc {
59  const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
60  if (!m) return { meta: [], body: md }
61  const meta: { key: string; value: string }[] = []
62  for (const line of (m[1] ?? '').split(/\r?\n/)) {
63    const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/)
64    if (kv) {
65      meta.push({ key: kv[1] ?? '', value: clean(kv[2] ?? '') })
66      continue
67    }
68    const item = line.match(/^\s+-\s+(.*)$/)
69    const last = meta[meta.length - 1]
70    if (item && last) last.value = last.value ? `${last.value}, ${clean(item[1] ?? '')}` : clean(item[1] ?? '')
71  }
72  return { meta, body: md.slice(m[0].length) }
73}
74
75// Segments of `path` appended to `base`: skips '' and '.', goes up with '..'; null if it leaves the root.
76function walk(base: readonly string[], path: string): string[] | null {
77  const out = [...base]
78  for (const part of path.split('/')) {
79    if (part === '' || part === '.') continue
80    if (part === '..') {
81      if (out.length === 0) return null
82      out.pop()
83    } else out.push(part)
84  }
85  return out
86}
87
88// From a relative href (also %20 and #anchor) to a path relative to the root; null if it leaves the vault.
89export function resolveRel(fromRel: string, href: string, root: string): string | null {
90  let path: string
91  try {
92    path = decodeURIComponent(href.split('#')[0] ?? '')
93  } catch {
94    return null
95  }
96  if (path === '') return null
97  let base: string[]
98  if (path.startsWith('/')) {
99    if (!path.startsWith(`${root}/`)) return null
100    path = path.slice(root.length + 1)
101    base = []
102  } else base = fromRel.split('/').slice(0, -1)
103  const out = walk(base, path)
104  return out && out.length > 0 ? out.join('/') : null
105}
106
107// Parentheses are encoded too: they would close the markdown link.
108export function fileUrl(root: string, rel: string): string {
109  return `file://${encodeURI(`${root}/${rel}`).replace(/\(/g, '%28').replace(/\)/g, '%29')}`
110}
111
112const LINK = /(\]\()(<[^>]+>|[^)\s]+)((?:\s+"[^"]*")?\))/g
113
114// The engine's Markdown makes only https:, http: and file: clickable. Relative links become absolute file://
115// ones; `internal` are the rewritten hrefs, the ones the pane handles (max 256).
116export function rewriteLinks(body: string, fromRel: string, root: string): { text: string; internal: string[] } {
117  const internal: string[] = []
118  const text = body.replace(LINK, (all: string, open: string, raw: string, close: string) => {
119    const href = raw.startsWith('<') ? raw.slice(1, -1) : raw
120    if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('#')) return all
121    const rel = resolveRel(fromRel, href, root)
122    if (rel === null) return all
123    const url = fileUrl(root, rel)
124    if (internal.length < 256) internal.push(url)
125    return `${open}${url}${close}`
126  })
127  return { text: drawable(text), internal }
128}
129
130// From the pressed link to the relative path, if it is a vault file; accepts the raw or the already decoded href.
131// It is decoded only once (decodeURI: %2e becomes '.'), then the segments are normalized as in resolveRel.
132export function pressedRel(href: string, root: string): string | null {
133  if (!href.startsWith('file://')) return null
134  let abs = href.slice('file://'.length)
135  try {
136    abs = decodeURI(abs)
137  } catch {}
138  if (!abs.startsWith(`${root}/`)) return null
139  const out = walk([], abs.slice(root.length + 1))
140  return out && out.length > 0 ? out.join('/') : null
141}
142
hooks/editor.tsx 93 lines
1// Client module of the Reading tab's editor: it draws the raw Markdown and receives keys and clicks.
2// It runs in the app's drawing thread: no `$`, no setState in the drawing, no drawn value that depends on
3// surface.columns or surface.rows (only the click handler uses them).
4// Careful: `h` and `Fragment` belong to the environment, do not use them as names.
5import type { ClientModule } from 'claude-code'
6import { CURSOR_CLIP, GUTTER, LINE_CLIP, VIEW, fromText, pointAt, reduce, toText, type Ed } from './edit'
7
8// The props the mod passes: the draft text, the cursor and the generation of the text.
9export type EditorProps = { text: string; row: number; col: number; gen: number }
10// What the editor sends to the mod on every edit, move or click (`save`/`paste` for cmd+s and cmd+v).
11export type EditorPost = { gen: number; text: string; row: number; col: number; save?: true; paste?: true }
12type Local = Ed & { gen: number }
13
14// Same values as theme.ts (ACCENT, SELECTED_BG): this module imports only edit.ts.
15const ACCENT = '#13918b'
16const CURSOR_BG = '#8080802e'
17
18// The engine rejects control characters in a Text (except tab and newline): a line with a \x1b made the editor
19// unmount. A � is drawn in their place (same length, so the same cursor columns); the text of the draft, and
20// what gets saved, stays that of the file.
21// The character class repeats that of `drawable` (format.ts) and must be kept identical: it is repeated here
22// because this Client module imports only edit.ts. One code unit becomes a single �: the lengths and the
23// cursor columns do not change.
24const shown = (line: string): string => line.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '\uFFFD')
25
26const Editor: ClientModule<EditorProps, Local> = (props, surface) => {
27  const { Box, Text } = surface.elements
28  // The local state holds until the mod sends a new text (a different gen): then it starts again from the
29  // props, with no setState in the drawing.
30  const current = (): Local => {
31    const s = surface.state
32    return s !== undefined && s.gen === props.gen ? s : { ...fromText(props.text, props.row, props.col), gen: props.gen }
33  }
34  const send = (ed: Ed, extra: { save?: true; paste?: true } = {}): void => {
35    const out: EditorPost = { gen: props.gen, text: toText(ed), row: ed.row, col: ed.col, ...extra }
36    surface.post(out)
37  }
38
39  surface.onKey(ev => {
40    const cur = current()
41    const out = reduce(cur, ev)
42    if (out.act === 'none') return
43    if (out.act === 'save') return send(cur, { save: true })
44    if (out.act === 'paste') return send(cur, { paste: true })
45    surface.setState({ ...out.ed, gen: cur.gen })
46    send(out.ed)
47  })
48
49  surface.onPointer(ev => {
50    if (ev.type !== 'down' || (ev.button ?? 'left') !== 'left') return
51    const cur = current()
52    const next = pointAt(cur, ev.x, ev.y, surface.columns)
53    if (next.row === cur.row && next.col === cur.col) return
54    surface.setState({ ...next, gen: cur.gen })
55    send(next)
56  })
57
58  const s = current()
59  const end = Math.min(s.lines.length, s.top + VIEW)
60  const rows = []
61  for (let i = s.top; i < end; i++) {
62    const line = shown(s.lines[i] ?? '')
63    const isHead = /^#{1,6} /.test(line)
64    const color = isHead ? ACCENT : undefined
65    const num = <Box width={GUTTER} flexShrink={0}><Text color="inactive">{String(i + 1).padStart(GUTTER - 1)}</Text></Box>
66    if (i !== s.row) {
67      rows.push(
68        <Box flexDirection="row">
69          {num}
70          <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
71            <Text color={color} bold={isHead} wrap="truncate-end">{line.slice(0, LINE_CLIP) || ' '}</Text>
72          </Box>
73        </Box>,
74      )
75      continue
76    }
77    // The cursor's line wraps in full, with the cursor as an inverted character.
78    const fit = line.slice(0, CURSOR_CLIP)
79    const c = Math.min(s.col, fit.length)
80    rows.push(
81      <Box flexDirection="row" backgroundColor={CURSOR_BG}>
82        {num}
83        <Box flexGrow={1} flexShrink={1} minWidth={0}>
84          <Text color={color} bold={isHead} wrap="wrap">{fit.slice(0, c)}<Text inverse>{fit[c] ?? ' '}</Text>{fit.slice(c + 1)}</Text>
85        </Box>
86      </Box>,
87    )
88  }
89  return <Box flexDirection="column" overflow="hidden">{rows}</Box>
90}
91
92export default Editor
93
hooks/sources.ts 89 lines
1import type { Sources } from '../types'
2import { detectLang } from './i18n'
3import type { Strings } from './strings'
4
5// The short name of a source in the activity log, in the language of the catalog; a role without a name
6// there (a source the user declared with a name of their own) is shown lowercase.
7const ROLE_KEYS: Readonly<Record<string, keyof Strings['roles']>> = {
8  Identity: 'identity',
9  'Durable memory': 'memory',
10  'Future work': 'futureWork',
11  'Daily history': 'diary',
12  Handoff: 'handoff',
13  Inbox: 'inbox',
14}
15export function roleLabel(role: string, t: Strings): string {
16  const key = ROLE_KEYS[role]
17  return key === undefined ? role.toLowerCase() : t.roles[key]
18}
19
20export const PROTECTED_ROLES: readonly string[] = ['Identity', 'Durable memory', 'Future work', 'Daily history']
21
22// Protected sources that the cockpit writes anyway: the Future work file (check, add, Reading editor). For the Activity
23// log they stay protected: only what the cockpit can write changes.
24export const EDITABLE_ROLES: readonly string[] = ['Future work']
25
26// The memories folder: the "Durable memory" source if it is a folder, its folder if it is a file
27// (`99 - Jarvis/memory/MEMORY.md` → `99 - Jarvis/memory`). null if it is not declared or the file sits in the root.
28export function memoryDir(roles: Readonly<Record<string, string>>): string | null {
29  const src = roles['Durable memory']
30  if (src === undefined) return null
31  if (!/\.md$/i.test(src)) return src
32  const i = src.lastIndexOf('/')
33  return i > 0 ? src.slice(0, i) : null
34}
35
36// The Italian table (`## Capacita' Jarvis`) or the one from Jarvis Lite (`## Capabilities`, English roles):
37// the Italian roles become the Lite ones, which are the cockpit's keys; the Soul template is only a seed, not
38// a source.
39const HEADING = /^##\s+(?:Capacit(?:a'|à)\s+Jarvis|Capabilities)\s*$/m
40const ROLE_ALIASES: Record<string, string> = {
41  "Identita'": 'Identity',
42  'Identità': 'Identity',
43  'Memoria durevole': 'Durable memory',
44  'Futuro operativo': 'Future work',
45  Diario: 'Daily history',
46  Diary: 'Daily history',
47}
48const SKIPPED_ROLES = new Set(['Role', 'Ruolo', "Capacita'", 'Soul template'])
49const ROW = /^\|\s*([^|]+?)\s*\|\s*`([^`]+)`\s*\|/
50
51export function parseCapabilities(md: string, root: string): Sources {
52  const start = md.search(HEADING)
53  const isOnboarding = md.includes('<!-- jarvis:onboarding-required -->')
54  const flag = isOnboarding ? { isOnboarding } : {}
55  const lang = detectLang(md, '')
56  if (start < 0) return { root, roles: {}, hasTable: false, lang, ...flag }
57  const roles: Record<string, string> = {}
58  for (const line of md.slice(start).split('\n').slice(1)) {
59    if (/^##\s/.test(line)) break
60    const [, role, src] = line.match(ROW) ?? []
61    if (role && src && !SKIPPED_ROLES.has(role)) roles[ROLE_ALIASES[role] ?? role] = src.replace(/\/+$/, '')
62  }
63  return { root, roles, hasTable: Object.keys(roles).length > 0, lang, ...flag }
64}
65
66// The user's name as given to Jarvis: first `Nome:` / `Name:` in CLAUDE.md, then the identity
67// (soul.md: "I am Jane's AI collaborator", "Sono il collaboratore di Jane", or the frontmatter
68// description). The `[NAME]` placeholder of the Jarvis Lite template does not count. Null if there is none.
69const NAME_LINE = /^\s*(?:[-*]\s+)?(?:\*\*)?(?:Nome|Name)(?:\*\*)?\s*:\s*(?:\*\*)?([^*\n]+?)(?:\*\*)?\s*$/im
70const SOUL_NAME = [
71  /\bI am (?:\*\*)?([^*\n'’]{1,40}?)(?:\*\*)?['’]s (?:AI )?collaborator/i,
72  /\bcollaboratore (?:AI )?di (?:\*\*)?([^*\n:.,;]{1,40}?)(?:\*\*)?\s*[:.,;]/i,
73  /\bcollaborating with (?:\*\*)?([^*\n.]{1,40}?)(?:\*\*)?\s*\./i,
74  /\blavora con (?:\*\*)?([^*\n.]{1,40}?)(?:\*\*)?\s*\./i,
75]
76export function userName(claudeMd: string, soulMd: string): string | null {
77  const ok = (s: string | undefined): string | null => {
78    const v = (s ?? '').trim()
79    return v === '' || v.includes('[') ? null : v
80  }
81  const line = ok(NAME_LINE.exec(claudeMd)?.[1])
82  if (line !== null) return line
83  for (const re of SOUL_NAME) {
84    const v = ok(re.exec(soulMd)?.[1])
85    if (v !== null) return v
86  }
87  return null
88}
89
hooks/i18n.ts 20 lines
1import type { Lang } from '../types'
2import type { Strings } from './strings'
3import { EN } from './strings.en'
4import { IT } from './strings.it'
5
6export type { Strings }
7
8// The first line of the Identity source that names a language decides: Italian gives `it`, any other
9// language `en`. Without one (no Identity yet, or the template placeholder), the capability table's
10// heading decides: the Italian heading gives `it`, anything else `en`. Never asked to the user.
11const LANG_LINE = /^.*\b(?:lingua|language)\b.*$/im
12const ITALIAN_HEADING = /^##\s+Capacit(?:a'|à)\s+Jarvis\s*$/m
13export function detectLang(claudeMd: string, soulMd: string): Lang {
14  const line = LANG_LINE.exec(soulMd)?.[0]
15  if (line !== undefined && !line.includes('[LANGUAGE]')) return /itali/i.test(line) ? 'it' : 'en'
16  return ITALIAN_HEADING.test(claudeMd) ? 'it' : 'en'
17}
18
19export const strings = (lang: Lang): Strings => (lang === 'it' ? IT : EN)
20
hooks/strings.en.ts 286 lines
1import type { Strings } from './strings'
2
3export const EN: Strings = {
4  dates: {
5    days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
6    daysLong: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
7    months: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
8    monthsLong: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
9    today: 'today',
10    yesterday: 'yesterday',
11    age: days => (days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days}d`),
12    daysAgo: days => `${days}d ago`,
13    shortDays: days => `${days}d`,
14    shortMonths: months => `${months}mo`,
15  },
16  tabs: { recap: 'Recap', diary: 'Diary', files: 'Files', reading: 'Reading', readingDraft: 'Reading ●', todo: 'To Do', memories: 'Memories' },
17  mascot: {
18    alt: {
19      idle: 'Jarvis waiting', sleep: 'Jarvis asleep', thinking: 'Jarvis thinking', reading: 'Jarvis reading', writing: 'Jarvis writing',
20      agent: 'Jarvis with a subagent', done: 'Jarvis finished', error: 'Jarvis has a problem',
21    },
22    title: {
23      idle: 'Jarvis waiting',
24      sleep: 'Jarvis asleep: no turn for a quarter of an hour',
25      work: 'Jarvis at work',
26      agent: 'Jarvis with a subagent',
27      done: 'Jarvis finished',
28      party: 'Jarvis celebrates: a test or commit passed',
29      error: 'Jarvis has a problem: turn interrupted or stopped',
30    },
31  },
32  command: { description: 'Opens the Jarvis cockpit: workspace status and live activity', opened: 'Cockpit opened.' },
33  figure: {
34    refused: (file, reason) => `Mascot: ${file} ignored (${({ tooBig: 'larger than 16 KB', notSvg: 'not an SVG', noViewBox: 'no viewBox', entity: 'DOCTYPE or ENTITY', script: 'script or event handler', external: 'external reference', animation: 'animation: the stage moves the mascot' } as const)[reason]})`,
35  },
36  roles: { identity: 'identity', memory: 'memory', futureWork: 'to do', diary: 'diary', handoff: 'handoff', inbox: 'inbox' },
37  recap: {
38    loading: 'Loading the state…',
39    sources: {
40      title: 'Sources',
41      noTable: 'Jarvis capability table not found in CLAUDE.md',
42      names: { Identity: 'Identity', 'Durable memory': 'Durable memory', 'Future work': 'Future work', 'Daily history': 'Daily history' },
43      missing: name => `✕ ${name} declared but missing`,
44    },
45    today: {
46      title: 'Today',
47      date: day => day,
48      none: 'Nothing due',
49      waitingSince: age => `waiting for ${age}`,
50      noNow: 'Now (or Active) section not found',
51      inNow: (count, section) => `${count} in ${section}`,
52      waiting: count => `${count} waiting`,
53      inbox: (count, oldest) => `${count} in Inbox${oldest === null ? '' : `, oldest ${oldest}`}`,
54    },
55    last: {
56      title: 'Last session',
57      fromDiary: 'from the diary',
58      diaryButton: 'Diary ›',
59      unsaved: 'not saved',
60      notInDiary: 'not in the diary',
61      resumeThis: 'Resume this ›',
62      before: (topic, closing) => `Earlier that day: ${topic} · ${closing}`,
63    },
64    next: { title: 'Next step', nothing: 'Nothing planned. Tell Jarvis what you want to work on.', resume: 'Resume ›', briefing: 'Briefing', others: 'Other handoffs:', backToWork: 'Back to the session' },
65    work: {
66      title: 'This session',
67      since: time => `since ${time}`,
68      logTitle: 'What happened',
69      logAlt: rows => `Activity: ${rows} ${rows === 1 ? 'line' : 'lines'}`,
70      logLess: 'Latest only',
71      logMore: rows => `${rows} more ›`,
72      writtenTitle: 'Written',
73      writtenLess: 'Less',
74      saved: time => `✓ saved at ${time}`,
75      save: 'Save session ›',
76      handoff: 'Handoff ›',
77      seeOpen: 'Show overview',
78    },
79    now: {
80      title: 'Now',
81      working: 'at work',
82      workingSince: time => `at work since ${time}`,
83      waiting: 'waiting',
84      thinking: 'thinking…',
85      agentRunning: (type, time) => `⧗ ${type} running · since ${time}`,
86      lastDone: (time, span) => `✓ last turn done at ${time}${span === null ? '' : ` in ${span}`}`,
87      lastStopped: time => `✗ stopped at ${time}`,
88      lastFailed: time => `✗ ended with an error at ${time}`,
89    },
90    activity: {
91      verbs: { read: 'read', search: 'search', edit: 'edit', create: 'create', move: 'move', delete: 'delete', shell: 'run' },
92      review: 'review',
93      context: counts => `the context · ${counts}`,
94      files: count => `${count} ${count === 1 ? 'file' : 'files'}`,
95      searches: count => `${count} ${count === 1 ? 'search' : 'searches'}`,
96      running: 'running',
97      end: (kind, span, files) =>
98        `${kind === 'aborted' ? '✗ stopped' : kind === 'answer' ? '✓ done' : '✗ ended with an error'}${span === null ? '' : ` in ${span}`}${files === 0 ? '' : ` · ${files} ${files === 1 ? 'file' : 'files'}`}`,
99    },
100  },
101  session: {
102    untitled: 'untitled session',
103    filesWritten: count => `${count} ${count === 1 ? 'file' : 'files'} written`,
104    handoff: { completed: 'handoff completed', superseded: 'handoff superseded', open: 'handoff open' },
105    turns: count => `${count} ${count === 1 ? 'turn' : 'turns'}`,
106    subagents: count => `${count} ${count === 1 ? 'subagent' : 'subagents'}`,
107    errors: count => `${count} ${count === 1 ? 'error' : 'errors'}`,
108    savedInDiary: 'saved in the diary',
109    resumeSession: (when, topic, files, more) =>
110      `Let's resume the session from ${when}: "${topic}".${files.length === 0 ? '' : ` Files written: ${files.join(', ')}${more > 0 ? ` and ${more} more` : ''}`}`,
111    resumeNow: (title, detail) => (detail === '' ? `Let's resume "${title}"` : `Let's resume "${title}": ${detail}`),
112    sourceHandoff: age => `from handoff · ${age}`,
113    sourceSection: section => `from ${section}`,
114    sourceLate: (section, late) => `from ${section} · ${late}`,
115    late: days => (days <= 0 ? 'due today' : days === 1 ? 'overdue since yesterday' : `overdue ${days}d`),
116  },
117  diary: {
118    empty: 'No notes in the diary',
119    loading: 'Loading…',
120    tooBig: 'The note is too big for the tab',
121    openInReading: 'Open in Reading ›',
122    readNote: (path, error) => `Cannot read ${path}: ${error}`,
123    readDiary: error => `Cannot read the diary: ${error}`,
124    parts: { focus: 'Focus', done: 'Done', decisions: 'Decisions', closing: 'Closing state' },
125  },
126  todo: {
127    name: 'To Do',
128    confirmDelete: 'Delete?',
129    add: '+ Add',
130    cancel: 'Cancel',
131    enterToAdd: 'Enter to add',
132    newItemIn: section => `New item in ${section}`,
133    loading: 'Loading…',
134    unreadable: (file, error) => `Cannot read ${file}: ${error}`,
135    tooBig: name => `${name} is too big for the tab`,
136    openInReading: 'Open in Reading ›',
137    notAdded: 'Item not added',
138    discard: 'Discard',
139    editFile: 'Edit the file ›',
140    changed: name => `${name} changed, I reloaded it`,
141    cannotWrite: error => `Cannot write the file: ${error}`,
142    notUtf8: 'The file is not UTF-8: edit it outside the panel',
143  },
144  memories: {
145    groups: { people: 'People', projects: 'Projects', preferences: 'Preferences', references: 'References', other: 'Other' },
146    fresh: { new: 'New', changed: 'Changed' },
147    title: 'Memories',
148    loading: 'Loading…',
149    unreadable: (dir, error) => `Cannot read ${dir ?? 'the memories folder'}: ${error}`,
150    none: dir => `No memories in ${dir ?? 'this folder'}`,
151    footer: 'To change the memories, ask Jarvis',
152    count: (total, fresh) => (fresh === 0 ? `${total}` : `${total} · ${fresh} new`),
153    more: hidden => `${hidden} more ›`,
154    less: 'Less ‹',
155    openInReading: 'Open in Reading ›',
156    askChange: 'Ask for a change',
157    linked: 'Linked',
158    map: 'Map',
159    mapAlt: (name, links) => `${name} and ${links} linked ${links === 1 ? 'memory' : 'memories'}`,
160    askPrompt: name => `Edit the memory "${name}": `,
161  },
162  files: {
163    openInApp: 'Open in app',
164    copyPath: 'Copy path',
165    filter: 'Filter…',
166    noSelection: 'No file selected',
167    unreadableTitle: 'Workspace unreadable',
168    unreadable: error => `Cannot read the workspace: ${error}`,
169    noMatch: query => `No file matches "${query}"`,
170    moreRows: hidden => `${hidden} more ${hidden === 1 ? 'row' : 'rows'}: use the filter`,
171    writtenByJarvis: time => `written by Jarvis at ${time}`,
172    modifiedAt: time => `modified at ${time}`,
173    modifiedAgo: age => `modified ${age} ago`,
174    decimalMark: '.',
175    trashAsk: 'Move to the macOS Trash?',
176    trashYes: '✕ Yes, trash it',
177    cancel: 'Cancel',
178    moveHint: 'Enter to move · a trailing / means into the folder',
179    move: '→ Move',
180    duplicate: '+ Duplicate',
181    trash: '✕ Trash',
182    copySuffix: n => (n === 1 ? ' copy' : ` copy ${n}`),
183    icons: {
184      dir: 'folder', dirOpen: 'open folder', md: 'Markdown', pdf: 'PDF', word: 'Word', excel: 'spreadsheet',
185      ppt: 'presentation', image: 'image', code: 'code', data: 'data', html: 'HTML', link: 'link', file: 'file',
186    },
187  },
188  reading: {
189    modes: { text: 'Text', diff: 'Diff', edit: 'Edit' },
190    editOff: why => `Edit: ${why}`,
191    blocked: {
192      protected: 'protected file, edit in chat',
193      notMarkdown: 'Markdown only',
194      tooBig: 'too long, use ↗',
195      notRead: 'file not read',
196      notUtf8: 'the file is not UTF-8, edit it outside the panel',
197      mixedEol: 'mixed line endings, edit it outside the panel',
198    },
199    updatedNow: 'updated just now',
200    modifiedByJarvis: time => `modified by Jarvis at ${time}`,
201    tooLong: 'File too long for the panel.',
202    loading: 'Loading the file…',
203    notText: 'Not a text file.',
204    unreadable: error => `Cannot read the file: ${error}`,
205    noFile: 'No file open',
206    pickFile: 'Pick a file from the Files tab',
207    diff: {
208      loading: 'Loading the diff…',
209      untracked: 'New file, not in git yet',
210      none: 'No history in git for this file',
211      error: 'Diff unavailable',
212      uncommitted: 'Uncommitted changes',
213      lastCommit: (when, subject) => `Last commit · ${when} · ${subject}`,
214      noDifference: 'No differences',
215      cut: 'Diff truncated',
216      onDisk: '(on disk)',
217      draft: '(draft)',
218    },
219    conflict: {
220      jarvis: 'Jarvis changed this file',
221      disk: 'The file changed while you were editing',
222      keep: 'Your draft stays until you choose.',
223      overwrite: 'Overwrite',
224      reload: 'Reload and lose the draft',
225      showDiff: 'Show the diff',
226      hideDiff: 'Hide the diff',
227      diffCut: '… diff cut',
228    },
229    pending: { title: name => `Unsaved draft on ${name}`, hint: 'One draft at a time: decide what to do with it first.', save: 'Save', discard: 'Discard', back: 'Back to the draft' },
230    edit: { notSaved: 'Not saved', unsaved: 'Unsaved', saved: 'Saved', confirmDiscard: 'Press again to discard', cancel: 'Cancel', save: 'Save' },
231    protectedError: 'Protected file: it cannot be saved from the cockpit',
232    outsideError: 'File outside the workspace: it cannot be saved from the cockpit',
233    cannotWrite: error => `Cannot write the file: ${error}`,
234    notUtf8: 'The file is not UTF-8: edit it outside the panel',
235  },
236  log: { profile: 'profile', core: 'core' },
237  pane: { elsewhere: 'The cockpit is designed for the desktop app', faultTitle: 'The cockpit ran into a problem', faultReload: 'Reload' },
238  toasts: {
239    promptNotEmpty: 'The prompt is not empty: clear it and try again',
240    copied: path => `Copied ${path}`,
241    cannotCopy: 'Cannot copy the path',
242    linkNowhere: 'The link leads nowhere',
243    linkOutside: 'The link points outside the workspace',
244    outsideVault: 'Outside the workspace',
245    protectedFile: name => `${name} is protected`,
246    saveFirst: 'Save or discard the draft first',
247    cannotTrash: error => `Cannot move it to the Trash: ${error}`,
248    trashed: name => `${name} moved to the Trash`,
249    moveWhere: 'Type where to move it',
250    onlyInVault: 'Only paths inside the workspace',
251    alreadyThere: 'It is already there',
252    notThere: rel => `${rel} is protected`,
253    exists: rel => `${rel} already exists`,
254    cannotCreate: (dir, error) => `Cannot create ${dir}: ${error}`,
255    cannotMove: error => `Cannot move it: ${error}`,
256    moved: rel => `Moved to ${rel}`,
257    cannotDuplicate: error => `Cannot duplicate it: ${error}`,
258    created: name => `Created ${name}`,
259    commandFailed: (command, code) => `${command} exited with ${code}`,
260    cannotOpenPane: 'Cannot open the Files pane',
261    paneUnavailable: 'The Files pane is not available here',
262    fileNotFound: rel => `File not found: ${rel}`,
263    cannotReread: name => `Cannot reread ${name}`,
264    goneFromVault: 'File no longer in the workspace',
265    saved: 'Saved',
266    cannotSave: 'Cannot save: the draft stays, try again',
267    savedStale: 'Saved, but the tab did not update: reopen the file',
268    pasteUnavailable: 'Paste is not available here: use ↗ to open the file in the app',
269    editorStopped: 'The editor stopped: the draft stays, reopen it from Edit',
270  },
271  header: { working: 'at work', workingSince: time => `at work · since ${time}`, waiting: 'waiting', reopen: 'reopens the cockpit' },
272  bubble: {
273    hello: first => (first === '' ? 'Hello!' : `Hello, ${first}!`),
274    setup: hello => `${hello} Shall we set up Jarvis?`,
275    setupAction: 'Start',
276    memory: 'I learned something new',
277    memoryAction: 'View',
278    handoff: 'I left a handoff to resume from',
279    handoffAction: 'Open',
280    save: 'Nice work. Save the session?',
281    saveAction: 'Save',
282    wins: ['Well done!', 'All green ✓', 'Yay!', 'Great work!', 'Mission accomplished', 'Happy!'],
283    saved: ['Session safe ✓', 'Saved. Nice work!', 'All noted ✓'],
284  },
285}
286