SLOPSHOPPER

quiet-bash

One-line tool rows, tool output hidden, inline PNG thumbnails; /quiet shows everything, /thumb shows a PNG

newrowsguardcommandtoastprompt
★ 2v0.1.1no licenseupdated 2026-10-08schreibse/claude-code-mods/quiet-bash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quiet-bash
› 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 › /quiet ⎿ quiet-bash: Full tool rows. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Personal Claude Code mods: plugins of function hooks that restyle the transcript, add rows around the prompt and react to tool calls. Built and used on Claude Code 2.1.287+, Linux.

Mods

ModWhat it doesCommandsNeeds
quiet-bashOne-line tool rows (✓ <description>, red ✗ exit N on failure, +N −M on edits, duration for calls ≥10 s). Hides every tool's result block (Bash output, Edit diffs, WebFetch, MCP and Agent results; interactive tools, SendUserFile and image Reads keep theirs), finished read-only rows (Read/Grep/Glob, and calls the engine ran read-only like ls or git status, and every Claude in Chrome step but navigate), collapsed tool groups unless one failed, and timed-out GitLab pipeline-wait notices. Inline PNG thumbnails under rows that read, sent or wrote a PNG (at most 3 written ones per call), relative paths included; a group holding a thumbnail stays open, even under /quiet/quiet brings it all back; /thumb [big] <path> shows a PNG (relative to the session's directory) as thumb #N: <path>ImageMagick (magick) for thumbnails; a terminal with kitty graphics (see herdr)
quiet-spinnerPlain spinner words (thinking, writing, running, waiting, preparing) and Took 1m 4s instead of the whimsical ones––
usage-percentRow under the prompt: `ctx 34% \5h 41% \wk 86%, yellow from 80 %, red from 95 %. In Nx repos also memory in the middle (claude.slice usage + app.slice pressure, yellow from 20 %, red from 50 %) and on the right the session's own nx serve projects (▶ admin api; a bare nx serve or run-many -t serve shows ▶ serve) and the branch's pipeline (ci ⏳ test, ⏸` when it waits on a manual job or an approval; none for Azure remotes)–gh / glab logged in for the pipeline; systemd claude.slice for memory
reminder-logTallies the reminders Claude Code injects for the model, per session; drops the token counter and repeated commit attribution blocks (sent again after a compaction)/reminders prints the tally of the last 30 days–
mr-bannerColored card with a link under each MR/PR created, merged, approved or reviewed–GitLab MCP server named gitlab, or glab / gh
coderabbit-bandBand above the prompt with the open CodeRabbit threads (by severity) and nitpicks of the current branch's GitLab MR, with a link. Shows only when something is open/coderabbit hides it until the counts changeglab logged in; GitLab remote
redactSecrets in prompts and tool output reach the model as ‹secret:…› tokens; only Write/Edit turn them back into the real value. See redact–betterleaks on PATH
mem-guardBash commands that run Node tools go into a memory-capped claude-cmd.slice scope. Refused, with the fix in the message: nx affected/run-many without --parallel=1, a pnpm script that has a :lite twin, jest without a worker cap (--maxWorkers=N, -w N or --runInBand; a percentage doesn't count), pkill -f/pgrep -f with an unbracketed pattern, ci:local, heavy runs and dev-server starts while the slice is above 75 % or under pressure, a third heavy command or a third dev server at once, and writes by a check-runner subagent. See mem-guard–Linux with systemd: a user claude-cmd.slice, ps; see mem-guard
handoverSkill plus mod: the handover skill writes a one-sentence handover for a cold session to ~/.claude/handover.md; the mod files it per session under ~/.claude/handovers/<session id>.md, so parallel sessions in one repo never overwrite each other. A band above the prompt shows it; after /clear that session's sentence waits in the prompt (Tab takes it) and the next prompt spends it. A new terminal suggests nothing but lists the repo's open sentences (newest first, 14 days)/handover-copy copies it and hides the band; /handover lists, /handover N puts one in the prompt–
herdr-notifyGNOME popup when Claude waits for a permission, an answer or a new prompt (a Notification hook, not a plugin). Skipped while that pane is the focused one in herdr. Clicking it raises Ghostty and focuses that session's herdr pane. Hook: `"Notification": [{"matcher": "permission_prompt\idle_prompt\elicitation_dialog", "hooks": [{"type": "command", "command": "bash \"$HOME/.claude/skills/herdr-notify/notify.sh\""}]}]`–herdr, Ghostty, notify-send, jq
stack-downOn /clear and exit (also logout, a finished -p run, SIGINT/SIGTERM/SIGHUP), stops what the session left running in its repo: the nx processes it started (with their workers), and the repo's Docker Compose projects (compose down, volumes kept) unless another Claude session works in the same repo. Only for a session at a repo's top level (git rev-parse --show-toplevel); one started above the repos, such as ~/Dev, stops nothing–docker compose, git
review-walkSkill plus mod: /review-walk takes the findings of the reviews already run in the conversation (/code-review, a repo's own review skill, CodeRabbit threads, MR comments), merges and ranks them, and asks fix / issue / skip one finding at a time; fixes wait until every finding is decided. The mod puts Finding N/M, a progress bar and the decisions so far over each question, and a band above the prompt between them. Decisions you authorised up front ("apply every Fix") are recorded through its ReviewWalkDecide tool instead of a question. The walk starts only after the skill runs, so a review on its own draws no band/review-walkA review run first: it walks findings, it never reviews. Any review skill or source works

The model sees exactly what it would without them: the mods change what is drawn, except reminder-log, which drops two kinds of injected reminders, redact, which hides secrets, mem-guard, which runs Node commands inside a systemd scope and refuses some with a mem-guard: … error, and stack-down, which stops the session's own servers and, when no other session is in the repo, its Compose stacks when the session ends.

Three mods call out on their own:

  • mr-banner, when a GitLab MCP note or approval answers without the MR's link, calls get_merge_request on the gitlab MCP server for the link and title. gh/glab commands cost nothing extra.
  • coderabbit-band polls glab for GitLab origin remotes: a 60 s timer checks the branch, and a fetch (glab mr view plus all pages of the MR's discussions) runs every minute for 15 min after a git push, every 5 min while the band shows something, every 15 min otherwise, and once 5 s after a thread is resolved.
  • usage-percent, in Nx repos, polls every 10 s: ps and pwdx for nx serve processes, and docker inspect once per container a server runs in, to read its compose project directory. The pipeline (gh run list / glab ci get) is fetched when HEAD moves, 15 s after a git push, every minute while it runs and every 5 min otherwise.

Install

From the marketplace (gets updates; herdr-notify and herdr-link-toast are not in it):

claude plugin marketplace add schreibse/claude-code-mods
claude plugin install quiet-bash@schreibse-mods   # per mod

Then turn on updates: /plugin → Marketplaces → schreibse-mods → Enable auto-update. See Updates.

From a clone, to adapt a mod or work on it. Claude Code loads every plugin folder under ~/.claude/skills/ at session start. Don't install the same mod from the marketplace too: the installed copy silently replaces the folder.

New sessions pick them up; a running one needs /exit and claude --continue.

Leave a mod out: claude plugin uninstall <mod>@schreibse-mods, or delete or unlink its folder. Try one for a single session: claude --plugin-dir ~/src/claude-code-mods/<mod>.

Where they run. Built and used only in the terminal CLI on Fedora (Linux, systemd, cgroup v2). The engine also loads user-scope mods in the local session the desktop app starts for its Code tab, and draws them there (desktop surface); untested here. See Setting up for what each mod needs.

This repo is the skills folder itself, so .gitignore ignores everything and re-includes each mod: a new mod needs a !/<name>/ line or git won't see it. .claude-plugin/types/ is generated by the engine and stays untracked.

Updates

Each mod has its own version (plugin.json) and a CHANGELOG.md in its folder; each release is a git tag <mod>--v<version>.

  • Get them: with auto-update on, Claude Code updates the mods after a session's first message and says Plugin updated: <mod> · Run /reload-plugins to apply. Without it nothing tells you; check by hand: ``sh claude plugin marketplace update schreibse-mods claude plugin update quiet-bash@schreibse-mods # per mod; "already at the latest version" if none ``
  • What changed: the mod's CHANGELOG.md. Read every version you skipped: a major version means you have to act (a new hook in settings.json, a new dependency, a removed command).
  • Notified outside Claude Code: on GitHub, Watch → Custom → Releases. Each release carries the mod's changelog entry.
  • From a clone: git -C ~/.claude/skills pull --ff-only, then read the changelogs of the mods whose version moved (git -C ~/.claude/skills diff ORIG_HEAD -- '*/.claude-plugin/plugin.json').

redact: secrets as tokens

betterleaks scans every prompt and tool result before it reaches the model. Each value it flags becomes ‹secret:xxxxxxxx› and stays hidden wherever it appears later, even where the scanner would not recognise it again.

The model's callWhat happens
Write, Edit, NotebookEdit with a tokenthe real value is written to the file
Write that would drop a secret the file holdsrefused: use Edit
Agent, SendMessage, TodoWrite with a tokenpassed on as the token
Any other tool with a token (Bash, WebFetch, MCP …)refused, so the value never leaves
A token the mod no longer knows (after a restart)refused, never restored wrongly

Cut-short output. Before Bash or any tool with a file_path/path (Read, Grep, Edit …) runs, every file the call names (Bash: the first 10 words that could be paths, files up to 1 MB) is scanned whole, so cut -c1-60 .env, cut -d= -f2 or a Read of a few lines from a PEM key still come back as tokens. Multi-line secrets are hidden line by line. Bash words resolve against the directory each segment runs in (cd sub && cut -c1-40 .env), and ~/, $HOME/ and ${HOME}/ expand to the home directory.

Rules. betterleaks' defaults plus redact/betterleaks.toml: Sentry DSN keys, and client secrets too short for the generic rules. A client secret containing dev, local, test, example, changeme or placeholder stays visible, so local dev clients keep working in commands.

Not covered:

  • images; output with no file behind it, cut short (git show HEAD:.env | cut …, printenv | cut …); the transcript file's structured tool records, which keep real values (the model does not read them).
  • the cut-short pre-scan misses globs (cut -c1-40 *.env), quoted paths with spaces and a cd inside a subshell ((cd sub && cut -c1-40 .env)).
  • betterleaks' client-secret rule needs some entropy (≥ 3.0), so short, repetitive client secrets stay visible.
  • values shorter than 8 characters are never hidden.
  • a value restored into a file by Write/Edit can be read back transformed (base64, rev, xxd) or sent (curl -T file). redact keeps secrets out of the model's context by accident; it is not a sandbox against a model trying to get them.
  • a subagent's report that quotes a ‹secret:…› token is refused like any other tool call carrying one.

The vault lives in session memory: /exit forgets it.

The status line shows redacted N once a value is hidden: N counts vault entries, so a PEM key counts once per line. A toast per call names the rules that hid new values. Without betterleaks the status line says off (no betterleaks) and nothing is hidden. A betterleaks run that fails leaves that call unredacted (values already in the vault stay hidden): one toast per failure streak, the status says off (betterleaks), and the next call scans again.

mem-guard: memory rules for Bash

Every Bash command that names a Node tool (node, npx, pnpm, npm, npm exec, yarn, nx, jest, vitest, playwright, tsc, ngc) runs as systemd-run --user --scope --slice=claude-cmd.slice -p MemoryMax=30% -p MemorySwapMax=4% -- bash -c '…', so an overrun dies with exit 137 instead of taking the desktop down. Leading cd … && stay outside the wrapper, so the shell's directory still moves.

The rules read what each part of a command runs (after &&, |, ;, &, $( ), inside bash -c '…', following cd), never words it only mentions, so a commit message saying pkill -f passes. Not looked into: xargs, make, eval, scripts.

At once, counted per scope in claude-cmd.slice from one ps scan (skipped when ps fails):

  • at most 2 heavy commands (jest, vitest, Playwright, tsc, ngc, an nx task); the nx daemon and dev servers don't count;
  • at most 2 dev servers (nx serve, nx run x:serve, a serve* script): the API and one app.

A command counts as heavy for these checks when it runs one of those tools, docker compose up, or a test, build, lint, typecheck, e2e or serve* script.

check-runner subagents (an agent type of the owner's) only run checks: git commands that change the tree or history, sed -i, --write/--fix, starting or stopping servers or processes, and writes outside /tmp are refused with "Report the failure instead of fixing it".

Assumes this setup; the messages name it:

  • a claude-cmd.slice user unit. Without it systemd makes the slice with no limit of its own: each command is still capped at 30 % of RAM, but the headroom check has nothing to measure and stays off: ``ini # ~/.config/systemd/user/claude-cmd.slice [Slice] MemoryMax=30% MemorySwapMax=4% ` A full slice can livelock without ever reaching the OOM killer, so let systemd-oomd kill a scope in it under sustained pressure: systemctl --user set-property claude-cmd.slice ManagedOOMMemoryPressure=kill ManagedOOMMemoryPressureLimit=50%. systemd turns a percentage into bytes of the machine's RAM (on 27 GiB: 8.2G and 1.1G), so the same unit fits any machine. Check what a machine gets: systemd-run --user --scope -q -p MemoryMax=30% -- sh -c 'cat /sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)/memory.max'`
  • a dev API served on port 4700: the refusal messages say fuser -k 4700/tcp.
  • repos whose heavy scripts have a :lite twin (lint:affected:lite, typecheck:lite, test:affected:lite) and a slow ci:local script.

usage-percent: not covered

  • On GitHub only the newest workflow run of the branch is shown.
  • A remote using an SSH host alias (git@work:org/repo) reads as ci no login (work) though you are logged in.
  • A server counts as the session's when it runs inside the session's root, or in a container whose compose project lives there. Two sessions on one checkout both see its servers.

herdr: images and links

Under herdr (here inside Ghostty), quiet-bash's thumbnails draw only their alt text, and links in mr-banner, coderabbit-band and Claude's own output aren't clickable.

Cause: Claude Code turns on kitty graphics only for terminals whose XTVERSION answer is kitty (≥ 0.28) or starts with ghostty, and hyperlinks only for terminals it recognises. herdr forwards both, but answers with its own name, so Claude Code turns both off.

Fix: force both on, inside herdr only, before Claude Code starts (it reads them at startup):

# ~/.bashrc.d/herdr-terminal.sh
if [ -n "$HERDR_ENV" ]; then
    export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1
    export FORCE_HYPERLINK=1
fi

Also turn on herdr's kitty graphics passthrough (off by default) in ~/.config/herdr/config.toml:

[experimental]
kitty_graphics = true

Then herdr server reload-config, open a new herdr pane (old panes keep the old environment) and start Claude Code there. Check it: /thumb /path/to/some.png should draw the picture.

herdr-link-toast (a herdr plugin, not a Claude Code mod)

herdr opens a ctrl+clicked link in the background with no feedback, and ignores file:// links altogether. herdr-link-toast opens http(s) links with xdg-open and shows a toast. It also opens the path in quiet-bash's thumbnail captions, which link to http://localhost/open-file/<path> because file:// isn't clickable in herdr. Such a link opens only when the path ends in .png; any other gets a "Could not open link" toast. Needs python3 ≥ 3.9. Install:

herdr plugin link ~/.claude/skills/herdr-link-toast

Without herdr, in plain kitty or Ghostty, none of this is needed. Terminals without the kitty graphics protocol show the thumbnail's alt text (its path).

Setting up on another machine (for AI agents)

Read before installing for someone. The mods were written for one machine; several carry its names and need Linux.

1. Check the host. Mods need Claude Code 2.1.287+. They load in the terminal CLI and, from ~/.claude/skills, in a desktop app's local Code-tab session. A desktop-app session can also be given a folder through CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Nothing here has been tried in the desktop app, VS Code, JetBrains, macOS, WSL or native Windows: say so to the person instead of promising it works.

2. Pick mods by platform.

Works anywhereNeeds LinuxNever on macOS or Windows
quiet-spinner, reminder-log, mr-banner, coderabbit-band, redact (with betterleaks), handover, review-walk; usage-percent's ctx/5h/wk and pipeline partsusage-percent's memory and dev-server parts (cgroup v2, /proc, pwdx; they stay empty elsewhere); stack-down (ps, /proc/<pid>/cwd, setsid)mem-guard: it wraps every Node command in systemd-run, so without systemd every node/pnpm/nx call fails. Leave it out. herdr-notify, herdr-link-toast: Linux, herdr, Ghostty, GNOME
  • Windows: quiet-bash, redact and handover read POSIX paths (/, ~). There, quiet-bash thumbnails and redact's Bash pre-scan find nothing, and handover leaves spent files behind.
  • Surfaces: quiet-bash thumbnails draw only in a terminal with kitty graphics (kitty, Ghostty). Bands and the usage row show in the terminal and the desktop app, not in VS Code or on mobile. /handover-copy can't copy from the desktop app.

3. Adapt to the OS you are on. The mods do not detect the OS; you are running on it, so adapt the person's copy and test it there. The plugin API has no OS call: on Windows $.env.get('OS') is Windows_NT, elsewhere uname -s says Darwin or Linux. Known gaps:

  • mem-guard off Linux: leave it out. To keep its command-only rules (jest worker cap, :lite, pkill -f, ci:local), skip scoped() and the ps/cgroup checks when systemd-run is missing.
  • handover on Windows: HOME may be unset (USERPROFILE), and spent sentences are removed with rm.
  • quiet-bash, redact on Windows: path parsing knows / and ~ only, not C:\….
  • usage-percent on macOS: no pwdx (lsof -a -d cwd -p <pid> instead); the memory zone needs cgroup v2 and stays empty.
  • herdr-notify, herdr-link-toast: Linux, herdr and GNOME only; another OS needs its own notifier, not a port.

Write each OS change as its own commit with a test, so it can come back upstream.

4. Replace the owner's names. Names in this README and in the messages are examples from the owner's repos; use the person's own. The API project there is ec-api, not api, and the dev-server rules only work with the real project names: read them from the repo (npx nx show projects).

ModOwner-specificWhere
mem-guardclaude-cmd.slice and its 30 % / 4 % limits; port 4700; :lite scripts; ci:local "~45 min"; the check-runner agent type; 2 heavy commands, 2 dev servershooks/rules.ts, hooks/register.ts
usage-percentclaude.slice (usage) and app.slice (pressure) under the user manager; Nx repos only (nx.json)hooks/register.tsx
stack-downanother session is a process whose command is claude (native install); an npm install runs as node …/cli.js, goes unseen, and its repo's Compose stacks go down anywayhooks/targets.ts
mr-banner, coderabbit-band, quiet-bashthe GitLab MCP server named gitlab (mcp__gitlab__…)hooks/*.ts(x)
coderabbit-band, usage-percentany remote that isn't GitHub (or Azure) is taken for GitLabhooks/register.tsx
herdr-notifyGhostty's desktop entry com.mitchellh.ghosttynotify.sh

5. Install and check. Install as above (from a clone when you adapted a mod: an update replaces a marketplace copy), then run claude plugin validate <mod> and claude plugin test <mod> for each mod you install. Start a new session and check what the person should now see: the usage row, a tool row with ✓, and for redact, nothing until it hides a value, then redacted N in the status line.

Developing

Each mod is .claude-plugin/plugin.json, hooks/hooks.json and hooks/register.ts(x), plus types/index.d.ts when it keeps session state. Per mod:

claude plugin validate <mod>   # what it hooks and calls, and what the engine would refuse
claude plugin test <mod>       # its *.test.ts(x)
tsc -p <mod>                   # once the engine has laid .claude-plugin/types/

Edits in ~/.claude/skills hot-reload into running sessions that loaded the mod.

Releasing a mod

Marketplace users only get a change once the mod's version moves, so a change meant for them bumps it in the same commit:

  1. Bump version in <mod>/.claude-plugin/plugin.json: major when users must act, minor for a feature, patch for a fix.
  2. Add ## <version> — <date> to <mod>/CHANGELOG.md, with an Action needed line on a major.
  3. claude plugin validate --strict . (the marketplace) and claude plugin validate --strict <mod>.
  4. Commit and push, then claude plugin tag --push <mod>.
  5. gh release create <mod>--v<version> --verify-tag --title "<mod> <version>" --notes-file <entry>, with the new changelog entry as the notes; links in it must be absolute.
Source 3 files
hooks/register.tsx 287 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, Register } from 'claude-code'
3
4import { MAX_IMAGE_SIDE, THUMB_BOXES, commandDir, diffStat, elapsed, failure, fit, isOversized, isQuietRead, keepsResult, openFileUrl, pipelineWaitTimeoutId, pngPathsIn, resolvePath, shortPath, shotMeta, shots, summary, supersede, thumbArgs, thumbKey, thumbRunKey, thumbText } from './rows'
5import type { ShotInfo, ThumbSize } from './rows'
6import type { Thumb } from '../types'
7
8const infos = new Map<string, ShotInfo | null>()
9const shrunk = new Map<string, string | null>()
10const startedAt = new Map<string, number>()
11const durations = new Map<string, number>()
12const readOnly = new Set<string>()
13const timedOutWaits = new Set<string>()
14
15type ToolUseRender = Extract<Parameters<Hook<'ui.render'>>[1], { component: 'ToolUse' }>
16type TerminalRender = Extract<Parameters<Hook<'ui.render'>>[1], { surface: 'terminal' }>
17type ToolCall = Parameters<Hook<'tool.call'>>[1]
18
19const MAX_WRITTEN_THUMBS = 3
20
21const isLoud = atom({ plugin: 'quiet-bash', key: 'isLoud' } as const, false)
22// In session state, so a hot reload of this module keeps the thumbnails of rows already drawn.
23const thumbs = atom({ plugin: 'quiet-bash', key: 'thumbs' } as const, {})
24
25async function shotInfo($: EngineInterface, file: string, mtimeMs = 0): Promise<ShotInfo | null> {
26  const key = `${file}@${mtimeMs}`
27  if (!infos.has(key)) {
28    infos.set(key, await identify($, file).catch(() => null))
29  }
30  return infos.get(key) ?? null
31}
32
33async function thumbOf($: EngineInterface, file: string, size: ThumbSize, since = 0): Promise<Thumb | null> {
34  const stat = await $.fs.stat(file).catch(() => null)
35  if (stat?.kind !== 'file' || stat.mtimeMs < since || !/\.png$/i.test(file)) {
36    return null
37  }
38  const info = await shotInfo($, file, stat.mtimeMs)
39  return info && { file, size, info, mtimeMs: stat.mtimeMs }
40}
41
42async function homeOf($: EngineInterface): Promise<string> {
43  return (await $.env.get('HOME')) ?? ''
44}
45
46// Read and SendUserFile show the PNGs they name; any other call, MCP included, shows PNGs its text names that changed while it ran.
47async function thumbsOf($: EngineInterface, call: ToolCall, result: unknown, since: number): Promise<Thumb[]> {
48  const home = await homeOf($)
49  const cwd = await $.session.cwd()
50  const named = call.tool === 'Read' ? [call.file_path] : call.tool === 'SendUserFile' ? call.files : null
51  if (named !== null) {
52    const found = await Promise.all(named.map(file => thumbOf($, resolvePath(file, cwd, home), 'small')))
53    return found.filter((thumb): thumb is Thumb => thumb !== null)
54  }
55  const dir = call.tool === 'Bash' ? commandDir(call.command, cwd, home) : cwd
56  const found: Thumb[] = []
57  for (const file of pngPathsIn(JSON.stringify([call, result]), dir, home)) {
58    const thumb = found.length < MAX_WRITTEN_THUMBS ? await thumbOf($, file, 'small', since) : null
59    if (thumb) {
60      found.push(thumb)
61    }
62  }
63  return found
64}
65
66async function shrink($: EngineInterface, file: string): Promise<string | null> {
67  const side = `${MAX_IMAGE_SIDE}x${MAX_IMAGE_SIDE}>`
68  const probe = await $.process.run(['magick', `${file}[0]`, '-resize', side, 'INLINE:PNG:-'])
69  const png = probe.stdout.trim().replace(/^data:image\/png;base64,/, '')
70  return probe.exitCode === 0 && !probe.isStdoutTruncated && png.length > 0 ? png : null
71}
72
73async function imageSource($: EngineInterface, thumb: Thumb) {
74  const fromFile = { file: thumb.file, format: 'png', generation: Math.floor(thumb.mtimeMs) } as const
75  if (!isOversized(thumb.info)) {
76    return fromFile
77  }
78  const key = `${thumb.file}@${thumb.mtimeMs}`
79  if (!shrunk.has(key)) {
80    shrunk.set(key, await shrink($, thumb.file).catch(() => null))
81  }
82  const png = shrunk.get(key)
83  return png ? { png } : fromFile
84}
85
86async function drawThumbs($: EngineInterface, e: TerminalRender, list: readonly Thumb[], home: string) {
87  const { Box, Image, Link, Text } = $.ui.resolve(e)
88  const room = Math.max(20, (e.viewport?.columns ?? 100) - 6)
89  const drawn = await Promise.all(list.map(async thumb => ({ thumb, source: await imageSource($, thumb) })))
90  return (
91    <Box flexDirection="column" paddingLeft={2}>
92      {drawn.map(({ thumb, source }) => {
93        const box = THUMB_BOXES[thumb.size]
94        return (
95          <Box flexDirection="column">
96            <Image
97              source={source}
98              {...fit(thumb.info, { columns: Math.min(box.columns, room), rows: box.rows })}
99              alt={thumb.file}
100            />
101            <Text dimColor><Link href={openFileUrl(thumb.file)} label={shortPath(thumb.file, home)} />{` · ${thumb.info.width}`}×{thumb.info.height}</Text>
102          </Box>
103        )
104      })}
105    </Box>
106  )
107}
108
109async function settle($: EngineInterface, id: string): Promise<number | undefined> {
110  const start = startedAt.get(id)
111  if (!durations.has(id) && start !== undefined) {
112    durations.set(id, (await $.clock.now()) - start)
113    startedAt.delete(id)
114  }
115  return durations.get(id)
116}
117
118async function identify($: EngineInterface, file: string): Promise<ShotInfo | null> {
119  const probe = await $.process.run(['magick', 'identify', '-format', '%w %h %B', `${file}[0]`])
120  const [width = 0, height = 0, size = 0] = probe.stdout.trim().split(' ').map(Number)
121  return probe.exitCode === 0 && width > 0 ? { width, height, bytes: size } : null
122}
123
124async function toolRow($: EngineInterface, e: ToolUseRender, engineRow: () => ReturnType<Parameters<Hook<'ui.render'>>[2]>) {
125  const { tool, input, isRunning, isErrored, isInterrupted, output } = e.props
126  const title = summary(tool, input, await homeOf($))
127  if (title === null || (await read($, isLoud))) {
128    return engineRow()
129  }
130
131  const { Box, Text } = $.ui.resolve(e)
132  const isFailed = isErrored && !isInterrupted
133  const shot = isRunning || isFailed ? null : shots(tool, input)
134  if (shot !== null && (await read($, thumbs))[e.props.tool_use_id]?.length === 0) {
135    return <Box />
136  }
137
138  if (shot !== null) {
139    const found = await Promise.all(shot.files.map(file => shotInfo($, file)))
140    return (
141      <Box>
142        <Text color="cyan">  ◆ </Text>
143        <Text bold wrap="truncate-end">{shot.caption}</Text>
144        <Box flexGrow={1} />
145        <Text dimColor>  {shotMeta(found)}</Text>
146      </Box>
147    )
148  }
149
150  const isDone = !isRunning && !isFailed && !isInterrupted
151  if (isDone && (title === '' || isQuietRead(tool, readOnly.has(e.props.tool_use_id)))) {
152    return <Box />
153  }
154
155  const took = isRunning ? '' : elapsed(await settle($, e.props.tool_use_id))
156  const detail = isInterrupted ? 'interrupted' : isFailed ? failure(output) : tool === 'Edit' || tool === 'Write' ? diffStat(output) : ''
157  const meta = [detail, took].filter(Boolean).join(' · ')
158
159  return (
160    <Box>
161      {isFailed ? <Text color="red">  ✗ </Text> : <Text dimColor>  {isRunning ? '…' : isInterrupted ? '⊘' : '✓'} </Text>}
162      <Text dimColor wrap="truncate-end">{title || tool}</Text>
163      {meta ? <Text dimColor>  {meta}</Text> : null}
164    </Box>
165  )
166}
167
168export const register: Register = on => {
169
170  on('session.start', async ($, e, next) => {
171    await $.command.register({ name: 'quiet', description: 'Toggle one-line tool rows' })
172    await $.command.register({ name: 'thumb', description: 'Show a PNG inline: /thumb <path>, /thumb big <path>' })
173    return next(e)
174  })
175
176  on('command.run', { command: 'quiet' }, async $ => {
177    const loud = await update($, isLoud, value => !value)
178    return { text: loud ? 'Full tool rows.' : 'One-line tool rows.' }
179  })
180
181  on('command.run', { command: 'thumb' }, async ($, e) => {
182    const home = await homeOf($)
183    const parsed = thumbArgs(e.args, home)
184    if ('pasted' in parsed) {
185      return { text: 'A pasted path turns into an image attachment before /thumb sees it. Type the path instead of pasting it.' }
186    }
187    const file = resolvePath(parsed.file, await $.session.cwd(), home)
188    const thumb = await thumbOf($, file, parsed.size)
189    if (!thumb) {
190      return { text: `Not a readable PNG: ${file}` }
191    }
192    const run = Object.keys(await read($, thumbs)).filter(key => key.startsWith(thumbKey(''))).length + 1
193    await update($, thumbs, all => ({ ...all, [thumbKey(run)]: [thumb] }))
194    return { text: thumbText(run, file) }
195  })
196
197  on('tool.call', async ($, e, next) => {
198    const since = await $.clock.now()
199    startedAt.set(e.tool_use_id, since)
200    try {
201      const result = await next(e)
202      if (result.isReadOnly) {
203        readOnly.add(e.tool_use_id)
204      }
205      const found = await thumbsOf($, e, result, since).catch(() => [])
206      if (found.length > 0) {
207        await update($, thumbs, all => supersede(all, e.tool_use_id, found))
208      }
209      return result
210    } finally {
211      await settle($, e.tool_use_id)
212      // The row may already have drawn as finished, before its duration and read-only flag were known.
213      $.ui.invalidate('ui.render')
214    }
215  })
216
217  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
218    const row = await toolRow($, e, () => next(e))
219    const list = e.props.isRunning ? undefined : (await read($, thumbs))[e.props.tool_use_id]
220    if (!list || e.surface !== 'terminal' || (await read($, isLoud))) {
221      return row
222    }
223    const { Box } = $.ui.resolve(e)
224    return (
225      <Box flexDirection="column">
226        {row}
227        {await drawThumbs($, e, list, await homeOf($))}
228      </Box>
229    )
230  }).catch(($, e, next) => {
231    $.ui.toast(`quiet-bash: ${e.props.tool} row failed (${next.error.kind}): ${next.error.message ?? 'no message'}`)
232    return next(e)
233  })
234
235  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
236    const { calls, isExpanded } = e.props
237    const shown = await read($, thumbs)
238    const hasThumb = (call: (typeof calls)[number]) => {
239      const list = shown[call.tool_use_id ?? '']
240      return list === undefined ? shots(call.tool, call.input) !== null : list.length > 0
241    }
242    if (calls.some(hasThumb)) {
243      return next({ ...e, props: { ...e.props, isExpanded: true } })
244    }
245    if (isExpanded || calls.some(call => call.isErrored) || (await read($, isLoud))) {
246      return next(e)
247    }
248    const { Box } = $.ui.resolve(e)
249    return <Box />
250  })
251
252  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
253    if ((await read($, thumbs))[e.props.tool_use_id] !== undefined && e.surface === 'terminal' && !(await read($, isLoud))) {
254      const { Box } = $.ui.resolve(e)
255      return <Box />
256    }
257    if (keepsResult(e.props.tool, e.props.output) || (await read($, isLoud))) {
258      return next(e)
259    }
260    const { Box } = $.ui.resolve(e)
261    return <Box />
262  })
263
264  on('ui.render', { component: 'CommandOutput', props: { command: 'thumb' } }, async ($, e, next) => {
265    const key = thumbRunKey(e.props.text)
266    const list = key === undefined ? undefined : (await read($, thumbs))[key]
267    return list && e.surface === 'terminal' ? await drawThumbs($, e, list, await homeOf($)) : next(e)
268  })
269
270  on('prompt.submit', async ($, e, next) => {
271    const id = e.origin.kind === 'task-notification' ? pipelineWaitTimeoutId(e.text) : undefined
272    if (id !== undefined) {
273      timedOutWaits.add(id)
274    }
275    return next(e)
276  })
277
278  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
279    const { origin, task, isExpanded } = e.props
280    if (origin.kind !== 'task-notification' || isExpanded || !timedOutWaits.has(task?.id ?? '') || (await read($, isLoud))) {
281      return next(e)
282    }
283    const { Box } = $.ui.resolve(e)
284    return <Box />
285  })
286}
287
hooks/rows.ts 244 lines
1import type { Thumb, Thumbs } from '../types'
2
3const PLAN_FILE = /\/\.claude\/plans\/[^/]+$/
4const IMAGE_FILE = /\.(png|jpe?g|gif|webp)$/i
5
6export type ShotInfo = { width: number; height: number; bytes: number }
7
8const ENGINE_DRAWN = new Set(['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode', 'TodoWrite'])
9
10export function keepsResult(tool: string, output: unknown): boolean {
11  return ENGINE_DRAWN.has(tool) || tool === 'SendUserFile' || (tool === 'Read' && (output as { type?: string } | undefined)?.type === 'image')
12}
13
14function bytes(n: number): string {
15  return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`
16}
17
18export function shotMeta(infos: readonly (ShotInfo | null)[]): string {
19  const known = infos.filter((info): info is ShotInfo => info !== null)
20  const count = infos.length === 1 ? '1 image' : `${infos.length} images`
21  if (known.length === 0) {
22    return count
23  }
24  const dims = known.map(info => `${info.width}×${info.height}`).join(', ')
25  return `${count} · ${dims} · ${bytes(known.reduce((sum, info) => sum + info.bytes, 0))}`
26}
27
28const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS'])
29// Browser steps come in dozens per task; only navigate says where the browser went.
30const BROWSER_STEP = /^mcp__claude-in-chrome__(?!navigate$)/
31
32export function isQuietRead(tool: string, isFlaggedReadOnly: boolean): boolean {
33  return isFlaggedReadOnly || READ_TOOLS.has(tool) || BROWSER_STEP.test(tool)
34}
35
36type Fields = {
37  command?: string
38  description?: string
39  file_path?: string
40  offset?: number
41  limit?: number
42  url?: string
43  query?: string
44  skill?: string
45  files?: string[]
46  caption?: string
47}
48
49export function shortPath(path: string, home: string): string {
50  return home !== '' && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
51}
52
53// herdr's ctrl+click ignores file:// and a `Link` takes only https or localhost, so the herdr plugin local.link-toast opens this URL's path.
54export function openFileUrl(path: string): string {
55  return `http://localhost/open-file${path.split('/').map(encodeURIComponent).join('/')}`
56}
57
58export function shots(tool: string, input: unknown): { caption: string; files: string[] } | null {
59  const fields = (input ?? {}) as Fields
60  const files =
61    tool === 'SendUserFile' ? (fields.files ?? []).filter(file => IMAGE_FILE.test(file))
62    : tool === 'Read' && fields.file_path !== undefined && IMAGE_FILE.test(fields.file_path) ? [fields.file_path]
63    : []
64  if (files.length === 0) {
65    return null
66  }
67  return { caption: fields.caption ?? files.map(file => file.split('/').pop()).join(' · '), files }
68}
69
70function firstText(input: unknown): string | undefined {
71  const value = Object.values((input ?? {}) as Record<string, unknown>).find(v => typeof v === 'string')
72  return typeof value === 'string' ? value.split('\n')[0] : undefined
73}
74
75export function summary(tool: string, input: unknown, home: string): string | null {
76  if (ENGINE_DRAWN.has(tool)) {
77    return null
78  }
79  const fields = (input ?? {}) as Fields
80  const path = fields.file_path === undefined ? undefined : shortPath(fields.file_path, home)
81  switch (tool) {
82    case 'Bash':
83      return fields.description ?? fields.command?.split('\n')[0] ?? ''
84    case 'Read': {
85      const { offset, limit } = fields
86      const range = offset === undefined ? '' : `:${offset}-${limit === undefined ? '' : offset + limit - 1}`
87      return `Read ${path}${range}`
88    }
89    case 'Edit':
90      return PLAN_FILE.test(fields.file_path ?? '') ? `Updated plan ${path?.split('/').pop()}` : `Edit ${path}`
91    case 'Write':
92      return PLAN_FILE.test(fields.file_path ?? '') ? `Wrote plan ${path?.split('/').pop()}` : `Write ${path}`
93    case 'WebFetch':
94      return `Fetch ${(fields.url ?? '').replace(/^https?:\/\//, '')}`
95    case 'WebSearch':
96      return `Search "${fields.query ?? ''}"`
97    case 'Agent':
98      return `Agent: ${fields.description ?? ''}`
99    case 'Skill':
100      return `Skill ${fields.skill ?? ''}`
101    case 'SendUserFile':
102      return `Sent ${(fields.files ?? []).map(file => file.split('/').pop()).join(', ')}`
103  }
104  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
105  const name = mcp ? `${(mcp[1] ?? '').replace(/^claude_ai_/, '')} ${mcp[2] ?? ''}` : tool
106  const detail = firstText(input)
107  return detail === undefined ? name : `${name}: ${detail}`
108}
109
110type Hunk = { lines?: string[] }
111
112export function diffStat(output: unknown): string {
113  const hunks = ((output ?? {}) as { structuredPatch?: Hunk[] }).structuredPatch ?? []
114  const lines = hunks.flatMap(hunk => hunk.lines ?? [])
115  const added = lines.filter(line => line.startsWith('+')).length
116  const removed = lines.filter(line => line.startsWith('-')).length
117  return added + removed === 0 ? '' : `+${added} −${removed}`
118}
119
120const SLOW_MS = 10_000
121
122export function elapsed(ms: number | undefined): string {
123  if (ms === undefined || ms < SLOW_MS) {
124    return ''
125  }
126  const seconds = Math.round(ms / 1000)
127  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
128}
129
130export function failure(output: unknown): string {
131  const exit = typeof output === 'string' ? /^Exit code (\d+)/.exec(output) : null
132  return exit ? `exit ${exit[1]}` : 'failed'
133}
134
135const PIPELINE_WAIT_TIMEOUT = /<task-id>([^<]+)<\/task-id>[\s\S]*\(gitlab\/wait_for_pipeline\) failed[\s\S]*Timed out waiting for terminal status/
136
137// A wait slice caps at 600 s and Claude re-arms it, so its timeout row is noise; the row's own text lacks the reason, the notification has it.
138export function pipelineWaitTimeoutId(notification: string): string | undefined {
139  return PIPELINE_WAIT_TIMEOUT.exec(notification)?.[1]
140}
141
142export type Box = { columns: number; rows: number }
143export type ThumbSize = 'small' | 'large'
144
145export const THUMB_BOXES: Record<ThumbSize, Box> = {
146  small: { columns: 40, rows: 10 },
147  large: { columns: 120, rows: 40 },
148}
149
150// The terminal draws nothing for a PNG wider or taller than this, so bigger ones are drawn from a shrunk copy.
151export const MAX_IMAGE_SIDE = 2048
152
153export function isOversized(info: ShotInfo): boolean {
154  return info.width > MAX_IMAGE_SIDE || info.height > MAX_IMAGE_SIDE
155}
156
157// A path starts at the start of a word, so the `//host/a.png` of a URL is not one.
158const PNG_PATHS = /(?<![^\s"'`()<>[\]{},;=])[^\s"'`()<>[\]{},;:\\]+\.png(?![\w/-]|\.\w)/gi
159const LEADING_CD = /^\s*cd(?:\s+("[^"]*"|'[^']*'|[^\s&]+))?\s*&&/
160
161function expandHome(word: string): string {
162  return word.replace(/^\$(?:HOME|\{HOME\})(?=\/|$)/, '~')
163}
164
165function absolute(path: string, dir: string, home: string): string {
166  return path.startsWith('/') ? path : path === '~' || path.startsWith('~/') ? `${home}${path.slice(1)}` : `${dir}/${path}`
167}
168
169export function resolvePath(target: string, dir: string, home: string): string {
170  const parts: string[] = []
171  for (const part of absolute(expandHome(target.replace(/^(['"])(.*)\1$/, '$2')), dir, home).split('/')) {
172    if (part === '..') {
173      parts.pop()
174    } else if (part !== '' && part !== '.') {
175      parts.push(part)
176    }
177  }
178  return `/${parts.join('/')}`
179}
180
181// Where a command's relative paths point: the session cwd, moved by any `cd X &&` it starts with.
182export function commandDir(command: string, cwd: string, home: string): string {
183  let dir = cwd
184  let previous = cwd
185  let rest = command
186  for (let cd = LEADING_CD.exec(rest); cd !== null; cd = LEADING_CD.exec(rest)) {
187    const next = cd[1] === '-' ? previous : resolvePath(cd[1] ?? '~', dir, home)
188    previous = dir
189    dir = next
190    rest = rest.slice(cd[0].length)
191  }
192  return dir
193}
194
195// Terminal cells are about twice as tall as wide, so a picture keeps its shape at half the rows.
196export function fit(info: { width: number; height: number }, box: Box): Box {
197  let columns = box.columns
198  let rows = Math.round((columns * info.height) / info.width / 2)
199  if (rows > box.rows) {
200    rows = box.rows
201    columns = Math.round((rows * 2 * info.width) / info.height)
202  }
203  const clamp = (n: number) => Math.min(255, Math.max(1, n))
204  return { columns: clamp(columns), rows: clamp(rows) }
205}
206
207// `text` is JSON, so its escapes (`\n`, `\"`, `\/`) are undone before paths are cut out of it.
208export function pngPathsIn(text: string, dir: string, home: string): string[] {
209  const paths = text.replace(/\\\//g, '/').replace(/\\[nrt"\\]/g, ' ').match(PNG_PATHS) ?? []
210  return [...new Set(paths.map(path => resolvePath(path, dir, home)))]
211}
212
213export function thumbArgs(args: string, home: string): { file: string; size: ThumbSize } | { pasted: true } {
214  const trimmed = args.trim()
215  const file = trimmed.replace(/^big\s+/, '')
216  if (/^\[Image #\d+\]$/.test(file)) {
217    return { pasted: true }
218  }
219  return { file: file.replace(/^~(?=\/|$)/, home), size: file === trimmed ? 'small' : 'large' }
220}
221
222const THUMB_RUN = /^thumb #(\d+): /
223
224export function thumbText(run: number, file: string): string {
225  return `thumb #${run}: ${file}`
226}
227
228export function thumbKey(run: number | string): string {
229  return `cmd:${run}`
230}
231
232// Identical `/thumb x.png` runs each keep their own image, so a later one does not redraw an earlier row.
233export function thumbRunKey(text: string): string | undefined {
234  const run = THUMB_RUN.exec(text)
235  return run ? thumbKey(run[1] ?? '') : undefined
236}
237
238// The newest call showing an unchanged image keeps it, so a Read checked before SendUserFile does not draw it twice.
239export function supersede(all: Thumbs, id: string, found: readonly Thumb[]): Thumbs {
240  const isShown = (thumb: Thumb) => found.some(f => f.file === thumb.file && f.mtimeMs === thumb.mtimeMs)
241  const rest = Object.fromEntries(Object.entries(all).map(([key, list]) => [key, key.startsWith('cmd:') ? list : list.filter(thumb => !isShown(thumb))]))
242  return { ...rest, [id]: [...found] }
243}
244
types/index.d.ts 10 lines
1export type IsLoud = boolean
2export type Thumb = { file: string; size: 'small' | 'large'; info: { width: number; height: number; bytes: number }; mtimeMs: number }
3export type Thumbs = Record<string, Thumb[]>
4
5declare module 'claude-code' {
6  interface PluginState {
7    'quiet-bash': { isLoud: IsLoud; thumbs: Thumbs }
8  }
9}
10