A pane that tails the files your Bash calls write to, so you can watch long-running output without a second terminal.

Watch the files your Bash commands write to, without a second terminal.
logpane is a Claude Code mod. When a Bash command redirects output to a .log file, logpane tails that file. A band above the prompt lists the files, and a pane shows the lines of one file.

For example, run make > build.log 2>&1 through Claude Code and watch build.log fill in the pane.
● while a process is writing to it and ○ when it ends.To try logpane from a clone:
claude --plugin-dir ~/git/logpane
To install it for normal use:
/plugin install logpane --marketplace benlrice/logpane
Enter y to add the marketplace, then select a scope.
logpane starts with the session. There is nothing to launch.
The band opens by itself when a tracked file goes live. It is one row above the prompt, with one button per file (for example 1 ● build.log 2 ○ test.log) and a ✕ button.
ctrl+x then Tab, or click the band.✕ to dismiss the band. It stays hidden until a tracked file goes live again or a new Bash command writes to a tracked file.The pane opens when you press a band button or enter /logpane. It never opens by itself. It has:
dir/name.live: <name> or ended: <name>.The pane shows the newest file until you press a button or number key. Then it keeps your selection.
| Key | Action |
|---|---|
| Tab or arrow keys, then Enter | Move between the buttons and press one. |
| 1 to 8 | Select the file with that number. |
| m | Maximize the pane. Press again to restore it. |
| Esc | Close the pane and remove ended files. The band returns if a file is live. |
Entering /logpane again removes ended files, clears your selection, and opens the pane with the keyboard focus.
logpane checks every 2 seconds with one lsof call. A file is live while a process has it open for writing, or while a foreground Bash command runs against it. A new command, or output in the last 4 seconds, also keeps a file live, so short writes between checks are not missed.
[!NOTE] Without
lsof, a file is live for 20 seconds after its last output.
| Command | Tracked files | ||
|---|---|---|---|
cmd > out.log, cmd >> out.log | out.log | ||
| `cmd \ | tee out.log, cmd \ | tee -a out.log` | out.log |
cmd > a.log 2> b.log | a.log, b.log | ||
&> f.log, &>> f.log, 1> f.log, `>\ | f.log` | f.log | |
tee [-a -i -p] f1.log f2.log ... | Each file operand that ends in .log | ||
> "my build.log", > 'a b.log' | The quoted path | ||
| `a && b > x.log; c \ | tee y.log` | x.log, y.log | |
cmd > out.json, `cmd \ | tee notes.md, cmd > .log` | None (the name must end in .log) | |
2>&1 and other fd duplications | None | ||
/dev/null, /dev/stdout, any other path under /dev or /proc | None | ||
A path with $VAR, ~, a backtick, * or ? | None (logpane cannot expand it) | ||
A redirect in $(...), in backticks or in a heredoc body | None |
If logpane cannot read part of a command, it skips that part and does not guess. Relative paths resolve from the session's working directory. logpane uses tail -F, so a file that does not exist yet, or that is truncated or rotated, is followed.
[!TIP] To watch other output, send it to a
.logfile.
.log, in upper or lower case, are tracked. build.log.1, app.log.gz and a file named only .log are not.cd in a compound command, or command substitutions.lsof.To check and test the mod:
claude plugin validate .
claude plugin test .hooks/register.tsx 285 lines1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import { cleanLine, extractTargets, resolvePath } from './parse'
5import {
6 attach,
7 detach,
8 dismiss,
9 dropEnded,
10 empty,
11 isBandWanted,
12 output,
13 pick,
14 probed,
15 type Effects,
16 type Registry,
17 type Result,
18} from './registry'
19import { isTrackedFile } from './tracked'
20import { viewFiles } from './view'
21
22const PANE = 'logpane'
23const INITIAL_LINES = 200
24// The Registry's GRACE_MS is two ticks; change them together.
25const TICK_MS = 2_000
26
27const registry = atom({ plugin: 'logpane', key: 'registry' } as const, empty)
28const tick = atom({ plugin: 'logpane', key: 'tick' } as const, 0)
29const size = atom({ plugin: 'logpane', key: 'size' } as const, 'normal')
30const paneOpen = atom({ plugin: 'logpane', key: 'paneOpen' } as const, false)
31
32// Pane sizes: inline body rows and docked body columns. Normal leaves both
33// to the engine's default.
34const SIZES = {
35 normal: {},
36 max: { rows: 52, columns: 156 },
37} as const
38type Size = keyof typeof SIZES
39
40type Env = Engine
41
42let isTicking = false
43const tails = new Map<string, { return: (value: never) => unknown }>()
44const partial = new Map<string, string>()
45
46// Paths from `paths` (Tracked files) that a process has open for writing, or null when lsof
47// cannot run. lsof exits 1 with no output when nothing has the paths open.
48async function probeWriters($: Env, paths: string[]): Promise<Set<string> | null> {
49 const writers = new Set<string>()
50 let out = ''
51 const run = $.process.spawn({ argv: ['lsof', '-Fan', '--', ...paths] })
52 try {
53 for await (const { stream, text } of run) if (stream === 'stdout') out += text
54 } catch {
55 return null
56 }
57 // Exit 0 lists writers; exit 1 is lsof's answer for no open files. Anything else is a failure.
58 const exit = await run.result.catch(() => null)
59 if (exit?.code !== 0 && exit?.code !== 1) return null
60
61 let access = ''
62 for (const line of out.split('\n')) {
63 if (line.startsWith('f')) access = ''
64 else if (line.startsWith('a')) access = line.slice(1)
65 else if (line.startsWith('n') && (access === 'w' || access === 'u')) writers.add(line.slice(1))
66 }
67
68 return writers
69}
70
71let isRefreshing = false
72
73async function refresh($: Env) {
74 if (isRefreshing) return
75 isRefreshing = true
76 try {
77 const before = await read($, registry)
78 if (before.files.length === 0) return
79 const writers = await probeWriters($, before.files.map(file => file.path))
80 const now = await $.clock.now()
81 await commit($, state => probed(state, now, writers))
82 } finally {
83 isRefreshing = false
84 }
85}
86
87// One `update` per operation. The registry is pure, so the updater is safe to
88// re-run; the effects of the final run are the ones applied.
89async function commit($: Env, operation: (state: Registry) => Result) {
90 let effects: Effects = { start: [], stop: [] }
91 await update($, registry, state => {
92 const result = operation(state)
93 effects = result.effects
94
95 return result.state
96 })
97 for (const path of effects.stop) stopTail(path)
98 for (const path of effects.start) startTail($, path)
99}
100
101function stopTail(path: string) {
102 void tails.get(path)?.return(undefined as never)
103 tails.delete(path)
104 partial.delete(path)
105}
106
107function startTail($: Env, path: string) {
108 if (tails.has(path)) return
109 const run = $.process.spawn({
110 argv: ['tail', '-n', String(INITIAL_LINES), '-F', '--', path],
111 })
112 tails.set(path, run)
113
114 void (async () => {
115 try {
116 for await (const { stream, text } of run) {
117 if (tails.get(path) !== run) break
118 if (stream !== 'stdout') continue
119 const parts = ((partial.get(path) ?? '') + text).split('\n')
120 partial.set(path, parts.pop() ?? '')
121 if (parts.length === 0) continue
122 const now = await $.clock.now()
123 const lines = parts.map(cleanLine)
124 await commit($, state => output(state, now, path, lines))
125 }
126 } catch {
127 // tail could not start or was killed with the Tracked file; nothing to show
128 } finally {
129 if (tails.get(path) === run) tails.delete(path)
130 }
131 })()
132}
133
134async function dropEndedFiles($: Env) {
135 const now = await $.clock.now()
136 await commit($, state => dropEnded(state, now))
137}
138
139async function openPane($: Env, extra: { rows?: number; columns?: number } = {}) {
140 const opened = await $.ui.open({ id: PANE, title: 'logpane', focus: true, closeOnEscape: true, ...extra })
141 if (opened.isPlaced) await update($, paneOpen, () => true)
142
143 return opened
144}
145
146// Opens the Pane at normal size on a Pick (null follows Newest). If the Pane
147// cannot be placed, the earlier Pick comes back.
148async function showPane($: Env, path: string | null) {
149 const previous = (await read($, registry)).picked
150 await update($, registry, state => pick(state, path))
151 await update($, size, () => 'normal')
152 const opened = await openPane($)
153 if (!opened.isPlaced) await update($, registry, state => pick(state, previous))
154
155 return opened
156}
157
158export const register: Register = on => {
159 on('session.start', async ($, e, next) => {
160 await $.command.register({
161 name: 'logpane',
162 description: 'Open a pane tailing the files your Bash calls write to',
163 })
164 if (!isTicking) {
165 isTicking = true
166 $.clock.every(TICK_MS, () => {
167 void update($, tick, n => n + 1)
168 refresh($).catch(() => {})
169 })
170 }
171
172 return next(e)
173 })
174
175 on('command.run', { command: 'logpane' }, async $ => {
176 await dropEndedFiles($)
177 const opened = await showPane($, null)
178
179 return opened.isPlaced ? {} : { text: `logpane: the pane could not be placed. ${opened.reason}` }
180 })
181
182 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
183 const targets = typeof e.command === 'string' ? extractTargets(e.command) : []
184 if (targets.length === 0) return next(e)
185
186 const cwd = await $.session.cwd()
187 const paths = [...new Set(targets.map(target => resolvePath(cwd, target)).filter(isTrackedFile))]
188 if (paths.length === 0) return next(e)
189 const isForeground = e.run_in_background !== true
190 const attachedAt = await $.clock.now()
191 await commit($, state => attach(state, attachedAt, paths, isForeground))
192
193 try {
194 return await next(e)
195 } finally {
196 if (isForeground) {
197 const detachedAt = await $.clock.now()
198 await commit($, state => detach(state, detachedAt, paths))
199 }
200 }
201 })
202
203 on('ui.close', async ($, e, next) => {
204 if (e.id === PANE) {
205 await update($, paneOpen, () => false)
206 await dropEndedFiles($)
207 }
208
209 return next(e)
210 })
211
212 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
213 const { Box, Button, Text } = $.ui.resolve(e)
214 await read($, tick)
215 const state = await read($, registry)
216 const level = await read($, size)
217 const now = await $.clock.now()
218
219 if (state.files.length === 0) {
220 return <Text dimColor>No files tracked yet. Run a Bash command that redirects to a file, such as `make > build.log`.</Text>
221 }
222
223 const views = viewFiles(state, now)
224 const shownView = views.find(view => view.isShown)!
225 const resize = async (target: Size) => {
226 const next: Size = level === target ? 'normal' : target
227 await update($, size, () => next)
228 await openPane($, SIZES[next])
229 }
230 const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
231
232 return (
233 <Box flexDirection="column">
234 <Box justifyContent="flex-end" columnGap={1}>
235 <Button hotkey="m" label={level === 'max' ? '❐' : '□'} onPress={() => resize('max')} />
236 </Box>
237 <Box flexWrap="wrap" columnGap={1}>
238 {views.map((view, i) => (
239 <Button
240 key={view.path}
241 hotkey={String(i + 1)}
242 variant={view.isShown ? 'primary' : undefined}
243 label={`${view.marker} ${view.label}`}
244 onPress={() => update($, registry, current => pick(current, view.path))}
245 />
246 ))}
247 </Box>
248 <Text dimColor>
249 {shownView.isLive ? 'live' : 'ended'}: {shownView.label}
250 </Text>
251 {state.files.find(file => file.path === shownView.path)!.lines.slice(-room).map((line, i) => (
252 <Text key={i} wrap="truncate-end">{line}</Text>
253 ))}
254 </Box>
255 )
256 })
257
258 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259 const state = await read($, registry)
260 const isOpen = await read($, paneOpen)
261 await read($, tick)
262 const now = await $.clock.now()
263
264 if (e.props.hasSurvey || isOpen || e.props.maxRows < 1 || !isBandWanted(state, now)) return next(e)
265
266 const { Box, Button } = $.ui.resolve(e)
267 const views = viewFiles(state, now)
268 return (
269 <Box justifyContent="space-between">
270 <Box flexWrap="wrap" columnGap={1}>
271 {views.map((view, i) => (
272 <Button
273 key={view.path}
274 hotkey={String(i + 1)}
275 label={`${i + 1} ${view.marker} ${view.label}`}
276 onPress={() => showPane($, view.path)}
277 />
278 ))}
279 </Box>
280 <Button key="dismiss" label="✕" onPress={() => update($, registry, dismiss)} />
281 </Box>
282 )
283 })
284}
285hooks/parse.ts 228 lines1// Finds the files a shell command line redirects or tees output to. No shell runs here:
2// anything that needs expansion is skipped rather than guessed.
3
4const UNSAFE = /[$`*?\\{]/
5const KEYWORDS = new Set(['{', '!', 'if', 'then', 'else', 'elif', 'do', 'while', 'until', 'time'])
6const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
7
8type Mode = 'none' | 'target' | 'skip'
9
10export function extractTargets(command: string): string[] {
11 const found: string[] = []
12 const heredocs: { delimiter: string; isTabbed: boolean }[] = []
13 let word = ''
14 let hasWord = false
15 let mode: Mode = 'none'
16 let isCommand = true
17 let isTee = false
18 let isFlagsDone = false
19
20 const add = (text: string) => {
21 const isUnsafe = text === '' || UNSAFE.test(text) || text.startsWith('~')
22 if (!isUnsafe && !found.includes(text)) found.push(text)
23 }
24
25 const endWord = () => {
26 if (!hasWord) return
27 const text = word
28 word = ''
29 hasWord = false
30
31 if (mode === 'target') {
32 mode = 'none'
33 add(text)
34 } else if (mode === 'skip') {
35 mode = 'none'
36 } else if (isCommand) {
37 if (KEYWORDS.has(text) || ASSIGNMENT.test(text)) return
38 isCommand = false
39 isTee = text.split('/').pop() === 'tee'
40 } else if (isTee) {
41 if (!isFlagsDone && text.startsWith('-') && text !== '-') {
42 if (text === '--') isFlagsDone = true
43 } else if (text !== '-') {
44 add(text)
45 }
46 }
47 }
48
49 const separate = () => {
50 endWord()
51 mode = 'none'
52 isCommand = true
53 isTee = false
54 isFlagsDone = false
55 }
56
57 const skipBalanced = (from: number, open: string, close: string): number => {
58 let depth = 0
59 let i = from
60 for (; i < command.length; i++) {
61 const c = command[i]
62 if (c === "'" || c === '"') {
63 const end = command.indexOf(c, i + 1)
64 if (end === -1) return command.length
65 i = end
66 } else if (c === open) depth++
67 else if (c === close && --depth === 0) return i + 1
68 }
69 return command.length
70 }
71
72 const skipHeredocBodies = (from: number): number => {
73 let i = from
74 for (const { delimiter, isTabbed } of heredocs) {
75 while (i < command.length) {
76 let end = command.indexOf('\n', i)
77 if (end === -1) end = command.length
78 const line = command.slice(i, end)
79 i = Math.min(end + 1, command.length)
80 if ((isTabbed ? line.replace(/^\t+/, '') : line) === delimiter) break
81 }
82 }
83 heredocs.length = 0
84 return i
85 }
86
87 const readDelimiter = (from: number): number => {
88 let i = from
89 let isTabbed = false
90 if (command[i] === '-') {
91 isTabbed = true
92 i++
93 }
94 while (command[i] === ' ' || command[i] === '\t') i++
95 let delimiter = ''
96 while (i < command.length && !/[\s;&|<>()]/.test(command[i])) {
97 if (command[i] !== "'" && command[i] !== '"' && command[i] !== '\\') delimiter += command[i]
98 i++
99 }
100 heredocs.push({ delimiter, isTabbed })
101 return i
102 }
103
104 for (let i = 0; i < command.length; ) {
105 const c = command[i]
106
107 if (c === ' ' || c === '\t') {
108 endWord()
109 i++
110 } else if (c === '\n') {
111 separate()
112 i = heredocs.length > 0 ? skipHeredocBodies(i + 1) : i + 1
113 } else if (c === '#' && !hasWord) {
114 const end = command.indexOf('\n', i)
115 i = end === -1 ? command.length : end
116 } else if (c === "'") {
117 const end = command.indexOf("'", i + 1)
118 word += command.slice(i + 1, end === -1 ? command.length : end)
119 hasWord = true
120 i = end === -1 ? command.length : end + 1
121 } else if (c === '"') {
122 let j = i + 1
123 while (j < command.length && command[j] !== '"') {
124 if (command[j] === '\\') word += '\\'
125 word += command[j === command.length ? j : command[j] === '\\' ? ++j : j]
126 j++
127 }
128 hasWord = true
129 i = j + 1
130 } else if (c === '\\') {
131 word += command.slice(i, i + 2)
132 hasWord = true
133 i += 2
134 } else if (c === '`') {
135 const end = command.indexOf('`', i + 1)
136 word += '`'
137 hasWord = true
138 i = end === -1 ? command.length : end + 1
139 } else if (c === '$' && (command[i + 1] === '(' || command[i + 1] === '{')) {
140 word += '$'
141 hasWord = true
142 i = command[i + 1] === '(' ? skipBalanced(i + 1, '(', ')') : skipBalanced(i + 1, '{', '}')
143 } else if (c === '(' || c === ')') {
144 separate()
145 i++
146 } else if (c === ';') {
147 separate()
148 i++
149 } else if (c === '|') {
150 separate()
151 i += command[i + 1] === '|' || command[i + 1] === '&' ? 2 : 1
152 } else if (c === '&' && command[i + 1] === '&') {
153 separate()
154 i += 2
155 } else if (c === '&' && command[i + 1] !== '>') {
156 separate()
157 i++
158 } else if (c === '>' || c === '&' || c === '<') {
159 // A bare number glued to the operator is a file descriptor (2>), not an argument.
160 if (hasWord && /^\d+$/.test(word) && (c === '>' || c === '<')) {
161 word = ''
162 hasWord = false
163 } else {
164 endWord()
165 }
166 let op = c
167 i++
168 if (c === '&') {
169 op = '&>'
170 i++
171 }
172 if (op === '<') {
173 if (command[i] === '<' && command[i + 1] === '<') {
174 mode = 'skip'
175 i += 2
176 } else if (command[i] === '<') {
177 i = readDelimiter(i + 1)
178 } else if (command[i] === '(') {
179 i = skipBalanced(i, '(', ')')
180 } else {
181 mode = 'skip'
182 }
183 } else if (command[i] === '(') {
184 i = skipBalanced(i, '(', ')')
185 } else {
186 if (command[i] === '>' && (op === '>' || op === '&>')) i++
187 else if (command[i] === '|' && op === '>') i++
188 if (op === '>' && command[i] === '&') {
189 i++
190 mode = /[0-9-]/.test(command[i] ?? '') ? 'skip' : 'target'
191 } else {
192 mode = 'target'
193 }
194 }
195 } else {
196 word += c
197 hasWord = true
198 i++
199 }
200 }
201 endWord()
202
203 return found
204}
205
206export function resolvePath(cwd: string, path: string): string {
207 const parts: string[] = []
208 const joined = path.startsWith('/') ? path : `${cwd}/${path}`
209
210 for (const part of joined.split('/')) {
211 if (part === '' || part === '.') continue
212 if (part === '..') parts.pop()
213 else parts.push(part)
214 }
215
216 return `/${parts.join('/')}`
217}
218
219// Drops terminal escapes and keeps only what a carriage-return rewrite left visible.
220export function cleanLine(line: string): string {
221 return line
222 .replace(/\r$/, '')
223 .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
224 .replace(/\x1b\][^\x07]*\x07/g, '')
225 .split('\r')
226 .pop()!
227}
228hooks/registry.ts 113 lines1// The registry: every rule about the Tracked files, as pure functions over one
2// value. Operations take `now` and return the new state plus the tails to start
3// or stop; they never spawn, never read a clock, and never mutate their input,
4// so an updater that wraps one is safe to re-run when `update` retries.
5
6import type { Registry, Tracked } from '../types'
7
8export const MAX_FILES = 8
9export const KEEP_LINES = 300
10const LIVE_MS = 20_000
11// Two ticks of the refresh interval (TICK_MS in register.tsx); change them together.
12const GRACE_MS = 4_000
13
14export type { Registry }
15
16/** Paths whose tails the adapter must start or stop. */
17export type Effects = { start: string[]; stop: string[] }
18
19export type Result = { state: Registry; effects: Effects }
20
21export const empty: Registry = { files: [], picked: null, newest: null, dismissed: false }
22
23const none: Effects = { start: [], stop: [] }
24
25// A Tracked file is Live while a process has it open for writing. Without lsof the
26// process is unknown, so recent output stands in for it. Output and a fresh
27// attach count for a short grace, so a writer that reopens the file per line,
28// or one that has not opened it yet, is not missed between probes.
29export const isLive = (file: Tracked, now: number) =>
30 file.foreground > 0 ||
31 file.isWritten === true ||
32 now - file.lastWriteAt < (file.isWritten === null ? LIVE_MS : GRACE_MS) ||
33 now - file.attachedAt < GRACE_MS
34
35/** Whether the Band should show, before the adapter adds its screen conditions. */
36export const isBandWanted = (state: Registry, now: number) =>
37 !state.dismissed && state.files.some(file => isLive(file, now))
38
39/** The index of the Shown file (Pick, else Newest), or the last file when neither is a Tracked file. */
40export const shownIndex = (state: Registry) => {
41 const found = state.files.findIndex(file => file.path === (state.picked ?? state.newest))
42
43 return found === -1 ? state.files.length - 1 : found
44}
45
46// The one un-dismiss rule: Dismiss ends when an Ended file goes Live, or on attach.
47function settle(before: Registry, after: Registry, now: number, isAttach: boolean): Registry {
48 const wentLive = before.files.some(
49 old => !isLive(old, now) && after.files.some(file => file.path === old.path && isLive(file, now)),
50 )
51
52 return isAttach || wentLive ? { ...after, dismissed: false } : after
53}
54
55export function attach(state: Registry, now: number, paths: string[], isForeground: boolean): Result {
56 let files = state.files
57 for (const path of paths) {
58 files = files.some(file => file.path === path)
59 ? files.map(file =>
60 file.path === path
61 ? { ...file, attachedAt: now, foreground: file.foreground + (isForeground ? 1 : 0) }
62 : file,
63 )
64 : [...files, { path, lines: [], foreground: isForeground ? 1 : 0, lastWriteAt: 0, attachedAt: now, isWritten: null }]
65 }
66 const evicted: string[] = []
67 while (files.length > MAX_FILES) {
68 const oldest = files.reduce((a, b) => (b.attachedAt < a.attachedAt ? b : a))
69 evicted.push(oldest.path)
70 files = files.filter(file => file !== oldest)
71 }
72 const picked = state.picked && evicted.includes(state.picked) ? null : state.picked
73 const next = settle(state, { files, picked, newest: paths[paths.length - 1] ?? null, dismissed: state.dismissed }, now, true)
74
75 return { state: next, effects: { start: paths.filter(path => !evicted.includes(path)), stop: evicted } }
76}
77
78export function detach(state: Registry, now: number, paths: string[]): Result {
79 const files = state.files.map(file =>
80 paths.includes(file.path) ? { ...file, foreground: Math.max(0, file.foreground - 1) } : file,
81 )
82
83 return { state: settle(state, { ...state, files }, now, false), effects: none }
84}
85
86export function output(state: Registry, now: number, path: string, lines: string[]): Result {
87 const files = state.files.map(file =>
88 file.path === path ? { ...file, lines: [...file.lines, ...lines].slice(-KEEP_LINES), lastWriteAt: now } : file,
89 )
90
91 return { state: settle(state, { ...state, files }, now, false), effects: none }
92}
93
94/** Record which paths have a writer open: `writers` is that set, or null when lsof could not say. */
95export function probed(state: Registry, now: number, writers: Set<string> | null): Result {
96 const files = state.files.map(file => ({ ...file, isWritten: writers ? writers.has(file.path) : null }))
97
98 return { state: settle(state, { ...state, files }, now, false), effects: none }
99}
100
101export function dropEnded(state: Registry, now: number): Result {
102 const kept = state.files.filter(file => isLive(file, now))
103 const dropped = state.files.filter(file => !kept.includes(file)).map(file => file.path)
104 const picked = state.picked && dropped.includes(state.picked) ? null : state.picked
105
106 return { state: { ...state, files: kept, picked }, effects: { start: [], stop: dropped } }
107}
108
109/** Pick a file, or pass null to follow Newest again. */
110export const pick = (state: Registry, path: string | null): Registry => ({ ...state, picked: path })
111
112export const dismiss = (state: Registry): Registry => ({ ...state, dismissed: true })
113hooks/tracked.ts 10 lines1// The Tracked-file policy: which paths logpane tails. A Tracked file is a `.log`
2// name with a stem (case-insensitive), and never a device or process file.
3
4const EXCLUDED = /^\/(dev|proc)(\/|$)/
5const LOG_NAME = /(^|\/)[^/]+\.log$/i
6
7export function isTrackedFile(path: string): boolean {
8 return LOG_NAME.test(path) && !EXCLUDED.test(path)
9}
10hooks/view.ts 30 lines1// The view: what the Pane and the Band both show about each Tracked file, as pure
2// data. Each handler builds its own JSX from it, so a label or marker changes in one place.
3
4import { isLive, type Registry, shownIndex } from './registry'
5
6export type FileView = { path: string; label: string; isLive: boolean; marker: '●' | '○'; isShown: boolean }
7
8// Button text for each path: the file name, with its folder only where names collide.
9export function labelsFor(paths: readonly string[]): string[] {
10 const names = paths.map(path => path.split('/').pop() ?? path)
11
12 return paths.map((path, i) => {
13 if (names.filter(name => name === names[i]).length < 2) return names[i]
14 const parent = path.split('/').slice(-2, -1)[0]
15
16 return parent ? `${parent}/${names[i]}` : names[i]
17 })
18}
19
20export function viewFiles(state: Registry, now: number): FileView[] {
21 const labels = labelsFor(state.files.map(file => file.path))
22 const shownAt = shownIndex(state)
23
24 return state.files.map((file, i) => {
25 const live = isLive(file, now)
26
27 return { path: file.path, label: labels[i], isLive: live, marker: live ? '●' : '○', isShown: i === shownAt }
28 })
29}
30types/index.d.ts 40 lines1export type Tracked = {
2 /** Absolute path, as the Bash command routed output to it. */
3 path: string
4 /** The last lines read from `tail -F`, oldest first. */
5 lines: string[]
6 /** Foreground Bash calls currently routed to the file. */
7 foreground: number
8 /** When stdout of the tail last produced lines, in ms; 0 for never. */
9 lastWriteAt: number
10 /** When a command last routed output to the file, in ms. */
11 attachedAt: number
12 /** Whether a process has the file open for writing; null when that cannot be checked. */
13 isWritten: boolean | null
14}
15
16export type Registry = {
17 files: Tracked[]
18 /** The path the person Picked, or null to follow Newest. */
19 picked: string | null
20 /** The last target of the most recent Bash call that redirected to a Tracked file. */
21 newest: string | null
22 /** True after the person Dismisses the Band, until a Tracked file goes Live again or a Bash call attaches. */
23 dismissed: boolean
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 logpane: {
29 /** The Tracked files, Pick, Newest and the Dismiss flag, written together. */
30 registry: Registry
31 /** Bumped on a timer so the Live and Ended markers redraw. */
32 tick: number
33 /** The Pane's size: the default, or maximized. */
34 size: 'normal' | 'max'
35 /** True while the Pane is open; the Band hides then. */
36 paneOpen: boolean
37 }
38 }
39}
40