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…

<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-Litefolder 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.
Jarvis Lite is a good fit if you want:
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.
The release ZIP is the simplest path. You do not need Git or Python to get the workspace:
Jarvis-Lite.zip from the latest published release using the button above.Jarvis-Lite folder; do not merge it into an existing workspace.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.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.
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.
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.
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.
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.
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 say | Jarvis 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.
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:
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.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.
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.
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.
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.
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.
| You are | Continue with |
|---|---|
| New to Jarvis Lite | Getting started |
| Using Lite day to day | Daily use |
| Updating Lite or bringing a capability to another Jarvis | Updates and adoption |
| Exploring optional tools | Extensions and external references |
| Contributing | Contribution workflow |
| Maintaining the repository or acting as a coding agent | Architecture and maintainer guide |
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/.
hooks/register.tsx 1424 lines1import { 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 lines1import 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}
68hooks/tree.ts 100 lines1// 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}
100hooks/format.ts 88 lines1import 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() }
88hooks/git.ts 87 lines1// 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}
87hooks/files.tsx 260 lines1import 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}
260hooks/readingView.tsx 223 lines1import 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}
223hooks/reader.ts 142 lines1import 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}
142hooks/editor.tsx 93 lines1// 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
93hooks/sources.ts 89 lines1import 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}
89hooks/i18n.ts 20 lines1import 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)
20hooks/strings.en.ts 286 lines1import 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