SLOPSHOPPER

filetree

A file tree of what Claude touches: what it is doing now, the files it touched this turn, and git status

newpaneguardcommandtoastprompt
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/filetree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ app󰁝 󰋜 󰑐 󰈈 󰋊 │ ┃  no git repo · git status shows after git ⏺ Read(src/auth.ts) │ ┃ init ⎿ Read 6 lines │ ┃ ● DONE Claude edited 3 files · auth.ts, aud ⏺ Update(src/auth.ts) │ ┃ / : Find a file ⏎ Go ⎿ Added 2 lines, removed 1 line │ ┃ ▣ client module ./rows.tsx ⏺ Bash(bun test) │ ┃ 󰕒 pushed ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /filetree │ ┃ ⎿ filetree: File tree: /work/app (following the working folder). │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
app󰁝 󰋜 󰑐 󰈈 󰋊  no git repo · git status shows after git init ● DONE Claude edited 3 files · auth.ts, audit.ts, cache.ts / : Find a file ⏎ Go ▣ client module ./rows.tsx 󰕒 pushed
README

filetree

Shows a file tree of your project beside the chat, with what Claude is reading, editing or committing right now and every file it has touched this turn.

Install

/plugin marketplace add partalemre/MODZ
/plugin install filetree@modz

Or in one step: /plugin install filetree --marketplace partalemre/MODZ.

The MODZ panel (modz) shows its line here: files changed this turn, uncommitted changes, or the last turn's commit.

What you see

Open the pane with /filetree. It does not open by itself; once opened, it comes back when a session restarts.

  • NOW / DONE card at the top: the file Claude is reading, editing or committing, in one sentence, with the branch, ahead/behind counts, change status, the tokens this turn's reads put into context (and the session's, once an earlier turn read too) and a short colour legend.
  • Touched by Claude: the files touched this turn, each with a kind badge, its folder, the tokens its reads put into context and a status label (Reading, Opened, Editing, Edited, Committed).
  • All files: the full tree for the working folder. The row Claude is in shimmers with a label, and the right column shows the date or the size, after a file's read tokens this turn in blue (≈1.5k tok). Reads are blue, writes orange, commits green, uncommitted changes amber.
  • Git: per-file status, +N -N line counts on modified files, and counts on folders. Commits to another repo (cd other && git commit) show as committed there; if git refuses a commit, the reason is shown.
  • Toolbar: search (it finds folders you have not opened), Collapse, Refresh, and More (size or date column, hidden files, parent folder, back to project, clear selection). With the tree focused: r refresh, c collapse, s size/date, . hidden files, u parent folder, p project, x clear selection, y copies the relative path (Y, the absolute one), arrow keys or j/k move, Enter opens a file or toggles a folder.
  • Click a row to select it; Ctrl-click or Shift-click opens the file in its default app.

Read tokens

Each Read Claude makes (a subagent's too, counted toward the file) is measured from the sizes the API reports: every model request carries its whole prompt (input, cache read and cache write tokens), so the next request in the same loop, less the previous one and its answer, is what that step's results added.

  • 13,321: the read was the only thing its step added, so the number is the API's own.
  • ≈1,481: the step added more than this read (other results, a reminder the engine attached, or the model's thinking), so the measured growth is shared by text length.
  • ~740: no request followed the read (the turn was stopped, or the context was compacted in between), so it is a guess from the text's length at the rate this session's measured steps showed.

The turn total sums the files; the session total sums the turns. In the terminal the now line carries the turn's total and each tree row its file's.

The desktop app (Code tab) draws the tree directly. The terminal needs the fullscreen layout (/tui fullscreen) and a window at least 110 columns wide.

If a file is selected in the tree when you send a prompt, one line naming it is added to that prompt's context so "this" can mean it. Writing @path in a prompt reveals that file in the tree.

Commands

  • /filetree opens the tree on the working folder and follows it.
  • /filetree <path> pins the tree to another folder.

Settings

OptionDefaultMeaning
activityreads and writesWhat shimmers in the tree: reads and writes, writes, reads or none. Git status and line counts always show.
glyphsautoauto uses Nerd Font icons when a Nerd Font is installed (plain Unicode in the desktop app), nerd or plain forces one.
followonon scrolls the tree to the file Claude reads, writes or commits. off keeps the view where you put it; highlights still show.
columndateWhat the right column shows at session start: date or size. The header button toggles it.

Limits

  • Folder sizes come from du (a summed listing on Windows), worked out in the background for the rows on screen.
  • Outside a git repository it checks once whether one exists; every git call is scoped to the working folder.
  • Clicking rows in herdr needs herdr 0.9.1 or later; on older versions the rows ignore the mouse.
  • Mods do not load in WSL sessions of the desktop app.
  • Read tokens count the Read tool only; files read through Bash (cat, rg) light up in the tree but carry no count. A turn's counts reset when the next turn starts.

Uses no model tokens beyond the one context line above.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Credits

Adapted from data-goblin/claude-code-filetree, licensed MIT. The license file is in this folder, with the contributors in ACKNOWLEDGEMENTS.md.

Source 53 files
hooks/register.tsx 300 lines
1import { type EngineInterface, type On, type Register, update } from 'claude-code'
2
3import { drawDesktop, opening } from './desktop'
4import { HomeButton } from './home'
5import { gitDir } from './gitread'
6import type { Host, IoHost, StateHost, UiHost } from './host'
7import { HUB_PANE, isOpenFor } from './hub'
8import { setLang } from './lang'
9import { onRows, type RowsMessage, scrollBy } from './keys'
10import { refreshLook } from './look'
11import { followCwd, openFromHub, sync } from './pane'
12import { promptContext } from './prompt'
13import { loopDone, noteRead, stepDone, takeReads } from './readstep'
14import { settleBackground, touched } from './scan'
15import { sceneOf } from './scene'
16import { isOffStage, shownOf } from './stage'
17import { mem } from './session'
18import { pinned } from './pin'
19import { openByCommand, resumePane, startPane } from './start'
20import { drawTerminal } from './terminal'
21import { afterCall, beforeCall, callFailed, isWatched } from './toolcall'
22import { emptyTree, join, posix, useDrives } from './tree'
23import { NO_TURN, syncHub } from './turn'
24import { words } from './words'
25
26const TREE = { plugin: 'filetree', key: 'tree' } as const
27const THEME = { plugin: 'filetree', key: 'theme' } as const
28const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
29const TURN = { plugin: 'filetree', key: 'turn' } as const
30const READS = { plugin: 'filetree', key: 'reads' } as const
31const LOOK = { plugin: 'filetree', key: 'look' } as const
32const HUB = { plugin: 'filetree', key: 'hub' } as const
33const PANE = 'filetree'
34const ACTIVITY_KEEP = 6
35// Git writes its index and HEAD in steps; the tree reads them once the burst settles.
36const GIT_SETTLE_MS = 300
37const MIN_WIDTH = 24
38const DEFAULT_ROWS = 40
39// The "← MODZ" button's row (the desktop's column gap adds one more).
40const HOME_ROWS = 2
41
42function stateOf($: EngineInterface): StateHost {
43  return {
44    tree: async () => ({ ...emptyTree(''), ...(await $.state.get(TREE)).value }),
45    putTree: async fn => {
46      await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
47    },
48    activities: async () => (await $.state.get(ACTIVITY)).value ?? [],
49    setActivities: async fn => {
50      await update($, ACTIVITY, cur => fn(cur ?? []).slice(-ACTIVITY_KEEP))
51    },
52    turn: async () => ({ ...NO_TURN, ...(await $.state.get(TURN)).value }),
53    setTurn: async fn => {
54      await update($, TURN, cur => fn({ ...NO_TURN, ...cur }))
55    },
56    readTotal: async () => (await $.state.get(READS)).value,
57    setReadTotal: async fn => {
58      await update($, READS, cur => fn(cur))
59    },
60    hubCard: async () => (await $.state.get(HUB)).value,
61    setHubCard: async card => {
62      await $.state.set(HUB, card)
63    },
64    look: async () => (await $.state.get(LOOK)).value,
65    setLook: async look => {
66      await update($, LOOK, () => look)
67    },
68    theme: async () => (await $.state.get(THEME)).value,
69    setTheme: async theme => {
70      await $.state.set(THEME, theme)
71    },
72    isHubOn: async () => (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true,
73    goHome: async () => {
74      try {
75        await $.state.set({ plugin: 'filetree', key: 'home' }, { at: await $.clock.now() })
76      } catch (error) {
77        $.ui.log(`filetree: home failed (${String(error)})`, { to: 'debug' })
78      }
79    },
80    reportShown: async () => {
81      try {
82        if ((await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
83        await $.state.set({ plugin: 'filetree', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
84      } catch (error) {
85        $.ui.log(`filetree: shown failed (${String(error)})`, { to: 'debug' })
86      }
87    },
88  }
89}
90
91function ioOf($: EngineInterface): IoHost {
92  return {
93    list: path => $.fs.list(path),
94    stat: (path, options) => $.fs.stat(path, options),
95    read: path => $.fs.read(path),
96    run: (argv, init) => $.process.run(argv, init),
97    now: () => $.clock.now(),
98    after: (ms, fn) => $.clock.after(ms, fn),
99    every: (ms, fn) => $.clock.every(ms, fn),
100    cwd: () => $.session.cwd(),
101    sessionId: () => $.session.id(),
102    messages: async agentId => {
103      const found = await $.session.messages(agentId ? { agentId, as: 'api' } : { as: 'api' })
104      return Array.isArray(found) ? found : []
105    },
106    tmpdir: () => $.env.get('TMPDIR'),
107    osEnv: () => $.env.get('OS'),
108    homeEnv: () => $.env.get('HOME'),
109    herdrEnv: () => $.env.get('HERDR_ENV'),
110    herdrBin: () => $.env.get('HERDR_BIN_PATH'),
111  }
112}
113
114function uiOf($: EngineInterface): UiHost {
115  return {
116    toast: text => $.ui.toast(text),
117    openPane: isFocused => opening(() => $.ui.open(isFocused ? { id: PANE, title: words().title, focus: true } : { id: PANE, title: words().title })),
118    isPaneOpen: async () => {
119      try {
120        return (await $.ui.panes()).some(p => p.id === PANE)
121      } catch {
122        return false
123      }
124    },
125    copy: args => $.ui.copy(args),
126    surfaces: () => $.session.surfaces(),
127    config: () => $.config.list(),
128  }
129}
130
131function hostOf($: EngineInterface): Host {
132  return { ...stateOf($), ...ioOf($), ...uiOf($) }
133}
134
135function onStart(on: On): void {
136  // DESIGN.md §9: the MODZ main panel asks for a pane by key; this only opens, never closes.
137  // modz's contract is not this mod's to import, so its address stays untyped here.
138  on('state.set', { plugin: 'modz', key: 'open' } as any, async ($: EngineInterface, e: any, next: any) => {
139    const done = await next(e)
140    if (isOpenFor(e.value, HUB_PANE)) await openFromHub(hostOf($))
141
142    return done
143  }).catch(($, e, next) => next(e))
144  // Another pane took the MODZ panel: this one steps aside rather than wait as a tab.
145  on('state.set', { plugin: 'modz', key: 'stage' } as any, async ($: EngineInterface, e: any, next: any) => {
146    const done = await next(e)
147    if (isOffStage(e.value, HUB_PANE)) await $.ui.close({ id: PANE })
148
149    return done
150  }).catch(($, e, next) => next(e))
151  on('session.start', async ($, e, next) => {
152    // The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
153    setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
154    await $.command.register({ name: 'filetree', description: words().command })
155    const isWindows = (await $.env.get('OS')) === 'Windows_NT'
156    useDrives(isWindows)
157    mem.home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
158    const host = hostOf($)
159    mem.activityId = Math.max(mem.activityId, ...(await host.activities()).map(a => a.id))
160    void startPane(host, { isWindows, isRemote: async () => Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY'))) })
161    return next(e)
162  })
163  on('command.run', { command: 'filetree' }, async ($, e) => ({ text: await openByCommand(hostOf($), e) }))
164}
165
166function onTools(on: On): void {
167  on('tool.call', async ($, e, next) => {
168    if (!isWatched(e.tool)) return next(e)
169    const host = hostOf($)
170    const call = await beforeCall(host, e)
171    let result: Awaited<ReturnType<typeof next>>
172    try {
173      result = await next(e)
174    } catch (err) {
175      await callFailed(host, call)
176      throw err
177    }
178    return afterCall(host, call, result)
179  })
180  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
181    const result = await next(e)
182    if (result.deny || result.isError || e.tool !== 'Read') return result
183    noteRead({ id: e.tool_use_id ?? '', file: e.file_path, text: result.text ?? '', agentId: e.agentId })
184    if (mem.showReads) void touched(hostOf($), [posix(e.file_path)], { tone: 'purple', isShown: true })
185    return result
186  })
187  // Each model request's size measures what the step before it read (readcost.ts); the request itself passes untouched.
188  on('turn.step', async function* ($, e, next) {
189    const reads = takeReads(e.agentId)
190    const step = yield* next(e)
191    void stepDone(hostOf($), { turnId: e.turnId, agentId: e.agentId, reads, usage: step.usage })
192    return step
193  }).catch(async function* ($, e, next) {
194    return yield* next(e)
195  })
196}
197
198function onTurns(on: On): void {
199  on('turn.start', async ($, e, next) => {
200    const host = hostOf($)
201    await host.setTurn(() => ({ ...NO_TURN, isRunning: true, hasRun: true }))
202    await syncHub(host)
203    return next(e)
204  })
205  on('turn.complete', async ($, e, next) => {
206    const result = await next(e)
207    const host = hostOf($)
208    await loopDone(host, e.agentId)
209    if (!e.agentId) await host.setTurn(cur => ({ ...NO_TURN, ...cur, isRunning: false }))
210    if (!e.agentId) await syncHub(host)
211    return result
212  })
213  on('ui.message', async ($, e, next) => {
214    if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
215    await onRows(hostOf($), e.data as RowsMessage, e.surface)
216    return {}
217  })
218}
219
220function onClassic(on: On): void {
221  on('classic.SessionStart', async ($, e, next) => {
222    const result = await next(e)
223    const host = hostOf($)
224    if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') void resumePane(host)
225    const gd = await gitDir(host, posix(e.cwd))
226    return gd ? { ...result, watchPaths: [...(result.watchPaths ?? []), join(gd, 'index'), join(gd, 'HEAD')] } : result
227  })
228  on('classic.CwdChanged', async ($, e, next) => {
229    const result = await next(e)
230    void followCwd(hostOf($))
231    return result
232  })
233  on('classic.FileChanged', async ($, e, next) => {
234    const result = await next(e)
235    if (/[\\/]\.git[\\/]|[\\/](index|HEAD)$/.test(e.file_path)) $.clock.after(GIT_SETTLE_MS, () => void sync(hostOf($), true))
236    return result
237  })
238}
239
240function onPane(on: On): void {
241  on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
242    const result = await next(e)
243    const host = hostOf($)
244    void sync(host)
245    if (await host.look()) void refreshLook(host)
246    return result
247  })
248  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
249    await scrollBy(hostOf($), e.by)
250    return {}
251  })
252  on('prompt.submit', async ($, e, next) => {
253    const context = await promptContext(hostOf($), e)
254    return next(context.length ? { ...e, context } : e)
255  })
256  on('prompt.attachment', async ($, e, next) => {
257    void settleBackground(hostOf($), e.text)
258    return next(e)
259  })
260}
261
262function onRender(on: On): void {
263  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
264    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
265    if (e.surface === 'terminal' && e.props.placement === 'inline') {
266      mem.noDock = true
267      void $.ui.close({ id: PANE }).catch(() => undefined)
268      const { Box: Empty } = $.ui.resolve(e)
269      return <Empty />
270    }
271    const host = hostOf($)
272    const s = await sceneOf(host, e.surface === 'desktop')
273    const isHubOn = await host.isHubOn()
274    const room = { width: Math.max(MIN_WIDTH, e.props.bodyColumns), bodyRows: (e.props.scroll?.bodyRows ?? DEFAULT_ROWS) - (isHubOn ? HOME_ROWS : 0) }
275    if (e.surface === 'desktop') {
276      const desk = $.ui.resolve(e)
277      const home = isHubOn ? HomeButton(desk, () => void host.goHome()) : null
278      return pinned(e, drawDesktop(host, desk, { s, room, home }))
279    }
280    const ui = $.ui.resolve(e)
281    const home = isHubOn ? HomeButton(ui, () => void host.goHome()) : null
282    return pinned(e, drawTerminal(host, ui, { s, room, home }))
283  })
284}
285
286export const register: Register = (on, options) => {
287  mem.glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
288  const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
289  mem.showReads = activity.includes('reads')
290  mem.showWrites = activity.includes('writes')
291  mem.followClaude = options?.follow !== 'off'
292  mem.sizeDefault = options?.column === 'size'
293  onStart(on)
294  onTools(on)
295  onTurns(on)
296  onClassic(on)
297  onPane(on)
298  onRender(on)
299}
300
hooks/desktop.tsx 123 lines
1import type { ElementTable } from 'claude-code'
2
3import type { FileTree } from '../types'
4import { actionsOf } from './actions'
5import { type CostRow, type Drawn, type GitRow, heroCard, type LegendItem, sectionLine, touchedCard } from './cards'
6import { activityChip, footer } from './chrome'
7import { costText, turnCost } from './costtext'
8import type { Host } from './host'
9import { paneMotion } from './kit/moving'
10import { heroOf, touchedRows } from './now'
11import type { RowsProps } from './rows'
12import { desktopRow } from './rowspec'
13import { heroInput, type Room, rowCtx, type Scene, treeWindow } from './scene'
14import { jump, search } from './search'
15import { mem } from './session'
16import { unsized, wantSizes } from './sizes'
17import { paletteOf, roleOfTone, shimmerTones } from './theme'
18import { drawToolbar } from './toolbar'
19import { upper } from './lang'
20import { words } from './words'
21
22type Ui = ElementTable<'desktop'>
23
24const PX_PER_COL = 8
25const ROW_PX = 18
26const MIN_SVG_W = 240
27const MAX_SVG_W = 900
28// Rows the desktop toolbar takes: its one line, and with "More" open the extra buttons and hint.
29const TOOLBAR_ROWS = 3
30const TOOLBAR_OPEN_ROWS = 9
31const FOOTER_ROWS = 2
32const MIN_BODY = 5
33
34function gitRowOf(t: FileTree): GitRow | null {
35  if (!t.top || !t.branch) return null
36  const [add, del] = t.diff[t.root] ?? [0, 0]
37  const changed = (t.counts[t.root] ?? [0, 0, 0]).reduce((a, b) => a + b, 0)
38  return { branch: t.branch.head, upstream: t.branch.upstream, ahead: t.branch.ahead, behind: t.branch.behind, changed, add, del }
39}
40
41// Shown once the session has read: this turn's count, and the session's when earlier turns read too.
42function costRowOf(s: Scene): CostRow | null {
43  const total = s.readTotal
44  if (!total || total.reads === 0) return null
45  const turn = turnCost(s.turn)
46  return { turn: turn ? costText(turn) : null, session: total.reads > (turn?.reads ?? 0) ? costText(total) : null }
47}
48
49// Only the colors the view shows right now get a legend entry.
50function legendOf(s: Scene, git: GitRow | null): LegendItem[] {
51  const tones = s.t.flashOn ? Object.values(s.t.flashTones).map(roleOfTone) : []
52  const has = (role: 'read' | 'edit') => s.turn.touches.some(x => x.role === role) || tones.includes(role)
53  const { legend } = words()
54  return [
55    ...(has('read') ? [{ role: 'read' as const, label: legend.read }] : []),
56    ...(has('edit') ? [{ role: 'edit' as const, label: legend.edit }] : []),
57    ...(git?.changed ? [{ role: 'warn' as const, label: legend.warn }] : []),
58  ]
59}
60
61// The pane's drawings on the desktop (kit/moving.ts): reset as the pane opens, so they cascade in each time.
62export const motion = paneMotion()
63
64// Opens the pane anew: its drawings cascade in again.
65export function opening<T>(open: () => T): T {
66  motion.reset()
67
68  return open()
69}
70
71// The three cards above the tree, drawn at the pane's width. Each one's `shows` leaves out what reads put into
72// context: that count climbs with every read, so it redraws the card without the sweep a real change gets.
73function cardsOf(s: Scene, w: number) {
74  const { t, look } = s
75  const input = heroInput(s)
76  const git = gitRowOf(t)
77  const cost = costRowOf(s)
78  const hero = heroOf(input)
79  const legend = legendOf(s, git)
80  const touchedList = touchedRows(input)
81  const section = sectionLine({ left: words().allFiles, right: upper((t.top ? t.top : t.root).split('/').pop() ?? '') }, { look, w })
82  return {
83    hero: { drawn: heroCard(hero, { git, cost, legend }, { look, w }), shows: { hero, git, legend, isCounted: Boolean(cost?.turn), isSummed: Boolean(cost?.session), look, w } },
84    touched: touchedList.length ? { drawn: touchedCard(touchedList, look, w), shows: { rows: touchedList.map(({ cost: c, ...row }) => ({ ...row, isCounted: c !== undefined })), look, w } } : null,
85    section: { drawn: section },
86  }
87}
88
89export function drawDesktop(host: Host, ui: Ui, frame: { s: Scene; room: Room; home: JSX.Element | null }) {
90  const { Box, Client, Svg } = ui
91  const { s, room, home } = frame
92  const { t, look } = s
93  const pal = paletteOf(look.isLight)
94  const { hero, touched, section } = cardsOf(s, Math.max(MIN_SVG_W, Math.min(MAX_SVG_W, room.width * PX_PER_COL)))
95  const lines = (px: number) => Math.ceil(px / ROW_PX) + 1
96  const toolRows = t.showTools ? TOOLBAR_OPEN_ROWS : TOOLBAR_ROWS
97  const fixed = lines(hero.drawn.height) + (touched ? lines(touched.drawn.height) : 0) + lines(section.drawn.height) + toolRows + FOOTER_ROWS
98  const ctx = rowCtx(s, room.width - 1, true)
99  const win = treeWindow(s, Math.max(MIN_BODY, room.bodyRows - fixed), { toSpec: r => desktopRow(ctx, r), colors: { thumb: pal.lineStrong, track: pal.line, note: pal.lo } })
100  if (t.showSize) wantSizes(host, unsized(t, win.rows))
101  const chip = s.latest ? activityChip(ui, s.latest, { unicode: true, colorOf: tone => pal.roles[roleOfTone(tone)].fg }) : null
102  // The full body width, so the cards end where the tree's right column does. A frame needs its box, so the
103  // drawing's size goes with it; a narrower slot than the columns promise caps the frame's width, and the
104  // drawing's viewBox scales it down inside instead of cutting it.
105  const svg = (key: string, card: { drawn: Drawn; shows?: unknown }) => <Svg key={key} {...motion.pic(key, card.drawn, { shows: card.shows })} />
106  const find = { onInput: (v: string) => void search(host, v), onSubmit: (v: string) => void jump(host, v) }
107  const rows = { rows: win.specs, active: t.cursor, activeBg: pal.line, hoverBg: pal.inset, tones: shimmerTones(pal, look.isLight), pointer: mem.pointer, ...(win.bar ? { bar: win.bar } : {}) } satisfies RowsProps
108  return (
109    <Box flexDirection="column" gap={1} minHeight={Math.max(1, room.bodyRows)}>
110      {home}
111      {svg('ft-hero', hero)}
112      {drawToolbar(ui, { t, act: actionsOf(host, t), search: find })}
113      {touched ? svg('ft-touched', touched) : null}
114      <Box flexDirection="column">
115        {svg('ft-section', section)}
116        <Client key="rows" module="./rows.tsx" props={rows} />
117      </Box>
118      <Box flexGrow={1} />
119      {footer(ui, s, chip) ?? <Box height={1} />}
120    </Box>
121  )
122}
123
hooks/home.tsx 33 lines
1// MODZ kit — the way back to the hub from a mod's pane (DESIGN.md §9). While the hub runs it keeps
2// `{ plugin: 'modz', key: 'isOn' }` true; a pane then draws `HomeButton` first, and its press writes the mod's
3// own `{ plugin: '<mod>', key: 'home' }` value (`{ at }`). The hub hears it, comes forward, and opens again if
4// it was closed, so the person always has a way back, even with one pane left and no tab to click.
5
6// The resolved element table of the surface that draws the pane.
7type Table = Record<string, any>
8
9export const HOME_LABEL = '← MODZ'
10
11export function HomeButton(ui: Table, onPress: () => void) {
12  const { Box, Button } = ui
13
14  return (
15    <Box key="modz-home-row" flexDirection="row">
16      <Button key="modz-home" label={HOME_LABEL} variant="secondary" onPress={onPress} />
17    </Box>
18  )
19}
20
21// The pane's tree with the way back above it; null while the hub is off draws the pane as it is.
22export function withHome<T>(ui: Table, tree: T, onHome: (() => void) | null) {
23  if (onHome === null) return tree
24  const { Box } = ui
25
26  return (
27    <Box flexDirection="column" rowGap={1}>
28      {HomeButton(ui, onHome)}
29      {tree}
30    </Box>
31  )
32}
33
hooks/gitread.ts 119 lines
1import { mapper, parseGit, parseNumstat, rollCounts, rollUp } from './gitstatus'
2import { type Host, patch } from './host'
3import { mem } from './session'
4import { resolve } from './shell'
5import { dirname, join } from './tree'
6import { syncHub } from './turn'
7
8const GIT_TIMEOUT_MS = 20_000
9const QUICK_MS = 5_000
10// Upward from the session's folder, deeper than any real checkout.
11const GIT_DIR_LEVELS = 64
12
13let gitRun: Promise<void> | null = null
14let gitNext: Promise<void> | null = null
15
16export function git(h: Host, cwd: string, call: { args: string[]; timeoutMs?: number }) {
17  const { args, timeoutMs = GIT_TIMEOUT_MS } = call
18  return h.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
19}
20
21export async function detectRepo(h: Host): Promise<void> {
22  const t = await h.tree()
23  if (!t.root) return
24  let top = ''
25  let prefix = ''
26  try {
27    const run = await git(h, t.root, { args: ['rev-parse', '--show-prefix', '--show-toplevel'], timeoutMs: QUICK_MS })
28    const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
29    prefix = p.trim()
30    top = tl.trim()
31  } catch {
32    top = ''
33  }
34  await patch(h, cur => (cur.root === t.root ? { top, prefix } : {}))
35}
36
37async function untracked(h: Host, root: string, dirs: string[]): Promise<string[]> {
38  if (!dirs.length) return []
39  const others = await git(h, root, { args: ['ls-files', '-o', '--exclude-standard', '-z', '--', ...dirs.map(d => d.slice(root.length + 1) || '.')] })
40  return others.exitCode === 0 ? others.stdout.split('\0').filter(Boolean) : []
41}
42
43async function readGit(h: Host): Promise<void> {
44  const t = await h.tree()
45  if (!t.root || !t.top) return
46  const root = t.root
47  try {
48    const status = await git(h, root, { args: ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'] })
49    if (status.exitCode !== 0) return
50    const parsed = parseGit(status.stdout, root, t.prefix)
51    const head = await git(h, root, { args: ['diff', 'HEAD', '--numstat', '-z', '--', '.'] })
52    const diff = head.exitCode === 0 ? parseNumstat(head.stdout, mapper(root, t.prefix)) : {}
53    const files = { ...parsed.files }
54    for (const rel of await untracked(h, root, parsed.untrackedDirs)) files[join(root, rel)] = 'new'
55    for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
56    mem.dirty = { root, files }
57    const { git: marks, ignored, untrackedDirs, branch } = parsed
58    await patch(h, cur => (cur.root === root ? { git: marks, ignored, untrackedDirs, branch, counts: rollCounts(files, root), diff: rollUp(diff, root) } : {}))
59    await syncHub(h)
60  } catch {
61    return
62  }
63}
64
65export function refreshGit(h: Host): Promise<void> {
66  if (!gitRun) {
67    gitRun = readGit(h).finally(() => {
68      gitRun = null
69    })
70    return gitRun
71  }
72  const rerun = () => {
73    gitNext = null
74    return refreshGit(h)
75  }
76  gitNext ??= gitRun.then(rerun, rerun)
77  return gitNext
78}
79
80export async function headOf(h: Host, cwd: string): Promise<string> {
81  try {
82    const run = await git(h, cwd, { args: ['rev-parse', 'HEAD'], timeoutMs: QUICK_MS })
83    return run.exitCode === 0 ? run.stdout.trim() : ''
84  } catch {
85    return ''
86  }
87}
88
89// The files the last commit holds, or what is staged; as absolute paths under the tree's root.
90export async function gitPaths(h: Host, at: { root: string; prefix: string }, isCommitted: boolean): Promise<string[]> {
91  const map = mapper(at.root, at.prefix)
92  try {
93    const args = isCommitted ? ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'] : ['diff', '--cached', '--name-only', '-z']
94    const run = await git(h, at.root, { args, timeoutMs: QUICK_MS })
95    return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
96  } catch {
97    return []
98  }
99}
100
101export async function gitDir(h: Host, cwd: string): Promise<string> {
102  for (let dir = cwd, i = 0; i < GIT_DIR_LEVELS; i++) {
103    try {
104      const dot = join(dir, '.git')
105      const st = await h.stat(dot)
106      if (st.kind === 'dir') return dot
107      const ref = /^gitdir:\s*(.+)$/m.exec(String(await h.read(dot)))?.[1]?.trim()
108      if (ref) return resolve(dir, ref, mem.home)
109    } catch {
110      const up = dirname(dir)
111      if (up === dir) return ''
112      dir = up
113      continue
114    }
115    return ''
116  }
117  return ''
118}
119
hooks/host.ts 69 lines
1import type { EngineInterface, Timer } from 'claude-code'
2
3import type { Activity, FileTree, HubCard, Look, ReadCost, Theme, TurnLog } from '../types'
4import type { ApiMessage } from './readcost'
5
6type Engine = EngineInterface
7
8// The mod's own values in `$.state`, bound in register.tsx where their references stay literal.
9export type StateHost = {
10  tree(): Promise<FileTree>
11  putTree(fn: (t: FileTree) => FileTree): Promise<void>
12  activities(): Promise<Activity[]>
13  setActivities(fn: (list: Activity[]) => Activity[]): Promise<void>
14  turn(): Promise<TurnLog>
15  setTurn(fn: (cur: TurnLog) => TurnLog): Promise<void>
16  readTotal(): Promise<ReadCost | undefined>
17  setReadTotal(fn: (cur: ReadCost | undefined) => ReadCost): Promise<void>
18  hubCard(): Promise<HubCard | undefined>
19  setHubCard(card: HubCard): Promise<void>
20  look(): Promise<Look | undefined>
21  setLook(look: Look): Promise<void>
22  theme(): Promise<Theme | undefined>
23  setTheme(theme: Theme): Promise<void>
24  // Whether the MODZ hub runs, and a request for it to come forward (the "← MODZ" button).
25  isHubOn(): Promise<boolean>
26  goHome(): Promise<void>
27  // The pane, opened when asked, now holds the MODZ panel: the hub and every other pane step aside (kit/stage.ts).
28  reportShown(): Promise<void>
29}
30
31// Files, commands, the clock and the session's environment.
32export type IoHost = {
33  list: Engine['fs']['list']
34  stat: Engine['fs']['stat']
35  read(path: string): Promise<unknown>
36  run: Engine['process']['run']
37  now(): Promise<number>
38  after(ms: number, fn: () => void): Timer
39  every(ms: number, fn: () => void): Timer
40  cwd(): Promise<string>
41  sessionId(): Promise<string>
42  // The loop's transcript as the API reads it: the main conversation, or a subagent's by its id.
43  messages(agentId: string | undefined): Promise<readonly ApiMessage[]>
44  tmpdir(): Promise<string | undefined>
45  osEnv(): Promise<string | undefined>
46  homeEnv(): Promise<string | undefined>
47  herdrEnv(): Promise<string | undefined>
48  herdrBin(): Promise<string | undefined>
49}
50
51// The pane, the clipboard, toasts and Claude's own settings.
52export type UiHost = {
53  toast(text: string): void
54  // The pane by its own id: opened (focused when asked for) or retitled, and whether it is open now.
55  openPane(isFocused: boolean): Promise<{ isPlaced: boolean }>
56  isPaneOpen(): Promise<boolean>
57  copy: Engine['ui']['copy']
58  surfaces: Engine['session']['surfaces']
59  config: Engine['config']['list']
60}
61
62// The engine's reach, bound in register.tsx: the engine follows `$` only into functions of the hooks module's own
63// file, so every other module gets this port instead.
64export type Host = StateHost & IoHost & UiHost
65
66export function patch(h: Host, fn: (t: FileTree) => Partial<FileTree>): Promise<void> {
67  return h.putTree(t => ({ ...t, ...fn(t) }))
68}
69
hooks/hub.ts 35 lines
1import type { HubCard, HubTone } from '../types'
2import { words } from './words'
3
4// The MODZ main panel's side of this mod (DESIGN.md §9): a one-line summary card it collects,
5// and its "open" requests, which name a pane by key. Pure: register.tsx does the writing.
6
7export const HUB_PANE = 'files'
8
9export type HubShown = { detail: string; tone: HubTone }
10// `edited` counts the files Claude wrote or committed in the latest turn; `sha` is that turn's commit.
11export type HubInput = { isRunning: boolean; touched: number; hasRepo: boolean; changed: number; edited?: number; sha?: string }
12
13const SHA_SHOWN = 7
14
15// Live work first, then what git says is left to commit, then what the last turn did.
16export function hubOf(s: HubInput): HubShown {
17  const edited = s.edited ?? 0
18  const w = words()
19  if (s.isRunning && s.touched > 0) return { detail: w.hubLive(s.touched, edited), tone: 'live' }
20  if (!s.hasRepo) return edited > 0 ? { detail: w.hubEditedLast(edited), tone: 'idle' } : { detail: w.hubNoRepo, tone: 'idle' }
21  if (s.changed > 0) return { detail: w.hubUncommitted(s.changed), tone: 'warn' }
22  if (s.sha) return { detail: w.hubCommit(s.sha.slice(0, SHA_SHOWN)), tone: 'ok' }
23
24  return { detail: w.hubClean, tone: 'ok' }
25}
26
27// Written only when what the card shows changes, so the main panel does not redraw for nothing.
28export function isSameCard(card: HubCard | undefined, shown: HubShown): boolean {
29  return card !== undefined && card.detail === shown.detail && card.tone === shown.tone
30}
31
32export function isOpenFor(value: unknown, pane: string): boolean {
33  return typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === pane
34}
35
hooks/lang.ts 24 lines
1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12  current = langOf(value)
13
14  return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24
hooks/keys.ts 82 lines
1import type { FileNode, FileTree } from '../types'
2import { actionsOf } from './actions'
3import { type Host, patch } from './host'
4import { copyPath, openNode, press, sync, toggle } from './pane'
5import { mem } from './session'
6import { TOOL_KEYS, type ToolKey } from './toolbar'
7import { visibleRows } from './tree'
8
9// What the rows Client posts: a press, a key, a scrollbar drag or a copy.
10export type RowsMessage = { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
11type Key = { key: string; ctrl: unknown; shift: unknown; surface?: string }
12
13const PAGE = 10
14const STEPS = new Map<string, number>([
15  ['up', -1],
16  ['k', -1],
17  ['down', 1],
18  ['j', 1],
19  ['pageup', -PAGE],
20  ['pagedown', PAGE],
21])
22// A wheel tick scrolls at least this many rows.
23const MIN_SCROLL = 3
24
25async function pressed(h: Host, t: FileTree, data: RowsMessage): Promise<void> {
26  const n = t.nodes.find(x => x.id === data.press)
27  if (!n) return
28  if (t.scroll === null) await patch(h, () => ({ scroll: mem.view.from }))
29  if (data.ctrl || data.shift) await openNode(h, n)
30  else await press(h, n)
31}
32
33// A key on the row under the cursor; false when it is not one of the row keys.
34async function onNode(h: Host, t: FileTree, at: { cur: FileNode; k: Key }): Promise<boolean> {
35  const { cur, k } = at
36  const { key } = k
37  if (key === 'y' || key === 'Y') await copyPath(h, cur.id, { isAbsolute: key === 'Y' || Boolean(k.shift), surface: k.surface })
38  else if ((key === 'right' || key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle(h, cur)
39  else if (key === 'left' || key === 'h') {
40    if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle(h, cur)
41    else if (cur.parent !== t.root) await patch(h, () => ({ cursor: cur.parent }))
42  } else if (key === 'return') await (cur.kind !== 'dir' ? openNode(h, cur) : toggle(h, cur))
43  else if (key === ' ') await toggle(h, cur)
44  else return false
45  return true
46}
47
48async function onKey(h: Host, t: FileTree, k: Key): Promise<void> {
49  const rows = visibleRows(t)
50  const at = rows.findIndex(r => r.node.id === t.cursor)
51  const cur = rows[at]?.node
52  const move = (d: number) => {
53    const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
54    return target ? patch(h, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
55  }
56  const step = STEPS.get(k.key)
57  if (step !== undefined) await move(step)
58  else if (k.key === 'home') await move(-rows.length)
59  else if (k.key === 'end') await move(rows.length)
60  else if (cur && (await onNode(h, t, { cur, k }))) return
61  else if (TOOL_KEYS[k.key] && !k.ctrl) actionsOf(h, t)[TOOL_KEYS[k.key] as ToolKey]()
62}
63
64export async function onRows(h: Host, data: RowsMessage, surface?: string): Promise<void> {
65  void sync(h)
66  const t = await h.tree()
67  if (typeof data.copy === 'string') return copyPath(h, data.copy, { isAbsolute: Boolean(data.shift), surface })
68  if (typeof data.scrollTo === 'number') {
69    const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * mem.view.max)
70    if (to !== t.scroll) await patch(h, () => ({ scroll: to }))
71    return
72  }
73  if (typeof data.press === 'string') return pressed(h, t, data)
74  if (typeof data.key === 'string') await onKey(h, t, { key: data.key, ctrl: data.ctrl, shift: data.shift, surface })
75}
76
77export async function scrollBy(h: Host, by: number): Promise<void> {
78  const t = await h.tree()
79  const to = Math.max(0, Math.min(mem.view.max, (t.scroll ?? mem.view.from) + Math.sign(by) * Math.max(MIN_SCROLL, Math.abs(by))))
80  if (to !== t.scroll) await patch(h, () => ({ scroll: to }))
81}
82
hooks/look.ts 39 lines
1import type { Look } from '../types'
2import type { Host } from './host'
3import { systemDark } from './platform'
4import { mem } from './session'
5import { lightByName } from './theme'
6import { DEFAULT_THEME, parseTheme } from './tree'
7
8const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
9
10// Claude's theme setting first; for `auto` the system's appearance (DESIGN.md §2).
11export async function refreshLook(h: Host): Promise<void> {
12  let theme: unknown
13  try {
14    theme = (await h.config()).find(row => row.key === 'theme')?.value
15  } catch {
16    theme = undefined
17  }
18  const named = lightByName(theme)
19  const dark = named === undefined ? await systemDark(h) : undefined
20  const look: Look = named === undefined ? { isLight: dark === false, isAuto: true } : { isLight: named, isAuto: false }
21  const cur = await h.look()
22  if (cur?.isLight !== look.isLight || cur?.isAuto !== look.isAuto) await h.setLook(look)
23}
24
25export async function loadTheme(h: Host): Promise<void> {
26  const path = `${(await h.homeEnv()) ?? ''}/${THEME_FILE}`
27  try {
28    const stat = await h.stat(path)
29    if (stat.kind !== 'file') throw new Error('no theme file')
30    if (stat.mtimeMs === mem.themeMtime) return
31    await h.setTheme(parseTheme(String(await h.read(path))))
32    mem.themeMtime = stat.mtimeMs
33  } catch {
34    if (mem.themeMtime === 0) return
35    await h.setTheme(DEFAULT_THEME)
36    mem.themeMtime = 0
37  }
38}
39
hooks/pane.ts 120 lines
1import type { FileNode } from '../types'
2import { cancelFlash } from './flash'
3import { detectRepo, refreshGit } from './gitread'
4import { type Host, patch } from './host'
5import { ancestorsOf, posix, emptyTree, inside } from './tree'
6import { loadDirs, openDirs, revealPaths } from './listing'
7import { openFile } from './platform'
8import { dropIndex } from './search'
9import { mem } from './session'
10import { forgetSizes } from './sizes'
11import { words } from './words'
12
13const DOUBLE_MS = 450
14const SYNC_MS = 2_000
15
16export async function cwdOf(h: Host): Promise<string> {
17  return posix(await h.cwd())
18}
19
20// Calm: the pane opens when the person asks; on its own the mod only retitles an open pane,
21// so only an asked-for open takes the MODZ panel.
22export async function showPane(h: Host, isAsked: boolean): Promise<void> {
23  if (isAsked) {
24    if ((await h.openPane(true)).isPlaced) await h.reportShown()
25  } else if (!mem.noDock && (await h.isPaneOpen())) await h.openPane(false)
26}
27
28export async function inApp(h: Host): Promise<boolean> {
29  try {
30    return (await h.surfaces()).some(s => s !== 'terminal')
31  } catch {
32    return false
33  }
34}
35
36export async function reset(h: Host, root: string, focus = false): Promise<void> {
37  const prev = await h.tree()
38  cancelFlash()
39  mem.lastRoot = root
40  dropIndex()
41  forgetSizes()
42  await h.putTree(() => ({ ...emptyTree(root), showHidden: prev.showHidden, showTools: prev.showTools, showSize: prev.root ? prev.showSize : mem.sizeDefault }))
43  await showPane(h, focus)
44  await loadDirs(h, [root])
45  await detectRepo(h)
46  await refreshGit(h)
47}
48
49// A request from the MODZ main panel: opens (or brings forward) the pane, never closes it.
50export async function openFromHub(h: Host): Promise<void> {
51  mem.noDock = false
52  const t = await h.tree()
53  if (!t.root || t.nodes.length === 0) await reset(h, await cwdOf(h), true)
54  else await showPane(h, true)
55}
56
57export async function followCwd(h: Host): Promise<boolean> {
58  if (!mem.follow) return false
59  const cwd = await cwdOf(h)
60  const t = await h.tree()
61  if (t.root === cwd) return false
62  await reset(h, cwd)
63  return true
64}
65
66export async function sync(h: Host, force = false): Promise<void> {
67  const now = await h.now()
68  if (!force && now - mem.lastSync < SYNC_MS) return
69  mem.lastSync = now
70  dropIndex()
71  const t = await h.tree()
72  if (!t.root) return
73  await loadDirs(h, [t.root, ...openDirs(t)])
74  if (t.top) await refreshGit(h)
75}
76
77export async function copyPath(h: Host, id: string, how: { isAbsolute: boolean; surface?: string }): Promise<void> {
78  const t = await h.tree()
79  const text = !how.isAbsolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
80  const done = await h.copy({ text, ...(how.surface ? { surface: how.surface as 'terminal' } : {}) })
81  h.toast(done.isCopied ? words().copied(text) : words().notCopied)
82}
83
84export async function toggle(h: Host, n: FileNode): Promise<void> {
85  if (n.kind === 'dir' && !(await h.tree()).expanded.includes(n.id)) await loadDirs(h, [n.id])
86  await patch(h, t => {
87    const open = new Set(t.expanded)
88    if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
89    return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
90  })
91}
92
93export async function openNode(h: Host, n: FileNode): Promise<void> {
94  if (n.kind === 'dir') {
95    mem.follow = false
96    await reset(h, n.id)
97  } else await openFile(h, n.id)
98}
99
100export async function press(h: Host, n: FileNode): Promise<void> {
101  const now = await h.now()
102  const isDouble = mem.lastPress.key === n.id && now - mem.lastPress.at < DOUBLE_MS
103  mem.lastPress = { key: isDouble ? '' : n.id, at: now }
104  if (!isDouble) {
105    await toggle(h, n)
106    return
107  }
108  await openNode(h, n)
109}
110
111export async function reveal(h: Host, paths: string[]): Promise<void> {
112  if (await followCwd(h)) return
113  await revealPaths(h, paths)
114  await patch(h, cur => {
115    const open = new Set(cur.expanded)
116    for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
117    return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
118  })
119}
120
hooks/prompt.ts 45 lines
1import type { Args } from 'claude-code'
2
3import type { FileTree } from '../types'
4import type { Host } from './host'
5import { exists } from './listing'
6import { followCwd, reveal, sync } from './pane'
7import { settleBackground } from './scan'
8import { mem } from './session'
9import { resolve } from './shell'
10import { inside } from './tree'
11
12// The tree's paths a prompt names with `@`, trailing punctuation dropped.
13function mentionsOf(text: string, t: FileTree): string[] {
14  return [...text.matchAll(/@([^\s"'`]+)/g)]
15    .map(m => (m[1] ?? '').replace(/[.,;:!?)]+$/, ''))
16    .filter(Boolean)
17    .map(p => resolve(t.root, p, mem.home))
18    .filter(p => inside(t.root, p))
19}
20
21async function revealMentions(h: Host, mentions: string[]): Promise<void> {
22  const found: string[] = []
23  for (const p of mentions) {
24    try {
25      await h.stat(p)
26      found.push(p.replace(/\/$/, ''))
27    } catch {
28      continue
29    }
30  }
31  if (found.length) await reveal(h, found)
32}
33
34// The prompt's context with the selected file named, so "this" in the prompt can mean it.
35export async function promptContext(h: Host, e: Args<'prompt.submit'>) {
36  void settleBackground(h, e.text)
37  if (!(await followCwd(h))) void sync(h)
38  const t = await h.tree()
39  const context = [...(e.context ?? [])]
40  if (t.selected && (await exists(h, t.selected))) context.push(`The user has this file selected in the file tree; "this" or "it" in the prompt likely refers to it: ${t.selected}`)
41  const mentions = mentionsOf(e.text, t)
42  if (mentions.length) void revealMentions(h, mentions)
43  return context
44}
45
hooks/readstep.ts 61 lines
1// The hooks' side of read costs (readcost.ts): a read waits in its loop until the loop's next request reports
2// how big its prompt grew. Steps of the main loop and of each subagent are measured apart, by `agentId`.
3
4import type { Host } from './host'
5import { addedOf, attribute, growthOf, type PendingRead, rateAfter, sizeOf, type Usage } from './readcost'
6import { type Loop, mem } from './session'
7import { posix } from './tree'
8import { noteCosts } from './turn'
9
10type Step = { turnId: string; agentId: string | undefined; reads: PendingRead[]; usage: Usage | null }
11
12const keyOf = (agentId: string | undefined): string => agentId ?? ''
13
14function loopOf(agentId: string | undefined): Loop {
15  const key = keyOf(agentId)
16  const loop = mem.loops.get(key) ?? { turnId: '', size: null, pending: [] }
17  mem.loops.set(key, loop)
18  return loop
19}
20
21// A Read that answered: its file and the length of the text Claude got wait for the loop's next request.
22export function noteRead(read: { id: string; file: string; text: string; agentId: string | undefined }): void {
23  if (!read.id) return
24  loopOf(read.agentId).pending.push({ id: read.id, file: posix(read.file), chars: read.text.length })
25}
26
27// At a request's start, the reads waiting are the step before's: this request's own tools have not run yet.
28export function takeReads(agentId: string | undefined): PendingRead[] {
29  const loop = loopOf(agentId)
30  const reads = loop.pending
31  loop.pending = []
32  return reads
33}
34
35async function measure(h: Host, step: { reads: PendingRead[]; agentId: string | undefined; grown: number }): Promise<void> {
36  const { reads, agentId, grown } = step
37  if (reads.length === 0) return
38  const added = grown > 0 ? addedOf(await h.messages(agentId), reads.map(r => r.id)) : null
39  const costs = attribute({ reads, added, grown }, mem.rate)
40  mem.rate = rateAfter(mem.rate, { added, grown })
41  await noteCosts(h, costs)
42}
43
44// After a request's response: its size, against the size of the request before, measures that step's reads.
45// The loop's size is kept at once, so the next step reads it whatever the measuring still awaits.
46export function stepDone(h: Host, step: Step): Promise<void> {
47  const loop = loopOf(step.agentId)
48  const before = loop.turnId === step.turnId ? loop.size : null
49  const after = sizeOf(step.usage)
50  loop.turnId = step.turnId
51  loop.size = after
52  return measure(h, { reads: step.reads, agentId: step.agentId, grown: growthOf(before, after) })
53}
54
55// A loop that ended, or was cut short, with reads no request followed: they keep a guess.
56export async function loopDone(h: Host, agentId: string | undefined): Promise<void> {
57  const reads = takeReads(agentId)
58  mem.loops.delete(keyOf(agentId))
59  await measure(h, { reads, agentId, grown: 0 })
60}
61