Start Claude in a folder of projects, browse them above the prompt, then open a new session or continue a recent one without leaving the terminal

A Claude Code mod for when you keep all your projects under one folder. Start Claude in that folder, pick a project above the prompt, then open a new session there or continue a recent one, without leaving the terminal.

<sub>The projects in the demo are made up; docs/make_demo.py draws it.</sub>
Projects. When Claude starts in a folder that looks like a folder of projects, a band above the input lists its child folders as 📁 rows, newest activity first, with how many sessions each one has. Repositories also show their branch, and ● when they have uncommitted changes (⎇ main ●), or ? for a repository whose own config (not your global one) defines filters or cannot be read, where asking git for status could run them; that part fills in a moment later so a large repository never holds the list back.
▲ / ▼ (k / j) also page through long lists; x closes the band.Sessions. Opening a project shows its recent sessions by title or last prompt, with its branch in the header.
While a folder or a project's sessions are being read, a spinner turns in the band.
n starts a new session in that project (/cd + /clear).c continues the most recent one, b goes back to the projects.| Command | What it does |
|---|---|
/projects [folder] | Show the projects in folder; with no folder, here or the current project's parent |
From inside Claude Code:
/plugin marketplace add aliemrevezir/mods
/plugin install project-hop@aliemrevezir-mods
Or load it straight from a clone:
git clone https://github.com/aliemrevezir/mods
claude --plugin-dir mods/plugins/project-hop
ls, stat, sed and grep; sessions are read from ~/.claude/projects.claude plugin validate .
claude plugin test .
Folder and transcript scanning lives in hooks/scan.ts; the band, the picks and the hop itself live in hooks/register.tsx.
hooks/register.tsx 301 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { HopView, Project } from '../types'
5import {
6 GIT_SCRIPT,
7 PROJECTS_SCRIPT,
8 SESSIONS_SCRIPT,
9 clip,
10 encodeProjectDir,
11 filterProjects,
12 looksLikeRoot,
13 matchProject,
14 matchSession,
15 parentOf,
16 paginate,
17 parseGit,
18 parseProjects,
19 parseSessions,
20 spinnerFrame,
21 timeAgo,
22 withGit,
23} from './scan'
24
25const HIDDEN: HopView = { kind: 'hidden' }
26const view = atom({ plugin: 'project-hop', key: 'view' } as const, HIDDEN)
27
28const SESSION_LIMIT = 40
29// Focus stops drawn above and below a page: the arrows reaching one turn the page.
30const EDGE_UP = 'page-up'
31const EDGE_DOWN = 'page-down'
32// Rows per page as last drawn, so a page turned by the arrows knows where to land.
33let pageSize = 8
34
35const scanProjects = async ($: EngineInterface, root: string): Promise<Project[]> => {
36 const { stdout } = await $.process.run(['sh', '-c', PROJECTS_SCRIPT, 'sh', root])
37 return parseProjects(root.replace(/\/+$/, ''), stdout)
38}
39
40// Scans still running, by what the band says about them; a spinner turns while any is.
41// The module's own: a reload starts with none, and the scans it starts again fill it.
42const jobs = new Map<string, string>()
43let tick = 0
44let spin: Timer | undefined
45
46const busy = async <T,>($: EngineInterface, job: string, label: string, work: () => Promise<T>): Promise<T> => {
47 jobs.set(job, label)
48 spin ??= $.clock.every(100, () => {
49 tick += 1
50 $.ui.invalidate('ui.render')
51 })
52 $.ui.invalidate('ui.render')
53 try {
54 return await work()
55 } finally {
56 jobs.delete(job)
57 if (jobs.size === 0) {
58 spin?.cancel()
59 spin = undefined
60 }
61 $.ui.invalidate('ui.render')
62 }
63}
64
65// The branch and dirty mark come after the list is up, so a large repository never holds the band back.
66const scanGit = ($: EngineInterface, root: string) =>
67 busy($, 'git', 'git', async () => {
68 const { stdout } = await $.process.run(['sh', '-c', GIT_SCRIPT, 'sh', root])
69 const git = parseGit(stdout)
70 await update($, view, (v): HopView => (v.kind === 'projects' && v.root === root ? { ...v, projects: withGit(v.projects, git) } : v))
71 })
72
73const showProjects = async ($: EngineInterface, root: string, projects?: Project[]) => {
74 const list = projects ?? (await busy($, 'scan', 'scanning projects', () => scanProjects($, root)))
75 await update($, view, (): HopView => ({ kind: 'projects', root, projects: list, filter: '', page: 0 }))
76 if (list.some(p => p.isRepo)) later($, () => scanGit($, root))
77}
78
79const openProject = async ($: EngineInterface, root: string, project: Project) => {
80 const { stdout } = await busy($, 'scan', `reading ${project.name} sessions`, () =>
81 $.process.run(['sh', '-c', SESSIONS_SCRIPT, 'sh', encodeProjectDir(project.path), String(SESSION_LIMIT * 2)]))
82 const sessions = parseSessions(stdout).slice(0, SESSION_LIMIT)
83 await update($, view, (): HopView => ({ kind: 'sessions', root, project, sessions, page: 0 }))
84}
85
86const sessionCount = (n: number): string => `${n} session${n === 1 ? '' : 's'}`
87
88const gitLabel = (git: Project['git']): string => (git ? `⎇ ${clip(git.branch, 14)}${git.unchecked ? ' ?' : git.dirty ? ' ●' : ''}` : '')
89
90// The band only scrolls by wheel in the fullscreen layout, so the lists page instead.
91const turnPage = ($: EngineInterface, by: number) =>
92 update($, view, (v): HopView => (v.kind === 'hidden' ? v : { ...v, page: (v.page || 0) + by }))
93
94const quote = (path: string) => (/\s/.test(path) ? `"${path}"` : path)
95
96// Moves this session into the project, then starts fresh there or picks a saved conversation back up.
97const hop = async ($: EngineInterface, project: Project, resumeId?: string) => {
98 await update($, view, () => HIDDEN)
99 $.ui.status(undefined)
100 await $.command.run({ command: 'cd', args: quote(project.path) })
101 if (resumeId !== undefined) {
102 await $.command.run({ command: 'resume', args: resumeId })
103 } else if ((await $.session.turns()) > 0) {
104 await $.command.run({ command: 'clear' })
105 }
106}
107
108// prompt.submit holds the turn, so the commands run once it has let go.
109const later = ($: EngineInterface, fn: () => Promise<void>) => {
110 $.clock.after(0, () => {
111 fn().catch(error => $.ui.toast(`project-hop: ${String(error)}`))
112 })
113}
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 const result = await next(e)
118 await $.command.register({
119 name: 'projects',
120 description: 'Browse project folders and open a new or recent session',
121 argumentHint: '[folder]',
122 })
123 const shown = await read($, view)
124 if (shown.kind !== 'hidden') {
125 // A reload keeps the band's state but not how it was gathered: scan again.
126 later($, () => (shown.kind === 'sessions' ? openProject($, shown.root, shown.project) : showProjects($, shown.root)))
127 } else if (e.isInteractive) {
128 later($, async () => {
129 const projects = await scanProjects($, e.cwd)
130 if (looksLikeRoot(projects, (await $.session.repo()) !== null)) await showProjects($, e.cwd, projects)
131 })
132 }
133 return result
134 })
135
136 on('command.run', { command: 'projects' }, async ($, e) => {
137 let root = e.args.trim()
138 if (root === '') {
139 const cwd = await $.session.cwd()
140 const here = await scanProjects($, cwd)
141 root = looksLikeRoot(here, (await $.session.repo()) !== null) ? cwd : parentOf(await $.session.root())
142 }
143 await showProjects($, root)
144 return { text: root }
145 })
146
147 // A number or a project's name typed while the band shows picks instead of reaching the model.
148 on('prompt.submit', async ($, e, next) => {
149 const current = await read($, view)
150 if (current.kind === 'projects') {
151 const pick = matchProject(e.text, current.projects)
152 if (pick.kind === 'project') {
153 later($, () => openProject($, current.root, pick.project))
154 return { drop: `project-hop: ${pick.project.name}` }
155 }
156 if (pick.kind === 'filter') {
157 await update($, view, (): HopView => ({ ...current, filter: pick.filter, page: 0 }))
158 return { drop: `project-hop: filtered by "${pick.filter}"` }
159 }
160 }
161 if (current.kind === 'sessions') {
162 const pick = matchSession(e.text, current.sessions)
163 if (pick.kind === 'back') {
164 later($, () => showProjects($, current.root, undefined))
165 return { drop: 'project-hop: projects' }
166 }
167 if (pick.kind === 'new') {
168 later($, () => hop($, current.project))
169 return { drop: `project-hop: ${current.project.name} · new session` }
170 }
171 if (pick.kind === 'resume') {
172 later($, () => hop($, current.project, pick.id))
173 return { drop: `project-hop: ${current.project.name} · resume` }
174 }
175 }
176 return next(e)
177 })
178
179 // Arrowing past a page's first or last row lands on an edge stop: turn the page and
180 // put the ring on the row that comes next, so the arrows walk the whole list.
181 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
182 if (e.origin.kind !== 'person' || (e.element !== EDGE_UP && e.element !== EDGE_DOWN)) return next(e)
183 const current = await read($, view)
184 if (current.kind === 'hidden') return next(e)
185 const by = e.element === EDGE_DOWN ? 1 : -1
186 const total = current.kind === 'projects' ? filterProjects(current.projects, current.filter).length : current.sessions.length
187 const at = paginate(Array.from({ length: total }), pageSize, current.page).at + by
188 const index = Math.min(total - 1, by > 0 ? at * pageSize : at * pageSize + pageSize - 1)
189 const key = `${current.kind === 'projects' ? 'project' : 'session'}-${index}`
190 await update($, view, (v): HopView => (v.kind === 'hidden' ? v : { ...v, page: at }))
191 later($, async () => {
192 await $.ui.focus({ requestId: e.requestId, key })
193 })
194 return {}
195 })
196
197 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
198 const current = await read($, view)
199 if (e.props.hasSurvey || e.props.isWorking) return next(e)
200 const scanning = jobs.get('scan')
201 const { Box, Button, Text } = $.ui.resolve(e)
202 const spinner = (label: string | undefined) =>
203 label === undefined ? null : <Text key="spinner" color="cyan">{spinnerFrame(tick)} {label}… </Text>
204 if (current.kind === 'hidden') return scanning === undefined ? next(e) : <Box>{spinner(scanning)}</Box>
205
206 const now = await $.clock.now()
207 const width = e.props.bodyColumns
208 const close = <Button key="close" label="× close" hotkey="x" onPress={() => update($, view, () => HIDDEN)} />
209 // Header, footer and the two edge stops take a row each; the rest is the page.
210 const pageRows = Math.max(3, e.props.maxRows - 4)
211 pageSize = pageRows
212 const edge = (key: string, label: string, by: number, shown: boolean) =>
213 shown ? <Button key={key} label={label} plain dimColor onPress={() => turnPage($, by)} /> : null
214 const pager = (at: number, pages: number) =>
215 pages > 1 ? (
216 <Box key="pager">
217 <Button key="prev" label="▲" hotkey="k" onPress={() => turnPage($, -1)} />
218 <Text dimColor> {at + 1}/{pages} </Text>
219 <Button key="next" label="▼" hotkey="j" onPress={() => turnPage($, 1)} />
220 <Text> </Text>
221 </Box>
222 ) : null
223
224 if (current.kind === 'projects') {
225 const shown = filterProjects(current.projects, current.filter)
226 const page = paginate(shown, pageRows, current.page)
227 const nameWidth = Math.max(12, Math.min(32, width - 48))
228 const gitWidth = shown.some(p => p.git) ? 20 : 0
229
230 return (
231 <Box flexDirection="column">
232 <Box>
233 <Text bold>▸ {clip(current.root, Math.max(10, width - 50))}</Text>
234 <Text dimColor>
235 {' '}
236 {shown.length}/{current.projects.length} projects{current.filter ? ` · "${current.filter}"` : ''}{' '}
237 </Text>
238 {spinner(scanning ?? (jobs.has('git') ? 'git' : undefined))}
239 {pager(page.at, page.pages)}
240 {current.filter ? (
241 <Button key="unfilter" label="clear filter" onPress={() => update($, view, (): HopView => ({ ...current, filter: '', page: 0 }))} />
242 ) : null}
243 {close}
244 </Box>
245 {edge(EDGE_UP, ' ▲ previous page', -1, page.at > 0)}
246 {page.rows.map((project, r) => {
247 const index = page.start + r
248 const sessions = project.sessions > 0 ? ` · ${sessionCount(project.sessions)}` : ''
249 return (
250 <Button
251 key={`project-${index}`}
252 label={`📁 ${clip(project.name, nameWidth).padEnd(nameWidth)} ${gitLabel(project.git).padEnd(gitWidth)}${timeAgo(project.lastActive, now)}${sessions}`}
253 plain
254 dimColor={project.sessions === 0}
255 autoFocus={r === 0 ? true : undefined}
256 onPress={() => openProject($, current.root, project)}
257 />
258 )
259 })}
260 {edge(EDGE_DOWN, ' ▼ next page', 1, page.at < page.pages - 1)}
261 <Text dimColor>ctrl+x tab → ↑↓ to move, Enter to open · or type a name + Enter</Text>
262 </Box>
263 )
264 }
265
266 const page = paginate(current.sessions, pageRows, current.page)
267 return (
268 <Box flexDirection="column">
269 <Box>
270 <Text bold>▸ {current.project.name}</Text>
271 {current.project.git ? <Text color="green"> {gitLabel(current.project.git)}</Text> : null}
272 <Text dimColor> {sessionCount(current.project.sessions)} </Text>
273 {spinner(scanning)}
274 {pager(page.at, page.pages)}
275 <Button key="new" label="n new" hotkey="n" variant="primary" onPress={() => hop($, current.project)} />
276 <Text> </Text>
277 <Button key="back" label="b back" hotkey="b" onPress={() => showProjects($, current.root, undefined)} />
278 <Text> </Text>
279 {close}
280 </Box>
281 {current.sessions.length === 0 ? <Text dimColor> No saved sessions in this project yet; press n to start one.</Text> : null}
282 {edge(EDGE_UP, ' ▲ previous page', -1, page.at > 0)}
283 {page.rows.map((session, r) => {
284 const i = page.start + r
285 return (
286 <Button
287 key={`session-${i}`}
288 label={`${String(i + 1).padStart(2)} ${timeAgo(session.mtime, now).padEnd(9)} ${clip(session.title, Math.max(20, width - 16))}`}
289 plain
290 autoFocus={r === 0 ? true : undefined}
291 onPress={() => hop($, current.project, session.id)}
292 />
293 )
294 })}
295 {edge(EDGE_DOWN, ' ▼ next page', 1, page.at < page.pages - 1)}
296 <Text dimColor>↑↓ + Enter or a number to resume · c = latest · n = new · b = back</Text>
297 </Box>
298 )
299 })
300}
301hooks/scan.ts 189 lines1import type { GitState, Project, SessionInfo } from '../types'
2
3const FIELD = '\x1e'
4
5// One line per child folder of $1: name, newest transcript's mtime, transcript count, has .git.
6// Claude keeps a folder's transcripts under ~/.claude/projects/<path, every non-alphanumeric as ->.
7export const PROJECTS_SCRIPT = `
8base="$HOME/.claude/projects"
9for d in "$1"/*/; do
10 d="\${d%/}"; [ -d "$d" ] || continue
11 enc=$(printf %s "$d" | sed 's/[^a-zA-Z0-9]/-/g')
12 newest=$(ls -t "$base/$enc"/*.jsonl 2>/dev/null | head -1)
13 t=0; [ -n "$newest" ] && t=$(stat -f %m "$newest" 2>/dev/null || stat -c %Y "$newest")
14 c=$(ls "$base/$enc"/*.jsonl 2>/dev/null | wc -l | tr -d ' ')
15 g=0; [ -e "$d/.git" ] && g=1
16 printf '%s${FIELD}%s${FIELD}%s${FIELD}%s\\n' "$(basename "$d")" "$t" "$c" "$g"
17done
18`
19
20// One line per repository child of $1: name, branch (short commit when detached), 1 when the tree has changes.
21// Every repository is asked at once, since a large one's status can take a while.
22// A cloned repository's own config can name commands status would run: core.fsmonitor is switched
23// off from the command line, which outranks it; a repository where a filter or an external diff
24// comes from anywhere but the person's system or global config (its .git/config, config.worktree,
25// a file either includes) is not asked for status at all, and reports 2.
26// Reading config runs nothing. --no-optional-locks keeps status from rewriting the index.
27export const GIT_SCRIPT = `
28for d in "$1"/*/; do
29 d="\${d%/}"; [ -e "$d/.git" ] || continue
30 (
31 g() { git -C "$d" -c core.fsmonitor=false -c core.untrackedCache=false --no-optional-locks "$@" 2>/dev/null; }
32 b=$(g symbolic-ref --short -q HEAD || g rev-parse --short HEAD)
33 x=0
34 # Exit 1 is "none found"; anything above (an old git, a config git cannot read) fails closed.
35 f=$(g config --show-scope --includes --name-only --get-regexp '^filter\\.|^diff\\.external$'); r=$?
36 if [ "$r" -gt 1 ] || printf '%s\\n' "$f" | grep -qvE '^(system|global)[[:space:]]|^$'; then x=2
37 elif [ -n "$(g status --porcelain --ignore-submodules | head -1)" ]; then x=1; fi
38 printf '%s${FIELD}%s${FIELD}%s\\n' "$(basename "$d")" "$b" "$x"
39 ) &
40done
41wait
42`
43
44export const parseGit = (stdout: string): Map<string, GitState> =>
45 new Map(
46 stdout.split('\n').flatMap(line => {
47 const [name = '', branch = '', dirty = '0'] = line.split(FIELD)
48 if (!line.includes(FIELD) || name === '' || branch === '') return []
49 const state: GitState = dirty === '2' ? { branch, dirty: false, unchecked: true } : { branch, dirty: dirty === '1' }
50 return [[name, state] as const]
51 }),
52 )
53
54export const withGit = (projects: readonly Project[], git: ReadonlyMap<string, GitState>): Project[] =>
55 projects.map(p => {
56 const state = git.get(p.name)
57 return state ? { ...p, git: state } : p
58 })
59
60// The newest $2 transcripts of $1: id, mtime, the last custom-title line, the last last-prompt line.
61export const SESSIONS_SCRIPT = `
62cd "$HOME/.claude/projects/$1" 2>/dev/null || exit 0
63ls -t ./*.jsonl 2>/dev/null | head -n "$2" | while IFS= read -r f; do
64 t=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f")
65 ct=$(grep -h '"type":"custom-title"' "$f" | tail -1)
66 lp=$(grep -h '"type":"last-prompt"' "$f" | tail -1)
67 id=$(basename "$f" .jsonl)
68 printf '%s${FIELD}%s${FIELD}%s${FIELD}%s\\n' "$id" "$t" "$ct" "$lp"
69done
70`
71
72export const encodeProjectDir = (path: string): string => path.replace(/[^a-zA-Z0-9]/g, '-')
73
74export const joinPath = (root: string, name: string): string => `${root.replace(/\/+$/, '')}/${name}`
75
76export const parentOf = (path: string): string => {
77 const trimmed = path.replace(/\/+$/, '')
78 const at = trimmed.lastIndexOf('/')
79 return at <= 0 ? '/' : trimmed.slice(0, at)
80}
81
82// Most recently used first, then folders Claude never ran in, alphabetically.
83export const parseProjects = (root: string, stdout: string): Project[] =>
84 stdout
85 .split('\n')
86 .filter(line => line.includes(FIELD))
87 .map(line => {
88 const [name = '', t = '0', c = '0', g = '0'] = line.split(FIELD)
89 return {
90 name,
91 path: joinPath(root, name),
92 lastActive: Number(t) || 0,
93 sessions: Number(c) || 0,
94 isRepo: g === '1',
95 }
96 })
97 .filter(p => p.name !== '' && !p.name.startsWith('.'))
98 .sort((a, b) => b.lastActive - a.lastActive || a.name.localeCompare(b.name))
99
100const field = (jsonLine: string, key: string): string | undefined => {
101 if (jsonLine === '') return undefined
102 try {
103 const value = (JSON.parse(jsonLine) as Record<string, unknown>)[key]
104 return typeof value === 'string' && value.trim() !== '' ? value : undefined
105 } catch {
106 return undefined
107 }
108}
109
110const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
111
112// A transcript with neither a title nor a prompt never had a conversation, so it is left out.
113export const parseSessions = (stdout: string): SessionInfo[] =>
114 stdout.split('\n').flatMap(line => {
115 if (!line.includes(FIELD)) return []
116 const [id = '', t = '0', titleLine = '', promptLine = ''] = line.split(FIELD)
117 const title = field(titleLine, 'customTitle') ?? field(promptLine, 'lastPrompt')
118 return id === '' || title === undefined ? [] : [{ id, mtime: Number(t) || 0, title: oneLine(title) }]
119 })
120
121// A folder of projects: not itself a repository, and at least two children that are or that Claude ran in.
122export const looksLikeRoot = (projects: readonly Project[], isRepo: boolean): boolean =>
123 !isRepo && projects.filter(p => p.isRepo || p.sessions > 0).length >= 2
124
125export const timeAgo = (seconds: number, now: number): string => {
126 if (seconds <= 0) return '—'
127 const s = Math.max(0, Math.round(now / 1000 - seconds))
128 if (s < 60) return 'just now'
129 if (s < 3600) return `${Math.floor(s / 60)}m ago`
130 if (s < 86400) return `${Math.floor(s / 3600)}h ago`
131 if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`
132 return `${Math.floor(s / (86400 * 30))}mo ago`
133}
134
135// A braille spinner, one frame per tick.
136export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
137export const spinnerFrame = (tick: number): string => SPINNER[((tick % SPINNER.length) + SPINNER.length) % SPINNER.length]!
138
139export const clip = (text: string, width: number): string =>
140 text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
141
142// What a typed line means while the band shows: a pick, a narrowed list, or a prompt for the model.
143export type TypedPick =
144 | { kind: 'project'; project: Project }
145 | { kind: 'filter'; filter: string }
146 | { kind: 'none' }
147
148// The folders carry no numbers, so only a name picks one.
149export const matchProject = (text: string, all: readonly Project[]): TypedPick => {
150 const typed = text.trim()
151 // A sentence is a prompt; only a single word is read as a project name.
152 if (typed === '' || /\s/.test(typed)) return { kind: 'none' }
153 const needle = typed.toLowerCase()
154 const exact = all.find(p => p.name.toLowerCase() === needle)
155 if (exact) return { kind: 'project', project: exact }
156 const hits = all.filter(p => p.name.toLowerCase().includes(needle))
157 if (hits.length === 1) return { kind: 'project', project: hits[0]! }
158 if (hits.length > 1) return { kind: 'filter', filter: typed }
159 return { kind: 'none' }
160}
161
162export type SessionPick = { kind: 'new' } | { kind: 'resume'; id: string } | { kind: 'back' } | { kind: 'none' }
163
164export const matchSession = (text: string, sessions: readonly SessionInfo[]): SessionPick => {
165 const typed = text.trim().toLowerCase()
166 if (typed === 'n' || typed === 'new') return { kind: 'new' }
167 if (typed === 'b' || typed === '..' || typed === 'back') return { kind: 'back' }
168 if ((typed === 'c' || typed === 'continue') && sessions[0]) {
169 return { kind: 'resume', id: sessions[0].id }
170 }
171 if (/^\d+$/.test(typed)) {
172 const session = sessions[Number(typed) - 1]
173 return session ? { kind: 'resume', id: session.id } : { kind: 'none' }
174 }
175 return { kind: 'none' }
176}
177
178// Splits rows into pages of `size`, clamping `page` into range.
179export const paginate = <T,>(rows: readonly T[], size: number, page: number) => {
180 const pages = Math.max(1, Math.ceil(rows.length / size))
181 const at = Math.min(Math.max(0, Number.isFinite(page) ? page : 0), pages - 1)
182 return { at, pages, start: at * size, rows: rows.slice(at * size, (at + 1) * size) }
183}
184
185export const filterProjects = (projects: readonly Project[], filter: string): Project[] => {
186 const needle = filter.trim().toLowerCase()
187 return needle === '' ? [...projects] : projects.filter(p => p.name.toLowerCase().includes(needle))
188}
189types/index.d.ts 38 lines1// A child folder of the root, with how recently Claude ran there.
2export type Project = {
3 name: string
4 path: string
5 // Seconds since the epoch of the newest session transcript; 0 when none.
6 lastActive: number
7 sessions: number
8 isRepo: boolean
9 // Filled in by a second, slower scan of the repositories; absent until it lands.
10 git?: GitState
11}
12
13// A repository's checked-out branch (or short commit when detached) and whether its tree has changes.
14// `unchecked`: its config defines commands git status could run, so status was never asked.
15export type GitState = { branch: string; dirty: boolean; unchecked?: true }
16
17// One saved conversation of a project, newest first in a list.
18export type SessionInfo = {
19 id: string
20 // Seconds since the epoch the transcript was last written.
21 mtime: number
22 title: string
23}
24
25// What the band above the prompt shows.
26export type HopView =
27 | { kind: 'hidden' }
28 | { kind: 'projects'; root: string; projects: Project[]; filter: string; page: number }
29 | { kind: 'sessions'; root: string; project: Project; sessions: SessionInfo[]; page: number }
30
31declare module 'claude-code' {
32 interface PluginState {
33 'project-hop': {
34 view: HopView
35 }
36 }
37}
38