SLOPSHOPPER

log-viewer

Show the last 60 lines of a small local log with an optional filter.

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · log-viewer
│ ┃ Log Viewer ✕ › fix the failing auth test and add an audit log call │ ┃ Log Viewer │ ┃ ⏺ Read(src/auth.ts) │ ┃ Show the last 60 lines of a small local log ⎿ Read 6 lines │ ┃ with an optional filter. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ File: app.log ⏎ open ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Contains: ⏎ filter │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Could not inspect file: ENOENT: app.log │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Read file again ] │ › /log-viewer │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Log Viewer
Log Viewer Show the last 60 lines of a small local log with an optional filter. File: app.log ⏎ open Contains: ⏎ filter Could not inspect file: ENOENT: app.log [ Read file again ]
README

Log Viewer

Show the last 60 lines of a small local log with an optional filter.

Use it when: Inspect a development log on demand.

Log Viewer render preview

Screenshot of this mod's render tree in the fixture preview, with sample data. This is not a capture of the Claude Code application. How previews are made.

Install and open

claude plugin marketplace add whyashthakker/awesome-claude-code-mods
claude plugin install log-viewer@awesome-claude-code-mods

Run /reload-plugins in an existing session, then /log-viewer. Use Tab and Enter to operate controls; Esc closes the pane. Terminal and Desktop Code tab supported; pane UI is unavailable in claude -p and the VS Code chat panel.

Try the source without installing:

claude --plugin-dir ./mods/log-viewer

Example: open /log-viewer and enter app.log in its input.

Access and behavior

Reads the chosen file (up to 256 KiB) on request. No writes, processes, network, or persistence.

Module variables reset on hot reload. All display output is bounded; viewers may truncate long content to 9,000 characters. Relative file paths and Git commands use the session working directory.

Verify

Tested with Claude Code 2.1.288. Minimum documented mods version: 2.1.287. The API can change; validate against your installed version.

claude plugin validate mods/log-viewer --strict
claude plugin test mods/log-viewer

Tests cover plugin commands, pane isolation, both UI surfaces, and the behavior described in tests/register.test.ts. Drawing tests validate element trees; they do not verify pixels painted by the native apps. Validation details.

MIT licensed. No runtime packages or build step required.

Source 2 files
hooks/register.js 55 lines
1import { clean, text, code, row, frame, bar, pretty, bounded, entries, safePath } from './ui.js'
2const ID = "log-viewer"
3let target="app.log"
4let report='Open a file to inspect it.'
5let filter=''
6
7function transform(source) {
8 const lines=source.split('\n').filter(l=>!filter || l.toLowerCase().includes(filter.toLowerCase()))
9 return lines.length+' matching lines · showing last 60\n\n'+lines.slice(-60).join('\n')
10}
11
12
13async function load($) {
14 try {
15  const path = safePath(target)
16  const info = await $.fs.stat(path)
17  if(info.size > 262144) throw new Error('File exceeds the 256 KiB viewer limit.')
18  const source = await $.fs.read(path)
19  report = transform(source)
20 } catch(error) { report='Could not inspect file: '+error.message }
21 $.ui.invalidate('ui.render')
22}
23
24export function register(on) {
25  on('session.start', async ($, e, next) => {
26
27    await $.command.register({name:ID, description:"Show the last 60 lines of a small local log with an optional filter.", immediate:true, argumentHint:"[path]"})
28    return next(e)
29  })
30  on('command.run', {command:"log-viewer"}, async ($, e) => {
31    if(e.args.trim()) target=e.args.trim()
32await load($)
33    await $.ui.open({id:ID,title:"Log Viewer",focus:true,closeOnEscape:true})
34    return {}
35  })
36
37  on('ui.render', {component:'Pane'}, async ($, e, next) => {
38    if(e.requestId !== ID) return next(e)
39    const E = $.ui.resolve(e)
40    try {
41      const body = await draw($, E)
42      return frame(E,"Log Viewer","Show the last 60 lines of a small local log with an optional filter.",body)
43    } catch(error) {
44      return frame(E,"Log Viewer",'Unable to inspect this data.',[text(E,error.message,{color:'yellow'}),E.Button({key:'refresh',label:'Refresh',onPress:()=>$.ui.invalidate('ui.render')})])
45    }
46  })
47}
48async function draw($, E) {
49  const refresh = E.Button({key:'refresh',label:'Refresh',hotkey:'r',plain:true,onPress:()=>$.ui.invalidate('ui.render')})
50
51return [E.Input({key:'target',label:'File',value:target,submitLabel:'open',onSubmit:async(value)=>{target=value;await load($)}}),
52 E.Input({key:'filter',label:'Contains',value:filter,submitLabel:'filter',onSubmit:async(v)=>{filter=v;await load($)}}), code(E,report,"text"),E.Button({key:'refresh',label:'Read file again',onPress:()=>load($)})]
53
54}
55
hooks/ui.js 36 lines
1// Pure UI helpers. No engine access; each plugin contains its own copy.
2export function clean(value, limit = 9000) {
3  return String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').slice(0, limit)
4}
5export function text(E, value, props = {}) {
6  return E.Text({ ...props, children: [clean(value)] })
7}
8export function code(E, value, language = 'text') {
9  return E.Code({ source: clean(value), language })
10}
11export function row(E, children) {
12  return E.Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children })
13}
14export function frame(E, title, subtitle, children) {
15  return E.Box({ flexDirection: 'column', gap: 1, children: [
16    text(E, title, { bold: true, color: 'cyan' }),
17    text(E, subtitle, { dimColor: true }), ...children,
18  ] })
19}
20export function bar(percent, width = 24) {
21  const p = Math.max(0, Math.min(100, Number(percent) || 0))
22  const n = Math.round(p / 100 * width)
23  return '[' + '#'.repeat(n) + '-'.repeat(width - n) + '] ' + p.toFixed(1) + '%'
24}
25export function pretty(value) { return JSON.stringify(value, null, 2) }
26export function bounded(value, max = 9000) {
27  if (value.length > max) throw new Error('Input exceeds ' + max + ' characters. Use a smaller selection.')
28  return value
29}
30export function entries(value) { return value && typeof value === 'object' ? Object.entries(value) : [] }
31export function safePath(value) {
32  const path = value.trim()
33  if (!path || path.startsWith('-') || /[\x00-\x1f]/.test(path)) throw new Error('Enter a path without control characters or leading flags.')
34  return path
35}
36