SLOPSHOPPER

project-hop

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

newbandcommandtoaststatusprompt
v0.4.0MITupdated 2026-10-05aliemrevezir/mods/plugins/project-hop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-hop
› 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 › /projects ⎿ project-hop: /work ▸ /work 0/0 projects [ × close ] ctrl+x tab → ↑↓ to move, Enter to open · or type a name + Enter ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▸ /work 0/0 projects [ × close ] ctrl+x tab → ↑↓ to move, Enter to open · or type a name + Enter
README

project-hop

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.

project-hop: browsing a folder of projects, walking them with the arrows, opening one and resuming a session

<sub>The projects in the demo are made up; docs/make_demo.py draws it.</sub>

What it does

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.

  • Press ctrl+x tab to give the band the keyboard, then ↑ ↓ to walk the folders and Enter to open one. Arrowing past the last row turns to the next page, past the first row to the previous one.
  • Or type a project's name and press Enter; a name that matches several filters the list.
  • ▲ / ▼ (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.

  • ↑ ↓ and Enter continue a session, or type its number.
  • n starts a new session in that project (/cd + /clear).
  • c continues the most recent one, b goes back to the projects.

Commands

CommandWhat it does
/projects [folder]Show the projects in folder; with no folder, here or the current project's parent

Install

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

Requirements

  • A Claude Code build with mods (plugin hook modules).
  • A POSIX shell with ls, stat, sed and grep; sessions are read from ~/.claude/projects.

Development

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.

License

MIT

Source 3 files
hooks/register.tsx 301 lines
1import { 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}
301
hooks/scan.ts 189 lines
1import 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}
189
types/index.d.ts 38 lines
1// 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