Search past coding agent sessions over MCP and with /recall, show them inside the session, and archive each session when it ends

Recall any past Claude Code session: ask Claude to look into it, or find it yourself and go back to it.

claude-recall archives every Claude Code session into SQLite, including the ones whose JSONL Claude Code has since deleted, and gives you three ways back into them from one binary called recall:
With the Claude Code plugin, recall also shows up in Claude Code itself: the repository's last sessions when you start, search results you can read at a glance, and /recall.
curl -fsSL https://raw.githubusercontent.com/babarot/claude-recall/main/bin/install.sh | bash
recall
The installer puts recall in ~/.local/bin, imports your sessions and registers the MCP server with Claude Code. Then ask Claude about a past session ("how did we set up the staging deploy last month?"), or run recall to browse them. See Install for Nix, building from source and the Claude Code plugin.
At work, a project rarely fits in one session. Project A gets split into tasks 1 to 10, and each one runs in its own session. While working on task 5, you need what was decided in task 3: ask Claude "what did we decide about the retry policy in the task 3 session?" and it looks that session up through MCP and answers from it. Or, to carry on with task 3 itself, find it in the TUI and press Enter to resume it.
A support question comes in, you open a session, and it is settled in ten minutes. Months later a similar question arrives. By then Claude Code has deleted that transcript, but claude-recall still has it: ask Claude to find how it was handled last time, or search for it yourself and read it again.
| You want to | Use | What only it gives you |
|---|---|---|
| Have Claude remember what happened in a past session | MCP server | The answer lands in the session you are working in, as context Claude can use right away |
| Find a session yourself and go back to it | TUI | Resume it in place (claude -r), recall it in a new claude, or copy its ID to hand to another agent |
| Find a session yourself and read it | Web UI | A comfortable reader in the browser, for long conversations and images |
| Look back without leaving the session | In Claude Code | The repository's last sessions as you start, a search without a model turn, and a recap of any session with one press |
The CLI underneath starts each of them (recall mcp, recall, recall ui) and also searches, lists and exports from the terminal or a script.
Each Claude Code session runs its own recall mcp, so Claude can look into past sessions whenever it needs to. Nothing is injected into the prompt: Claude calls these tools when you ask about the past or when it realizes something was discussed before.
| Tool | Description |
|---|---|
recall_search | Full-text search across past sessions, optionally within one repository (repo); each hit names its session's title, size and repository |
recall_list | List archived sessions, optionally of one repository (repo) |
recall_export | Export a session's full conversation, or with tail only its last messages |
recall_stats | Show archive statistics |
It also offers one prompt, recap, which has Claude read where a session ended, sum up what was done, decided and left, and ask how to go on. Claude Code lists it as a command named after the server: /claude-recall:recap <session-id> with claude mcp add, /plugin:claude-recall:claude-recall:recap <session-id> from the plugin.
A session ID from the TUI (y) works too: "look up session a1b2c3 with claude-recall and continue from there".
recall lists every archived session with its title, folder (a git worktree is shown under the repository it belongs to), branch, message count, size and ID. Started inside a repository, it shows only that repository's sessions; . switches to all of them, and recall --all starts with all of them.
| Key | Action |
|---|---|
Enter | Resume the session: claude -r <id> from the session's folder |
c | Recall the session in a new claude through MCP: for a session claude -r cannot resume, such as one whose worktree was removed |
y / Y | Copy the session ID / the resume command (the recall command when it cannot be resumed) |
/ | Filter by title, folder, branch, ID or what was said; text:, title:, folder: and others narrow it to one field |
a | Ask Claude to find sessions, when you remember what it was about but not what to type. It runs your own claude -p, so no API key is needed |
Space | Read the conversation over the detail pane |
? | Show every key, the ones for where you are first |
Its settings are under [tui] in the config file, and [keys] changes which keys do what. See docs/tui.md for every key, the filter, the folder list, the detail pane and the mouse.
recall ui # http://localhost:6276, in the background
recall ui stop
A session browser, a chat viewer and search. While it runs, new and changed sessions are imported within about half a second, the session list moves them to the top, and the chat view of a running session follows new messages. The server listens on 127.0.0.1 only.
The plugin carries a Claude Code mod (a hooks module) that draws recall in the session you are working in.
When a session starts, the latest sessions of the same repository, its worktrees included, show above the prompt until you send the first prompt or command.

When Claude calls recall_search, the result shows as a tree of repositories, sessions (title, message count, date) and the messages that matched, instead of JSON. Claude still reads the JSON.

/recall <query> searches this repository's sessions on the spot, without a model turn, and shows them as a table; --all searches every session. Claude reads the same list, so "read number 2" works next.

Pressing a session's ID in any of them, or /recall <n>, runs the recap prompt on it: Claude reads where that session ended, sums up what was done, decided and left, and asks how to go on.
Sessions that only looked back through recall (a search for a word, a recap) are put together on one line instead of a row each; their IDs can still be pressed. The words are English, or Japanese when Claude Code's language setting is Japanese.
It needs a Claude Code that loads hooks modules (tested on 2.1.291). docs/plugin.md covers how it works.
claude-recall is a recall tool, not a memory system. The goal is to make grep ~/.claude/projects/**/*.jsonl a better experience, and to keep those files around after Claude Code deletes them. When you or the agent realize something was discussed before, you look it up. See ADR-001.
claude-mem is an excellent project solving a related problem, and if it fits your workflow, you should use it. claude-recall deliberately solves a different one.
claude-mem extends the agent's memory, for Claude Code and many other agents. It captures observations on every tool use, summarizes them with an LLM into structured facts, stores them in a vector DB, and injects the result into the next session's prompt. claude-recall doesn't touch the memory layer. It stores the JSONL files Claude Code already writes and searches them with SQLite FTS5.
| claude-mem | claude-recall | |
|---|---|---|
| Goal | Extend the agent's memory | Help you and the agent recall |
| Injection | Push (auto-injected at SessionStart) | Pull (looked up when needed) |
| What's stored | LLM-summarized observations | Raw conversation, noise-stripped |
| Search | FTS5 + Chroma vector hybrid | FTS5 only (deterministic) |
| LLM calls | During indexing, through a hosted observer, your own OpenRouter or Gemini key, or your Anthropic plan | Never while storing or searching; only when you ask Claude from the TUI (a), through your own claude |
| Where data lives | Local, with optional cloud sync | Local only |
| Agents | Claude Code, Codex, Gemini, OpenCode and more | Claude Code |
| Runtime | Node + Bun + Python (uv) + Chroma, resident worker | One static binary, no daemon |
The tradeoff claude-recall picks:
Getting started takes three steps: put recall on PATH, import your sessions into the archive, and connect Claude Code to the MCP server. What each way of installing does for you:
| Install | recall on PATH | Import | MCP server |
|---|---|---|---|
| curl | Yes | Yes | Yes, when claude is on PATH |
| Nix | Yes | No | No |
| Build from source | Yes | No | No |
| Claude Code plugin | No | When a Claude Code session starts | Yes |
Whatever is left is in Set up.
curl -fsSL https://raw.githubusercontent.com/babarot/claude-recall/main/bin/install.sh | bash
Installs recall to ~/.local/bin (set RECALL_INSTALL_DIR to change it), imports your sessions and registers the MCP server.
Each release is published to babarot/nur-packages:
nix profile install github:babarot/nur-packages#claude-recall
The package also carries the Claude Code plugin under share/claude-plugin/claude-recall. Then set up.
Requires Go and Node.js (for the web UI):
git clone https://github.com/babarot/claude-recall.git
cd claude-recall
make install # builds the UI, embeds it, installs recall to ~/.local/bin
go install github.com/babarot/claude-recall/cmd/recall@latest also works; that build leaves the web UI out. Then set up.
The plugin is the recommended way to connect claude-recall to Claude Code. plugin/ bundles, with recall on PATH:
| Component | What it does |
|---|---|
| MCP server | Runs recall mcp |
SessionEnd hook | Runs recall import when a session ends |
| Hooks module | Draws recall in Claude Code and adds /recall |
An older Claude Code, which does not load hooks modules, still gets the MCP server and the hook.
Each release ships it as claude-recall-plugin.tar.gz. A plugin directory under ~/.claude/skills/ loads as claude-recall@skills-dir, so with Nix:
ln -s ~/.nix-profile/share/claude-plugin/claude-recall ~/.claude/skills/claude-recall
Without the plugin, register just the MCP server, as in Set up.
After installing with Nix or from source, import your sessions and connect Claude Code:
recall import # create the archive from ~/.claude/projects
claude mcp add claude-recall -s user -- recall mcp # or install the plugin
Until the archive exists, recall and its search, list, export and stats commands say how to set it up and exit with status 1. The MCP server and the web UI create the archive and import into it when they start, so with the MCP server connected, the first Claude Code session you start does the import too.
A curl install updates itself:
recall update # replace recall with the latest release, and restart the web UI
recall update --check # only say whether a newer release is out
The TUI and recall version say when a newer release is out (see A new release). recall update does not import or register the MCP server again. Running MCP servers keep the old version until their Claude Code session ends. recall update came in a release after 1.7.2; to get it, re-run the installer once more. A Nix install is updated with Nix, and a build from source by building it again; recall update says so.
The archive is ~/.claude/vault.db. Sessions whose JSONL Claude Code has deleted stay in it, so it is the only copy of them: back it up, and never delete it to rebuild it.
sqlite3 ~/.claude/vault.db ".backup '/path/to/backup.db'"
Sessions are imported from ~/.claude/projects ($CLAUDE_CONFIG_DIR/projects when Claude Code runs with CLAUDE_CONFIG_DIR), and from the trees extra_projects_dirs in the config file lists, such as a container's; docs/architecture.md says when. Symlinked project directories and transcripts are followed. The archive stays in ~/.claude either way; db in the config file moves it.
| Stored | Excluded |
|---|---|
| User and assistant text | System events (turn_duration and others) |
| Thinking | File history snapshots |
| Tool calls and their results | Sidechains |
| Slash-command expansions, task notifications | Progress, queue operations and other bookkeeping |
~/.config/claude-recall/config.toml (or $XDG_CONFIG_HOME/claude-recall/config.toml). recall writes it the first time the TUI runs, with every setting at its default and commented out; a setting left out is its default, so set only what you change:
[core]
db = "~/.claude/vault.db" # the archive, unless --db says otherwise
[ui]
port = 6276 # the web UI, unless --port says otherwise
[tui]
theme = "auto" # or tokyo-night, dracula, nord, gruvbox-dark, ansi
detail_position = "bottom" # or "right", or "auto" on a wide terminal
[keys]
resume = "space" # swap the keys of resume and read
read = "enter"
A mistake in it is reported with the line it is on, not ignored. See docs/configuration.md for every setting, and Changing keys for [keys].
recall # Browse sessions (same as recall tui)
recall import # Import all sessions
recall search "terraform module"
recall search "deploy" --project oksskolten --from 2026-03-01
recall list --project gh-infra --format json
recall search "staging" --repo . # this repository, its worktrees included
recall export <session-id> --format json --output session.json
recall stats
recall ui # Web UI in the background (http://localhost:6276)
recall mcp # MCP server over stdio (started by Claude Code)
recall update # Update to the latest release
recall version
Each command's flags are in docs/cli.md and recall <command> --help.
make test # go vet, go test, UI tests
make build # recall with the web UI embedded
go run ./cmd/recall search "query" --db /tmp/vault-copy.db
# Web UI: Vite dev server on 5173, proxying /api to the Go server on 6276
cd ui && npm run dev
go run ./cmd/recall ui --foreground
Work against a copy of the archive (sqlite3 ~/.claude/vault.db ".backup '/tmp/vault-copy.db'"), not the live file.
After a change to how the TUI looks, re-record its GIF with make demo-tui; after a change to what the plugin draws in Claude Code, make demo-claude (see demo/README.md).
For the plugin's hooks module, claude --plugin-dir plugin loads it from the checkout and reloads it as you edit, and claude plugin test plugin runs its tests (see docs/plugin.md).
docs/architecture.md covers how sessions flow into the archive, the schema and the web UI's live updates. The ADRs record the larger decisions, such as why claude-recall moved from Deno to Go (ADR-004).
MIT
hooks/register.tsx 575 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Elements } from 'claude-code'
3
4import type { FoundRow, ListedSession, SearchView } from '../types'
5import {
6 cells,
7 fitTree,
8 groupByRepo,
9 groupHits,
10 highlight,
11 MSGS_CELLS,
12 WHEN_CELLS,
13 oneLine,
14 padCells,
15 padStartCells,
16 parseJSON,
17 placeOf,
18 previousSessions,
19 queryTerms,
20 resultText,
21 rowLayout,
22 shortDate,
23 shortId,
24 snippet,
25 truncateCells,
26 type SearchHit,
27} from './recall'
28import { langOf, relativeTime, words, type Words } from './i18n'
29
30const previous = atom({ plugin: 'claude-recall', key: 'previous' } as const, null)
31const omitted = atom({ plugin: 'claude-recall', key: 'omitted' } as const, 0)
32const isBandHidden = atom({ plugin: 'claude-recall', key: 'isBandHidden' } as const, false)
33const queries = atom({ plugin: 'claude-recall', key: 'queries' } as const, {})
34const found = atom({ plugin: 'claude-recall', key: 'found' } as const, [])
35const views = atom({ plugin: 'claude-recall', key: 'views' } as const, {})
36const lang = atom({ plugin: 'claude-recall', key: 'lang' } as const, 'en')
37
38// Theme keys, so the drawing follows the person's theme.
39const BRAND = 'merged'
40const META = 'subtle'
41const HIT = 'warning'
42// A match inside a dim snippet, and a repository's name: gray, the match bold.
43const MATCH = 'inactive'
44
45// Sessions the /recall table draws in full, and the ones its text lists for
46// the model.
47const MAX_ROWS = 15
48const MAX_TEXT_ROWS = 30
49// Lines a recall_search tree may take, its repository headers included.
50const MAX_TREE_LINES = 24
51const MAX_HITS = 2
52
53const isRecallTool = (tool: string, name: string) =>
54 tool.startsWith('mcp__') && tool.includes('recall') && tool.endsWith(`__${name}`)
55
56// The recall CLI's JSON for argv, or undefined when it failed or found
57// nothing (it then prints a sentence, not JSON).
58async function recallJSON<T>($: EngineInterface, argv: string[]): Promise<{ value?: T; error?: string }> {
59 const { exitCode, stdout, stderr } = await $.process.run(['recall', ...argv])
60 if (exitCode !== 0) return { error: oneLine(stderr) }
61 return { value: parseJSON<T>(stdout) }
62}
63
64// Resumes a past session by running the MCP server's recap prompt, as if
65// the person typed it: the server words the instruction, and the model
66// reads the session with recall_export, sums it up and asks how to go on.
67async function resumeSession($: EngineInterface, id: string) {
68 const commands = await $.command.list()
69 const recap = commands.find(c => c.source === 'mcp' && c.name.includes('recall') && /\brecap\b/.test(c.name))
70 if (!recap) {
71 $.ui.toast(words(await read($, lang)).noRecap)
72 return
73 }
74 await $.command.run({ command: recap.name, args: id })
75}
76
77async function hideBand($: EngineInterface) {
78 if (!(await read($, isBandHidden))) await update($, isBandHidden, () => true)
79}
80
81// Not awaited: the turn starts once the session is idle, and a command or a
82// press waiting on it would hold the session until then. A failure is said
83// in a toast rather than lost.
84function resume($: EngineInterface, id: string) {
85 void resumeSession($, id).catch((err: unknown) => $.ui.toast(`recall: could not resume ${shortId(id)}: ${String(err)}`))
86}
87
88type Table = Elements['terminal']
89
90// One session as a row of fixed columns, laid out here rather than by
91// flex so wide characters never push a column off the line: number, ID
92// (pressing it recaps that session), title, place, message count, time,
93// hit count; then the first hit under the title, the query highlighted.
94function sessionRow(
95 { Box, Button, Text }: Pick<Table, 'Box' | 'Button' | 'Text'>,
96 $: EngineInterface,
97 row: { n?: number; id: string; title: string; place: string; when: string; msgs?: number; hits?: number; snippet?: string },
98 terms: string[],
99 inner: number,
100 w: Words,
101) {
102 const layout = rowLayout(inner, row.n !== undefined, row.hits !== undefined, row.msgs !== undefined)
103 const snippetText = row.snippet ? truncateCells(row.snippet, inner - layout.indent) : ''
104 return (
105 <Box key={`row-${row.id}`} flexDirection="column">
106 <Box flexDirection="row">
107 {row.n !== undefined && <Text color={META}>{padStartCells(String(row.n), 2) + ' '}</Text>}
108 <Button key={`refer-${row.id}`} plain dimColor label={shortId(row.id)} onPress={() => resume($, row.id)} />
109 <Text wrap="truncate-end">
110 {' '}
111 <Text bold>{padCells(row.title, layout.title)}</Text>
112 {layout.place > 0 && <Text color={META}>{' ' + padCells(row.place, layout.place)}</Text>}
113 {row.msgs !== undefined && <Text color={META}>{' ' + padStartCells(w.msgs(row.msgs), MSGS_CELLS)}</Text>}
114 <Text color={META}>{' ' + padStartCells(row.when, WHEN_CELLS)}</Text>
115 {row.hits !== undefined && <Text color={HIT}>{' ' + padStartCells(String(row.hits), 4)}</Text>}
116 </Text>
117 </Box>
118 {snippetText && (
119 <Text wrap="truncate-end" color={META}>
120 {' '.repeat(layout.indent)}
121 {highlight(snippetText, terms).map(run =>
122 run.isHit ? (
123 <Text color={MATCH} bold>
124 {run.text}
125 </Text>
126 ) : (
127 run.text
128 ),
129 )}
130 </Text>
131 )}
132 </Box>
133 )
134}
135
136// The sessions that were only a look back through recall, on one line:
137// how many, then their IDs, each pressed like any other to recap it, as
138// many as fit in `inner` cells and a count of the rest.
139function searchesLine(
140 { Box, Button, Text }: Pick<Table, 'Box' | 'Button' | 'Text'>,
141 $: EngineInterface,
142 lead: string,
143 ids: string[],
144 inner: number,
145 w: Words,
146) {
147 const label = lead + w.searches(ids.length) + ' '
148 // An ID takes eight cells, and two more for the ", " after it.
149 const fit = Math.max(1, Math.floor((inner - cells(label) - 4) / 10))
150 const shown = ids.slice(0, fit)
151 return (
152 <Box flexDirection="row">
153 <Text color={META}>{label}</Text>
154 {shown.map((id, i) => (
155 <Box key={`search-${id}`} flexDirection="row">
156 <Button key={`refer-${id}`} plain dimColor label={shortId(id)} onPress={() => resume($, id)} />
157 {i < shown.length - 1 && <Text color={META}>{', '}</Text>}
158 </Box>
159 ))}
160 {ids.length > shown.length && <Text color={META}>{` +${ids.length - shown.length}`}</Text>}
161 </Box>
162 )
163}
164
165// A header line: `parts` cut to fit, then `hint` at the right edge when
166// there is room for it.
167function headerLine(
168 { Text }: Pick<Table, 'Text'>,
169 parts: { text: string; color?: string; bold?: boolean }[],
170 hint: string,
171 inner: number,
172) {
173 const hintCells = cells(hint)
174 const showHint = hint !== '' && inner >= 50 + hintCells
175 let room = inner - (showHint ? hintCells + 2 : 0)
176 const drawn: { text: string; color?: string; bold?: boolean }[] = []
177 for (const part of parts) {
178 if (room <= 0) break
179 const text = truncateCells(part.text, room)
180 drawn.push({ ...part, text })
181 room -= cells(text)
182 }
183 return (
184 <Text wrap="truncate-end">
185 {drawn.map(part => (
186 <Text color={part.color} bold={part.bold}>
187 {part.text}
188 </Text>
189 ))}
190 {showHint && <Text color={META}>{' '.repeat(Math.max(2, room + 2)) + hint}</Text>}
191 </Text>
192 )
193}
194
195// A recall_search result as a tree: repositories, their sessions (ID,
196// title, size, date) and up to two hits of each, the query in gray bold.
197// Sessions that were only a recall search share one line per repository.
198function searchBody(
199 $: EngineInterface,
200 el: Pick<Table, 'Box' | 'Button' | 'Text'>,
201 hits: SearchHit[],
202 query: string,
203 inner: number,
204 w: Words,
205) {
206 const { Box, Button, Text } = el
207 const terms = queryTerms(query)
208 const { shown, hidden } = fitTree(
209 groupByRepo(groupHits(hits), g => g.recallOnly),
210 MAX_TREE_LINES,
211 MAX_HITS,
212 )
213 const titleCells = Math.max(10, inner - 4 - 8 - 2 - 2 - MSGS_CELLS - 2 - 5)
214 const snippetCells = Math.max(10, inner - 8)
215 return (
216 <Box flexDirection="column">
217 {shown.map((r, ri) => (
218 <Box key={`repo-${r.repo}`} flexDirection="column">
219 <Text wrap="truncate-end">
220 <Text color={META}>{ri === 0 ? '⎿ ' : '├ '}</Text>
221 <Text color={MATCH}>{truncateCells(r.repo, inner - 2)}</Text>
222 </Text>
223 {r.sessions.map((g, si) => {
224 const isLast = si === r.sessions.length - 1 && r.searches.length === 0
225 return (
226 <Box key={`s-${g.sessionId}`} flexDirection="column">
227 <Box flexDirection="row">
228 <Text color={META}>{isLast ? '│ └ ' : '│ ├ '}</Text>
229 <Button key={`refer-${g.sessionId}`} plain dimColor label={shortId(g.sessionId)} onPress={() => resume($, g.sessionId)} />
230 <Text wrap="truncate-end">
231 {' '}
232 <Text>{padCells(g.title, titleCells)}</Text>
233 <Text color={META}>
234 {' ' + padStartCells(g.msgs === undefined ? '' : w.msgs(g.msgs), MSGS_CELLS)}
235 {' ' + padStartCells(shortDate(g.date), 5)}
236 </Text>
237 </Text>
238 </Box>
239 {g.hits.slice(0, MAX_HITS).map((hit, hi) => (
240 <Text key={`h-${g.sessionId}-${hi}`} wrap="truncate-end" color={META}>
241 {(isLast ? '│ ' : '│ │ ') + (hit.role === 'user' ? '❯ ' : '⏺ ')}
242 {highlight(truncateCells(snippet(hit.content, terms, snippetCells * 2), snippetCells), terms).map(run =>
243 run.isHit ? (
244 <Text color={MATCH} bold>
245 {run.text}
246 </Text>
247 ) : (
248 run.text
249 ),
250 )}
251 </Text>
252 ))}
253 </Box>
254 )
255 })}
256 {r.searches.length > 0 &&
257 searchesLine(
258 el,
259 $,
260 '│ └ ',
261 r.searches.map(g => g.sessionId),
262 inner,
263 w,
264 )}
265 </Box>
266 ))}
267 <Text color={META} wrap="truncate-end">
268 {'└ '}
269 {hidden > 0 ? `${w.more(hidden)} · ` : ''}
270 {w.pressToResume}
271 </Text>
272 </Box>
273 )
274}
275
276function searchHits(output: unknown): SearchHit[] | undefined {
277 const text = resultText(output)
278 const hits = text ? parseJSON<SearchHit[]>(text) : undefined
279 return Array.isArray(hits) ? hits : undefined
280}
281
282// The hits of a recall_search result, less the running session's own: it
283// matches the words it was just asked, as /recall leaves it out too. The
284// MCP server shortens IDs, so the running session's is matched by prefix.
285async function drawnHits($: EngineInterface, output: unknown): Promise<SearchHit[] | undefined> {
286 const hits = searchHits(output)
287 if (!hits) return undefined
288 const current = await $.session.id().catch(() => '')
289 return current ? hits.filter(hit => !current.startsWith(hit.sessionId)) : hits
290}
291
292function inputQuery(input: unknown): string {
293 return input && typeof input === 'object' && typeof (input as { query?: unknown }).query === 'string'
294 ? (input as { query: string }).query
295 : ''
296}
297
298export const register: Register = on => {
299 // The previous sessions of this repository, above the prompt until the
300 // first prompt is sent.
301 on('session.start', async ($, e, next) => {
302 const started = await next(e)
303 // A refused name (another plugin's /recall) leaves the command out, not
304 // the band.
305 await $.command
306 .register({
307 name: 'recall',
308 description: 'Search past sessions of this repository with claude-recall (--all for every one)',
309 argumentHint: '<query> [--all] | <n>',
310 })
311 .catch((err: unknown) => $.ui.log(`claude-recall: /recall not registered: ${String(err)}`, { to: 'debug' }))
312 const settings = await $.settings.read().catch(() => ({}))
313 await update($, lang, () => langOf((settings as { language?: unknown }).language))
314 if (!e.isInteractive) return started
315 void (async () => {
316 const [{ value: sessions }, id] = await Promise.all([
317 recallJSON<ListedSession[]>($, ['list', '--repo', '.', '--format', 'json', '--limit', '50']),
318 $.session.id(),
319 ])
320 const prev = previousSessions(sessions ?? [], id)
321 await update($, omitted, () => prev.omitted)
322 await update($, previous, () => (prev.shown.length ? prev.shown : null))
323 })().catch(() => {})
324 return started
325 })
326
327 // The band goes once the person does anything: a prompt, or a slash
328 // command (which is not a prompt, so needs a hook of its own).
329 on('prompt.submit', async ($, e, next) => {
330 await hideBand($)
331 return next(e)
332 }).catch(($, e, next) => next(e))
333
334 on('command.run', async ($, e, next) => {
335 await hideBand($)
336 return next(e)
337 }).catch(($, e, next) => next(e))
338
339 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
340 const prev = await read($, previous)
341 if (!prev || (await read($, isBandHidden)) || e.props.hasSurvey) return next(e)
342 if (e.surface !== 'terminal') return next(e)
343 const { Box, Button, Text } = $.ui.resolve(e)
344 const now = await $.clock.now()
345 const w = words(await read($, lang))
346 const left = await read($, omitted)
347 // The border and its padding take two cells a side.
348 const inner = (e.props.bodyColumns ?? e.viewport?.columns ?? 80) - 4
349 return (
350 <Box flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={1}>
351 <Box flexDirection="row">
352 <Box flexGrow={1}>
353 {headerLine(
354 { Text },
355 [
356 { text: 'recall', color: BRAND, bold: true },
357 { text: ` ${w.bandTitle}`, bold: true },
358 { text: ` ${w.bandHint}`, color: META },
359 ...(left > 0 ? [{ text: ` · ${w.searchesOmitted(left)}`, color: META }] : []),
360 ],
361 '',
362 inner - 8,
363 )}
364 </Box>
365 <Button key="hide" plain label={w.close} onPress={() => update($, isBandHidden, () => true)} />
366 </Box>
367 {prev.map(s =>
368 sessionRow(
369 { Box, Button, Text },
370 $,
371 {
372 id: s.sessionId,
373 title: oneLine(s.displayTitle),
374 place: placeOf({ repository: s.repository, worktree: s.worktree, branch: s.gitBranch }, true),
375 when: relativeTime(s.endedAt ?? s.startedAt, now, w),
376 msgs: s.messageCount,
377 },
378 [],
379 inner,
380 w,
381 ),
382 )}
383 </Box>
384 )
385 })
386
387 // recall_search results: the query of each call, for the drawing of a
388 // standalone result row, which does not carry the call's input.
389 on('tool.call', async ($, e, next) => {
390 if (!isRecallTool(e.tool, 'recall_search')) return next(e)
391 const query = typeof (e as { query?: unknown }).query === 'string' ? (e as { query: string }).query : ''
392 await update($, queries, q => ({ ...q, [e.tool_use_id]: query }))
393 return next(e)
394 }).catch(($, e, next) => next(e))
395
396 // The transcript folds MCP calls into one "Called ..." line. A run of
397 // recall_search calls unfolds, so each draws as its own row below; the
398 // ToolSearch that loads the tool's schema first may sit in the same run.
399 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
400 const calls = e.props.calls
401 const isSearch = (tool: string) => isRecallTool(tool, 'recall_search')
402 const isSearchesOnly =
403 calls.some(c => isSearch(c.tool)) &&
404 calls.every(c => (isSearch(c.tool) || c.tool === 'ToolSearch') && !c.isRunning && !c.isErrored)
405 if (e.props.isExpanded || !isSearchesOnly) return next(e)
406 return next({ ...e, props: { ...e.props, isExpanded: true } })
407 })
408
409 // A recall_search row, unfolded or under ctrl+o: the call as one line
410 // (the query, the counts), then its result as a tree. Only the drawing
411 // changes; the model reads the JSON.
412 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
413 const p = e.props
414 if (!isRecallTool(p.tool, 'recall_search') || p.isRunning || p.isErrored || p.isInterrupted) return next(e)
415 if (e.surface !== 'terminal') return next(e)
416 const hits = await drawnHits($, p.output)
417 if (!hits) return next(e)
418 const { Box, Button, Text } = $.ui.resolve(e)
419 const query = inputQuery(p.input)
420 const w = words(await read($, lang))
421 const inner = (e.viewport?.columns ?? 100) - 4
422 return (
423 <Box flexDirection="column">
424 {headerLine(
425 { Text },
426 [
427 { text: '⏺ ', color: 'success' },
428 { text: 'recall', color: BRAND },
429 { text: ` ${query}`, color: MATCH },
430 { text: ` ${w.sessions(groupHits(hits).length)} · ${w.hits(hits.length)}`, color: META },
431 ],
432 '',
433 inner,
434 )}
435 {hits.length > 0 && <Box paddingLeft={2}>{searchBody($, { Box, Button, Text }, hits, query, inner - 2, w)}</Box>}
436 </Box>
437 )
438 })
439
440 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
441 if (!isRecallTool(e.props.tool, 'recall_search') || e.props.isErrored) return next(e)
442 if (e.surface !== 'terminal') return next(e)
443 const hits = await drawnHits($, e.props.output)
444 if (!hits) return next(e)
445 const { Box, Button, Text } = $.ui.resolve(e)
446 const query = (await read($, queries))[e.props.tool_use_id] ?? ''
447 const w = words(await read($, lang))
448 // The transcript indents a tool's result by about five cells.
449 const inner = (e.viewport?.columns ?? 100) - 6
450 return (
451 <Box flexDirection="column">
452 {headerLine(
453 { Text },
454 [{ text: `${w.sessions(groupHits(hits).length)} · ${w.hits(hits.length)}`, color: META }],
455 '',
456 inner,
457 )}
458 {hits.length > 0 && searchBody($, { Box, Button, Text }, hits, query, inner, w)}
459 </Box>
460 )
461 })
462
463 // /recall: search without a model turn. The text is the command's output
464 // the model also reads (so "read number 2" works next), in English; the
465 // terminal draws the same result as a table.
466 on('command.run', { command: 'recall' }, async ($, e) => {
467 await hideBand($)
468 const args = e.args.trim()
469 if (args === '') return { text: `Usage: /${e.command} <query> [--all] | /${e.command} <n>` }
470
471 if (/^\d+$/.test(args)) {
472 const id = (await read($, found))[Number(args) - 1]
473 if (!id) return { text: `There is no result ${args}. Search first with /${e.command} <query>.` }
474 resume($, id)
475 return { text: `Recapping ${shortId(id)}.` }
476 }
477
478 const isAll = /(^|\s)--all(\s|$)/.test(args)
479 const query = args.replace(/(^|\s)--all(\s|$)/g, ' ').trim()
480 // `--` keeps a query that starts with "-" from being read as a flag.
481 const argv = ['search', '--format', 'json', '--limit', '1000', ...(isAll ? [] : ['--repo', '.']), '--', query]
482 const [{ value, error }, current] = await Promise.all([recallJSON<SearchHit[]>($, argv), $.session.id()])
483 if (error !== undefined) return { text: `recall search failed: ${error}` }
484 const groups = groupHits((value ?? []).filter(hit => hit.sessionId !== current))
485 // Sessions that look like a recall search go last, on one line in the
486 // table; nothing is left out, and the numbers follow this order.
487 const isSearch = (g: (typeof groups)[number]) => g.recallOnly
488 const work = groups.filter(g => !isSearch(g))
489 const searches = groups.filter(isSearch)
490 const ordered = [...work, ...searches]
491 await update($, found, () => ordered.map(g => g.sessionId))
492 const scope = isAll ? 'all' : 'repo'
493 const terms = queryTerms(query)
494 const toRow = (g: (typeof groups)[number]): FoundRow => ({
495 id: g.sessionId,
496 title: g.title,
497 place: placeOf(g, !isAll),
498 at: g.date,
499 msgs: g.msgs,
500 hits: g.hits.length,
501 snippet: snippet(g.hits[0]?.content ?? '', terms, 160),
502 })
503 const view: SearchView = {
504 query,
505 scope,
506 total: groups.length,
507 rows: work.slice(0, MAX_ROWS).map(toRow),
508 searches: searches.map(g => g.sessionId),
509 columns: e.presentation.columns,
510 }
511 await update($, views, v => ({ ...v, [args]: view }))
512
513 const en = words('en')
514 if (groups.length === 0) {
515 return { text: `"${query}" · no sessions · ${en.scope[scope]}${isAll ? '' : ' (--all searches every session)'}` }
516 }
517 // The model reads every session, recall searches included, numbered as
518 // `/recall <n>` takes them.
519 const now = await $.clock.now()
520 const lines = ordered
521 .slice(0, MAX_TEXT_ROWS)
522 .map(toRow)
523 .map(
524 (r, i) =>
525 `${String(i + 1).padStart(2)} ${shortId(r.id)} ${r.title} (${r.place} · ${relativeTime(r.at, now)} · ${en.hits(r.hits)})`,
526 )
527 const more = ordered.length > MAX_TEXT_ROWS ? [` ${en.more(ordered.length - MAX_TEXT_ROWS)}`] : []
528 return {
529 text: [`"${query}" · ${en.sessions(groups.length)} · ${en.scope[scope]}`, '', ...lines, ...more].join('\n'),
530 }
531 })
532
533 on('ui.render', { component: 'CommandOutput', props: { command: 'recall' } }, async ($, e, next) => {
534 if (e.surface !== 'terminal' || e.props.isErrored) return next(e)
535 const view = (await read($, views))[e.props.args.trim()]
536 if (!view || view.total === 0) return next(e)
537 const { Box, Button, Text } = $.ui.resolve(e)
538 const terms = queryTerms(view.query)
539 const w = words(await read($, lang))
540 const now = await $.clock.now()
541 // The command's row is indented by about four cells, the border and its
542 // padding take two more a side.
543 const inner = (view.columns ?? e.viewport?.columns ?? 100) - 8
544 return (
545 <Box flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={1}>
546 <Box marginBottom={1}>
547 {headerLine(
548 { Text },
549 [
550 { text: 'recall', color: BRAND, bold: true },
551 { text: ` ${view.query}`, bold: true },
552 { text: ` · ${w.sessions(view.total)} · ${w.scope[view.scope]}`, color: META },
553 ],
554 w.idToResume,
555 inner,
556 )}
557 </Box>
558 {view.rows.map((r, i) =>
559 sessionRow({ Box, Button, Text }, $, { ...r, n: i + 1, when: relativeTime(r.at, now, w) }, terms, inner, w),
560 )}
561 {view.searches.length > 0 && (
562 <Box marginTop={view.rows.length > 0 ? 1 : 0}>
563 {searchesLine({ Box, Button, Text }, $, '', view.searches, inner, w)}
564 </Box>
565 )}
566 {view.total > view.rows.length + view.searches.length && (
567 <Box marginTop={1}>
568 <Text color={META}>{w.more(view.total - view.rows.length - view.searches.length)}</Text>
569 </Box>
570 )}
571 </Box>
572 )
573 })
574}
575hooks/recall.ts 315 lines1// Pure helpers for the plugin's hooks module: reading what the recall CLI
2// and MCP server return, and laying it out in terminal cells. What a
3// session is (its repository, display title, size, whether it was only a
4// look back) comes from recall; nothing here works it out. Nothing here
5// touches `$`.
6
7import type { ListedSession } from '../types'
8
9export type { ListedSession }
10
11// A search hit, from `recall search --format json` or recall_search; the
12// two spell a few fields differently.
13export type SearchHit = {
14 sessionId: string
15 repository?: string
16 worktree?: string
17 gitBranch?: string
18 branch?: string
19 startedAt?: string
20 date?: string
21 displayTitle?: string
22 recallOnly?: boolean
23 messageCount?: number
24 messages?: number
25 role: string
26 content: string
27}
28
29// The hits of one session, in result order.
30export type HitGroup = {
31 sessionId: string
32 repository: string
33 worktree: string
34 branch: string
35 date: string
36 title: string
37 msgs?: number
38 recallOnly: boolean
39 hits: SearchHit[]
40}
41
42export function parseJSON<T>(text: string): T | undefined {
43 try {
44 return JSON.parse(text) as T
45 } catch {
46 return undefined
47 }
48}
49
50// The text of an MCP tool result as the transcript hands it over: a string,
51// a list of content blocks, or `{ content: [...] }`.
52export function resultText(output: unknown): string | undefined {
53 if (typeof output === 'string') return output
54 const blocks = Array.isArray(output)
55 ? output
56 : output && typeof output === 'object' && Array.isArray((output as { content?: unknown }).content)
57 ? (output as { content: unknown[] }).content
58 : undefined
59 if (!blocks) return undefined
60 const texts = blocks
61 .map(b => (b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : ''))
62 .filter(Boolean)
63 return texts.length ? texts.join('\n') : undefined
64}
65
66export function groupHits(hits: SearchHit[]): HitGroup[] {
67 const groups = new Map<string, HitGroup>()
68 for (const hit of hits) {
69 let g = groups.get(hit.sessionId)
70 if (!g) {
71 g = {
72 sessionId: hit.sessionId,
73 repository: hit.repository ?? '',
74 worktree: hit.worktree ?? '',
75 branch: hit.gitBranch ?? hit.branch ?? '',
76 date: hit.startedAt ?? hit.date ?? '',
77 title: oneLine(hit.displayTitle ?? ''),
78 msgs: hit.messageCount ?? hit.messages,
79 recallOnly: hit.recallOnly === true,
80 hits: [],
81 }
82 groups.set(hit.sessionId, g)
83 }
84 g.hits.push(hit)
85 }
86 return [...groups.values()]
87}
88
89// A repository's sessions in a result: the ones drawn in full, and the ones
90// that were only a recall search themselves, drawn together on one line.
91export type RepoGroup = { repo: string; sessions: HitGroup[]; searches: HitGroup[] }
92
93// Sessions grouped by repository, in result order within each. Repositories
94// go in the order their first full session appears; those holding only
95// searches follow, in the order they appear.
96export function groupByRepo(groups: HitGroup[], isSearch: (g: HitGroup) => boolean = () => false): RepoGroup[] {
97 const repos = new Map<string, RepoGroup>()
98 for (const g of groups) {
99 let r = repos.get(g.repository)
100 if (!r) {
101 r = { repo: g.repository, sessions: [], searches: [] }
102 repos.set(g.repository, r)
103 }
104 ;(isSearch(g) ? r.searches : r.sessions).push(g)
105 }
106 const firstFull = (r: RepoGroup) => {
107 const g = r.sessions[0]
108 return g ? groups.indexOf(g) : groups.length
109 }
110 return [...repos.values()].sort((a, b) => firstFull(a) - firstFull(b))
111}
112
113// What fits in `maxLines`: a repository header takes a line, a session one
114// plus one per hit shown (at most `hitsPerSession`), a repository's
115// searches one line together. Taken in order until the next does not fit;
116// the sessions left are counted as hidden.
117export function fitTree(
118 repos: RepoGroup[],
119 maxLines: number,
120 hitsPerSession: number,
121): { shown: RepoGroup[]; hidden: number } {
122 const shown: RepoGroup[] = []
123 let lines = 0
124 let hidden = 0
125 let isFull = false
126 const fits = (cost: number) => {
127 if (isFull || lines + cost > maxLines) {
128 isFull = true
129 return false
130 }
131 lines += cost
132 return true
133 }
134 for (const r of repos) {
135 const taken: HitGroup[] = []
136 for (const g of r.sessions) {
137 if (fits(1 + Math.min(g.hits.length, hitsPerSession) + (taken.length === 0 ? 1 : 0))) taken.push(g)
138 else hidden++
139 }
140 let searches: HitGroup[] = []
141 if (r.searches.length) {
142 if (fits(1 + (taken.length === 0 ? 1 : 0))) searches = r.searches
143 else hidden += r.searches.length
144 }
145 if (taken.length || searches.length) shown.push({ repo: r.repo, sessions: taken, searches })
146 }
147 return { shown, hidden }
148}
149
150// Where a session ran, short: inside the repository being looked at, the
151// worktree or the branch; across repositories, the repository and its
152// worktree.
153export function placeOf(s: { repository: string; worktree?: string; branch?: string }, isOneRepo: boolean): string {
154 if (isOneRepo) return s.worktree || s.branch || s.repository
155 return s.worktree ? `${s.repository} (${s.worktree})` : s.repository
156}
157
158export const shortId = (id: string) => id.slice(0, 8)
159
160export function oneLine(text: string): string {
161 return text.replace(/\s+/g, ' ').trim()
162}
163
164// The part of `text` around the first match of any query term, at most
165// `width` characters, with "…" where it was cut.
166export function snippet(text: string, terms: string[], width = 80): string {
167 const flat = oneLine(text)
168 if (flat.length <= width) return flat
169 const lower = flat.toLowerCase()
170 let at = -1
171 for (const t of terms) {
172 const i = lower.indexOf(t.toLowerCase())
173 if (i >= 0 && (at < 0 || i < at)) at = i
174 }
175 if (at < 0) return flat.slice(0, width - 1) + '…'
176 const start = Math.max(0, at - Math.floor(width / 3))
177 const end = Math.min(flat.length, start + width)
178 return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : '')
179}
180
181// Splits `text` into runs, marking the ones that match a query term.
182export function highlight(text: string, terms: string[]): { text: string; isHit: boolean }[] {
183 const words = terms.filter(t => t.length > 0)
184 if (words.length === 0) return [{ text, isHit: false }]
185 const escaped = words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
186 const re = new RegExp(`(${escaped.join('|')})`, 'gi')
187 return text
188 .split(re)
189 .filter(s => s !== '')
190 .map(s => ({ text: s, isHit: words.some(w => w.toLowerCase() === s.toLowerCase()) }))
191}
192
193// The words of an FTS5 query worth highlighting: phrases without their
194// quotes, operators dropped.
195export function queryTerms(query: string): string[] {
196 const terms: string[] = []
197 const re = /"([^"]+)"|(\S+)/g
198 let m: RegExpExecArray | null
199 while ((m = re.exec(query)) !== null) {
200 const t = m[1] ?? m[2]
201 if (!t || ['AND', 'OR', 'NOT'].includes(t)) continue
202 terms.push(t.replace(/[*()]/g, ''))
203 }
204 return terms.filter(Boolean)
205}
206
207export function shortDate(iso: string): string {
208 // A date alone (recall_search's `date`) is that day wherever the reader is;
209 // Date.parse would read it as UTC midnight and shift it west of UTC.
210 const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)
211 if (day) return `${day[2]}/${day[3]}`
212 const t = Date.parse(iso)
213 if (Number.isNaN(t)) return iso.slice(0, 10)
214 const d = new Date(t)
215 return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
216}
217
218// The sessions to offer at start, from `recall list --repo .`, newest
219// first, without the running one and one-message stubs. Sessions that were
220// only a look back through recall are passed over and counted, so the band
221// can say it left them out: `omitted` counts the ones newer than the last
222// session shown.
223export function previousSessions(
224 sessions: ListedSession[],
225 currentId: string,
226 limit = 3,
227): { shown: ListedSession[]; omitted: number } {
228 const shown: ListedSession[] = []
229 let omitted = 0
230 for (const s of sessions) {
231 if (shown.length === limit) break
232 if (s.sessionId === currentId || s.messageCount <= 2) continue
233 if (s.recallOnly) omitted++
234 else shown.push(s)
235 }
236 return { shown, omitted }
237}
238
239// Terminal cells a character takes: two for East Asian wide and fullwidth
240// characters (CJK, kana, hangul, fullwidth forms), one otherwise.
241function charCells(cp: number): number {
242 if (cp < 0x1100) return 1
243 if (
244 (cp >= 0x1100 && cp <= 0x115f) ||
245 (cp >= 0x2e80 && cp <= 0x303e) ||
246 (cp >= 0x3041 && cp <= 0x33ff) ||
247 (cp >= 0x3400 && cp <= 0x4dbf) ||
248 (cp >= 0x4e00 && cp <= 0x9fff) ||
249 (cp >= 0xa000 && cp <= 0xa4cf) ||
250 (cp >= 0xac00 && cp <= 0xd7a3) ||
251 (cp >= 0xf900 && cp <= 0xfaff) ||
252 (cp >= 0xfe30 && cp <= 0xfe4f) ||
253 (cp >= 0xff00 && cp <= 0xff60) ||
254 (cp >= 0xffe0 && cp <= 0xffe6) ||
255 (cp >= 0x1f300 && cp <= 0x1faff) ||
256 (cp >= 0x20000 && cp <= 0x3fffd)
257 )
258 return 2
259 return 1
260}
261
262export function cells(text: string): number {
263 let n = 0
264 for (const ch of text) n += charCells(ch.codePointAt(0) ?? 0)
265 return n
266}
267
268// `text` cut to at most `max` cells, ending in "…" when it was cut.
269export function truncateCells(text: string, max: number): string {
270 if (cells(text) <= max) return text
271 if (max <= 0) return ''
272 let out = ''
273 let n = 0
274 for (const ch of text) {
275 const w = charCells(ch.codePointAt(0) ?? 0)
276 if (n + w > max - 1) break
277 out += ch
278 n += w
279 }
280 return out + '…'
281}
282
283// `text` cut or padded with spaces to exactly `width` cells.
284export function padCells(text: string, width: number): string {
285 const cut = truncateCells(text, width)
286 return cut + ' '.repeat(Math.max(0, width - cells(cut)))
287}
288
289// `text` cut to `width` cells and right-aligned in them.
290export function padStartCells(text: string, width: number): string {
291 const cut = truncateCells(text, width)
292 return ' '.repeat(Math.max(0, width - cells(cut))) + cut
293}
294
295export type RowLayout = { title: number; place: number; indent: number }
296
297// Cells of the message count column, "12345 msgs" at most.
298export const MSGS_CELLS = 10
299
300// Cells of the time column: "11時間前" and "just now" at most.
301export const WHEN_CELLS = 8
302
303// Column widths for a session row `inner` cells wide: number (when the rows
304// are numbered), ID, title, place, message count, time, hit count (when
305// counted). The place goes first when the row is narrow, then the title
306// shrinks to its floor.
307export function rowLayout(inner: number, isNumbered: boolean, hasHits: boolean, hasMsgs = false): RowLayout {
308 const lead = (isNumbered ? 4 : 0) + 8 + 2
309 const tail = 2 + WHEN_CELLS + (hasHits ? 2 + 4 : 0) + (hasMsgs ? 2 + MSGS_CELLS : 0)
310 const room = inner - lead - tail
311 const place = room >= 48 ? Math.min(28, Math.max(16, Math.floor(room * 0.3))) : 0
312 const title = Math.max(10, room - (place ? place + 2 : 0))
313 return { title, place, indent: lead }
314}
315hooks/i18n.ts 73 lines1// The words the drawings show, in English unless Claude Code's `language`
2// setting names Japanese. What the model reads (a command's output) stays
3// English whatever the setting.
4
5export type Lang = 'en' | 'ja'
6
7// The language of Claude Code's `language` setting, which people write as
8// they like ("japanese", "Japanese", "日本語", "ja").
9export function langOf(setting: unknown): Lang {
10 return typeof setting === 'string' && /^(ja|japanese|日本語)/i.test(setting.trim()) ? 'ja' : 'en'
11}
12
13const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
14
15const en = {
16 bandTitle: 'Pick up where you left off',
17 bandHint: '(press an ID to resume)',
18 close: 'Close',
19 sessions: (n: number) => plural(n, 'session', 'sessions'),
20 hits: (n: number) => plural(n, 'hit', 'hits'),
21 more: (n: number) => plural(n, 'more session', 'more sessions'),
22 msgs: (n: number) => `${n} msgs`,
23 searches: (n: number) => plural(n, 'recall search', 'recall searches'),
24 searchesOmitted: (n: number) => `${plural(n, 'recall search', 'recall searches')} left out`,
25 pressToResume: 'press an ID to resume',
26 idToResume: 'ID to resume',
27 noRecap: "recall's recap prompt is not available here; update recall to resume a session",
28 scope: { repo: 'this repository', all: 'all' },
29 justNow: 'just now',
30 minutesAgo: (n: number) => `${n}m ago`,
31 hoursAgo: (n: number) => `${n}h ago`,
32 daysAgo: (n: number) => `${n}d ago`,
33}
34
35const ja: typeof en = {
36 bandTitle: '前回の続き',
37 bandHint: '(ID を押すと再開できます)',
38 close: '閉じる',
39 sessions: n => `${n} セッション`,
40 hits: n => `${n} 件`,
41 more: n => `ほか ${n} セッション`,
42 msgs: n => `${n} msgs`,
43 searches: n => `recall の検索 ${n} 件`,
44 searchesOmitted: n => `recall の検索 ${n} 件は省略`,
45 pressToResume: 'ID を押すと再開',
46 idToResume: 'ID で再開',
47 noRecap: 'recall の recap prompt が見つかりません。セッションを再開するには recall を更新してください',
48 scope: { repo: 'このリポジトリ', all: 'すべて' },
49 justNow: 'たった今',
50 minutesAgo: n => `${n}分前`,
51 hoursAgo: n => `${n}時間前`,
52 daysAgo: n => `${n}日前`,
53}
54
55export type Words = typeof en
56
57export const words = (lang: Lang): Words => (lang === 'ja' ? ja : en)
58
59// How long ago `iso` was, in the drawing's words; a month and day past a week.
60export function relativeTime(iso: string, now: number, w: Words = en): string {
61 const t = Date.parse(iso)
62 if (Number.isNaN(t)) return ''
63 const min = Math.round((now - t) / 60000)
64 if (min < 1) return w.justNow
65 if (min < 60) return w.minutesAgo(min)
66 const hours = Math.round(min / 60)
67 if (hours < 24) return w.hoursAgo(hours)
68 const days = Math.round(hours / 24)
69 if (days < 7) return w.daysAgo(days)
70 const d = new Date(t)
71 return `${d.getMonth() + 1}/${d.getDate()}`
72}
73types/index.d.ts 63 lines1// A session as `recall list --format json` prints it.
2export type ListedSession = {
3 sessionId: string
4 projectPath: string
5 gitBranch?: string
6 firstPrompt?: string
7 messageCount: number
8 startedAt: string
9 endedAt?: string
10 title?: string
11 // What the session is called, as the TUI shows it: the title, or else
12 // the first prompt made readable.
13 displayTitle: string
14 // The session called recall's MCP tools and no other tool, so it was
15 // only a look back.
16 recallOnly: boolean
17 // The repository the session belongs to, as the TUI groups it, and the
18 // worktree inside it, when it ran in one.
19 repository: string
20 worktree?: string
21}
22
23// One session of a search, ready to draw.
24export type FoundRow = {
25 id: string
26 title: string
27 place: string
28 // When the session last ran, ISO 8601; drawn relative to now.
29 at: string
30 msgs?: number
31 hits: number
32 snippet: string
33}
34
35// What /recall drew for one run, keyed by its args.
36export type SearchView = {
37 query: string
38 scope: 'repo' | 'all'
39 total: number
40 // The sessions drawn in full, at most a screenful.
41 rows: FoundRow[]
42 // The sessions that look like they were only a recall search, drawn
43 // together on one line after the rows.
44 searches: string[]
45 // The terminal's width when the command ran.
46 columns?: number
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 'claude-recall': {
52 previous: ListedSession[] | null
53 // How many recall searches the band left out to show work sessions.
54 omitted: number
55 isBandHidden: boolean
56 queries: Record<string, string>
57 found: string[]
58 views: Record<string, SearchView>
59 lang: 'en' | 'ja'
60 }
61 }
62}
63