SLOPSHOPPER

claude-recall

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

newbandrowsguardcommandtoast
★ 26v1.8.1no licenseupdated 2026-10-09babarot/claude-recall/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-recall
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /recall ⎿ claude-recall: Usage: /recall <query> [--all] | /recall <n> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-recall

Test

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

The recall TUI: looking through sessions, narrowing to a folder and what was said, reading a conversation, and asking Claude

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:

  • MCP server: Claude searches past sessions and reads what was done in them, from inside the session you are working in
  • TUI: find a session yourself and resume it where it left off
  • Web UI: read past conversations comfortably in the browser, live as they are written

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.

Quick start

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.

Use cases

Pick up where an earlier task left off

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.

Answer a question you already answered months ago

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.

How to recall

You want toUseWhat only it gives you
Have Claude remember what happened in a past sessionMCP serverThe answer lands in the session you are working in, as context Claude can use right away
Find a session yourself and go back to itTUIResume 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 itWeb UIA comfortable reader in the browser, for long conversations and images
Look back without leaving the sessionIn Claude CodeThe 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.

MCP server

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.

ToolDescription
recall_searchFull-text search across past sessions, optionally within one repository (repo); each hit names its session's title, size and repository
recall_listList archived sessions, optionally of one repository (repo)
recall_exportExport a session's full conversation, or with tail only its last messages
recall_statsShow 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".

TUI

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.

KeyAction
EnterResume the session: claude -r <id> from the session's folder
cRecall the session in a new claude through MCP: for a session claude -r cannot resume, such as one whose worktree was removed
y / YCopy 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
aAsk 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
SpaceRead 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.

Web UI

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.

In Claude Code

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.

Starting Claude Code in a repository: the band above the prompt lists the sessions that last ran there

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.

Asking Claude to look something up with recall: the recall_search result drawn as a tree, then Claude's answer

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

/recall searching this repository, then every repository with --all

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.

Why

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.

Why not just claude-mem?

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-memclaude-recall
GoalExtend the agent's memoryHelp you and the agent recall
InjectionPush (auto-injected at SessionStart)Pull (looked up when needed)
What's storedLLM-summarized observationsRaw conversation, noise-stripped
SearchFTS5 + Chroma vector hybridFTS5 only (deterministic)
LLM callsDuring indexing, through a hosted observer, your own OpenRouter or Gemini key, or your Anthropic planNever while storing or searching; only when you ask Claude from the TUI (a), through your own claude
Where data livesLocal, with optional cloud syncLocal only
AgentsClaude Code, Codex, Gemini, OpenCode and moreClaude Code
RuntimeNode + Bun + Python (uv) + Chroma, resident workerOne static binary, no daemon

The tradeoff claude-recall picks:

  • Raw logs don't drift. What's stored is what happened, not a summary of it.
  • The agent says when it doesn't know. Past context comes from a tool call, not from memory it may misremember.
  • Idle means idle. No worker, no background LLM calls.
  • One binary, works offline.

Install

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:

Installrecall on PATHImportMCP server
curlYesYesYes, when claude is on PATH
NixYesNoNo
Build from sourceYesNoNo
Claude Code pluginNoWhen a Claude Code session startsYes

Whatever is left is in Set up.

curl

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.

Nix

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.

Build from source

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.

Claude Code plugin

The plugin is the recommended way to connect claude-recall to Claude Code. plugin/ bundles, with recall on PATH:

ComponentWhat it does
MCP serverRuns recall mcp
SessionEnd hookRuns recall import when a session ends
Hooks moduleDraws 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.

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.

Upgrade

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.

Your archive

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.

StoredExcluded
User and assistant textSystem events (turn_duration and others)
ThinkingFile history snapshots
Tool calls and their resultsSidechains
Slash-command expansions, task notificationsProgress, queue operations and other bookkeeping

Configuration

~/.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].

CLI

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.

Development

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).

Tech stack

License

MIT

Source 4 files
hooks/register.tsx 575 lines
1import { 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}
575
hooks/recall.ts 315 lines
1// 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}
315
hooks/i18n.ts 73 lines
1// 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}
73
types/index.d.ts 63 lines
1// 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