Side pane in fullscreen Claude Code: context usage, git status and the folder tree of the session's directory

Terminal setup for Ubuntu 26.04 LTS on WSL: Ghostty (inside WSL, shown on Windows through WSLg), zsh + zsh4humans + powerlevel10k, tmux, Neovim + LazyVim, and the search tools behind search-functions (ugrep built from source with every archive format, ripgrep, ripgrep-all, fzf, fd, bat, delta, ...), with Intel One Mono as the terminal font, plus Claude Code's look and extras: fullscreen UI, the Catppuccin theme, the workspace side pane (a mod), the global CLAUDE.md (Bilal's rules for Claude), and the plugins and skills that the Linux PC uses.
The steps below are written so that Claude Code running inside WSL can carry them out. A person can follow them just as well.
<name>.bak-<date>-<time> (the commands below do this) and tell the user.sudo command is grouped in a block marked (user runs): ask the user to run that block themselves by typing it after ! in the Claude Code prompt (! sudo apt install ...), then continue with the check.apt says Unable to locate package X, run the install again without X, and list X in your final report. Do not substitute another package on your own.curl -s https://api.github.com/repos/OWNER/REPO/releases/latest | grep tag_name) and use it.sudo apt update
sudo apt install -y \
build-essential pkg-config git curl wget ca-certificates unzip zip 7zip xz-utils fontconfig \
zsh tmux neovim tree-sitter-cli lazygit gh jq tree lsd fd-find bat git-delta nodejs npm python3 \
libpcre2-dev zlib1g-dev libbz2-dev liblzma-dev liblz4-dev libzstd-dev libbrotli-dev \
poppler-utils antiword pandoc libimage-exiftool-perl ffmpeg pipx
What they are for: build-essential, pkg-config and the lib*-dev packages build ugrep (PCRE2 regex, zlib/bzip2/xz/lz4/zstd/brotli for -z; 7-Zip support is bundled in ugrep). poppler-utils (pdftotext), antiword, pandoc and libimage-exiftool-perl are the filters of ugrep+ (PDF, .doc, .docx/.odt/.epub/.rtf, image metadata); pandoc, poppler-utils and ffmpeg are the adapters of rga. tree-sitter-cli, the C compiler and curl are required by LazyVim; lazygit is optional for it. git-delta and nodejs/npm (for git-split-diffs) are used by the git functions of search-functions. pipx installs graphify for Claude Code (step 12).
Check: nvim --version | head -1 shows 0.11.2 or newer (Ubuntu 26.04 ships 0.11.6), and fdfind --version; batcat --version; delta --version; tree-sitter --version all print a version.
fd and bat under their usual namesUbuntu installs them as fdfind and batcat; every config and script here calls fd and bat.
mkdir -p ~/.local/bin
[ -e ~/.local/bin/fd ] || ln -s "$(command -v fdfind)" ~/.local/bin/fd
[ -e ~/.local/bin/bat ] || ln -s "$(command -v batcat)" ~/.local/bin/bat
Check: ~/.local/bin/fd --version and ~/.local/bin/bat --version work.
~/.local/binUbuntu's fzf is too old for search-functions (it needs 0.58 or newer for --style), and the zsh config points z4h at ~/.local/bin/fzf.
V=0.74.5 # latest release of junegunn/fzf, without the "v"
cd "$(mktemp -d)"
curl -fsSLO "https://github.com/junegunn/fzf/releases/download/v$V/fzf-$V-linux_amd64.tar.gz"
curl -fsSLO "https://github.com/junegunn/fzf/releases/download/v$V/fzf_${V}_checksums.txt"
grep "fzf-$V-linux_amd64.tar.gz" "fzf_${V}_checksums.txt" | sha256sum -c -
tar -xzf "fzf-$V-linux_amd64.tar.gz" && install -m 755 fzf ~/.local/bin/fzf
Check: the sha256sum -c line printed OK, and ~/.local/bin/fzf --version shows the version.
Built the same way as on the Linux PC: ./configure --enable-pretty with every compression library from step 1. make install puts ugrep, ug, ugrep+ and ug+ into /usr/local/bin.
V=v7.8.5 # latest release tag of Genivia/ugrep
mkdir -p ~/GitHub
git clone --depth 1 --branch "$V" https://github.com/Genivia/ugrep.git ~/GitHub/ugrep
cd ~/GitHub/ugrep && ./build.sh --enable-pretty
Then (user runs):
cd ~/GitHub/ugrep && sudo make install
Check: ugrep --version lists -P:pcre2jit and -z:zlib,bzip2,lzma,lz4,zstd,brotli,7z,tar/pax/cpio/zip (a missing name means its lib*-dev package was missing during ./build.sh), and command -v ugrep+ ug+ prints /usr/local/bin/... for both.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env"
cargo install --locked ripgrep
cargo install --locked ripgrep_all
Check: rg --version and rga --version work, and rga --rga-list-adapters lists pandoc, poppler and ffmpeg among the adapters.
sudo npm install -g git-split-diffs
Check: command -v git-split-diffs prints a path.
Ghostty runs inside WSL, so the fonts go into WSL (Windows fonts are not visible to it). Intel One Mono is the terminal font; the Nerd Font icons of the prompt, lsd and Neovim come from Ghostty's built-in symbol font. JetBrainsMono Nerd Font is installed as well, for any terminal without built-in icons.
cd "$(mktemp -d)"
curl -fsSLo intel.zip https://github.com/intel/intel-one-mono/releases/download/V1.4.0/ttf.zip # latest release of intel/intel-one-mono
curl -fsSLO https://github.com/ryanoasis/nerd-fonts/releases/download/v3.5.1/JetBrainsMono.tar.xz # latest release of ryanoasis/nerd-fonts
mkdir -p ~/.local/share/fonts/IntelOneMono ~/.local/share/fonts/JetBrainsMonoNerd
unzip -j -o intel.zip '*.ttf' -d ~/.local/share/fonts/IntelOneMono
tar -xJf JetBrainsMono.tar.xz -C ~/.local/share/fonts/JetBrainsMonoNerd
fc-cache -f
Check: fc-list | grep -c "Intel One Mono" and fc-list | grep -c "JetBrainsMono Nerd Font" are both greater than 0.
If the user also works in a terminal that runs on Windows itself (Windows Terminal, an unofficial Ghostty port), that terminal needs the fonts installed in Windows: ask the user to open the .ttf files from \\wsl.localhost\<distro>\home\<user>\.local\share\fonts\ in Windows (wslpath -w ~/.local/share/fonts prints the exact path) and click Install.
The Linux PC also has document fonts (Aref Ruqaa, Scheherazade New, Shaikh Hamdullah Mushaf, Elif1, Atkinson Hyperlegible, Cinzel, Cormorant Garamond, Playfair Display, Victor Mono). They are not needed for the terminal; install them only if the user asks.
Clone this repo to ~/.dot-files and link its files into the home folder. An existing file in the way is renamed to <name>.bak-<date>-<time>; an existing link is left alone and reported.
[ -d ~/.dot-files ] || git clone https://github.com/bilalakbana/dot-files-wsl.git ~/.dot-files
cd ~
for f in .zshenv .zshrc .p10k.zsh .tmux.conf \
.config/shell/env.sh .config/shell/aliases.sh \
.config/tmux/history-search .config/tmux/claude-chat-text.py \
.config/ghostty/config; do
mkdir -p "$(dirname "$f")"
if [ -L "$f" ]; then
[ "$(readlink "$f")" = "$HOME/.dot-files/$f" ] || echo "a different link, left alone: $f -> $(readlink "$f")"
continue
fi
if [ -e "$f" ]; then b="$f.bak-$(date +%F-%H%M%S)"; mv -- "$f" "$b" && echo "backed up: $b"; fi
ln -s "$HOME/.dot-files/$f" "$f"
done
grep -q 'config/shell/env.sh' ~/.bashrc || printf '\n# Shared with zsh: PATH/exports and aliases\n. ~/.config/shell/env.sh\n. ~/.config/shell/aliases.sh\n' >> ~/.bashrc
Check: ls -l ~/.zshrc ~/.tmux.conf ~/.config/ghostty/config shows links into ~/.dot-files, and bash -ic 'command -v fd bat fzf' finds all three in ~/.local/bin.
~/.SearchFunctionsThe scripts call each other through the fixed path ~/.SearchFunctions, so the folder name matters.
git clone https://github.com/bilalakbana/search-functions-wsl.git ~/.SearchFunctions
If the clone asks for a username or fails with Repository not found, the repo is private: run gh auth login (GitHub.com, HTTPS, log in with a web browser), then gh repo clone bilalakbana/search-functions-wsl ~/.SearchFunctions.
Check: ls ~/.SearchFunctions/sub-functions/windows_action exists, and command -v powershell.exe prints a path (Windows interop; Enter / Ctrl-E / Ctrl-C of the search functions need it).
chsh -s /usr/bin/zsh
Then ask the user to open Ghostty (Windows Start menu: Ghostty (Ubuntu), or run ghostty & in a WSL shell). On its first start zsh4humans downloads its plugins and powerlevel10k; tmux starts automatically.
Check: in the new Ghostty window, the prompt is the powerlevel10k prompt, echo $SHELL is /usr/bin/zsh, the tmux status bar is at the bottom, and f, ugrep-fzf and gfzf exist (whence f).
The PC uses the unchanged LazyVim starter (no extras).
[ -e ~/.config/nvim ] && mv ~/.config/nvim ~/.config/nvim.bak-$(date +%F-%H%M%S)
git clone https://github.com/LazyVim/starter ~/.config/nvim
nvim --headless "+Lazy! sync" +qa
Check: nvim --headless "+checkhealth lazyvim" "+w! /tmp/lazyvim-health.txt" +qa and read the file: there must be no ERROR lines (warnings about optional tools are fine).
First the skills from other repositories, with the versions of the Linux PC on 2026-10-23. Do this before the links below: graphify install adds a block to ~/.claude/CLAUDE.md, and the link step then backs up that file and links Bilal's CLAUDE.md (the Linux PC has no graphify block in it either).
npx -y skills@1.7.4 add vercel-labs/skills --skill find-skills -g -a claude-code -y
npx -y skills@1.7.4 add addyosmani/agent-skills --skill interview-me --skill spec-driven-development -g -a claude-code -y
npx -y skills@1.7.4 add blader/humanizer --skill humanizer -g -a claude-code -y
npx -y skills@1.7.4 add nateherkai/scroll-craft --skill scroll-craft -g -a claude-code -y
npx -y skills@1.7.4 add remotion-dev/skills -g -a claude-code -y --skill remotion-best-practices \
--skill remotion-captions --skill remotion-create --skill remotion-docs --skill remotion-interactivity \
--skill remotion-maps --skill remotion-markup --skill remotion-multimedia --skill remotion-render \
--skill remotion-saas --skill remotion-studio --skill remotion-upgrade
pipx install graphifyy==0.9.92
graphify install --platform claude
Check: ls ~/.claude/skills shows 18 folders: find-skills, interview-me, spec-driven-development, humanizer, scroll-craft, graphify and the 12 remotion-* folders.
The side pane (context, limits, git status, folder tree) is the workspace mod in .claude/mods, a folder marketplace called bilal-mods; Claude Code reads it straight from that folder. It needs the fullscreen UI. Link the mod folder, the theme, the global CLAUDE.md and Bilal's own skills (format, autopilot) into ~/.claude (same rules as step 8). An existing ~/.claude/CLAUDE.md is backed up: tell the user, because it can hold rules for the work computer that they want to keep.
mkdir -p ~/.claude/themes ~/.claude/skills
cd ~
for f in .claude/mods .claude/themes/catppuccin-macchiato.json .claude/CLAUDE.md \
.claude/skills/format .claude/skills/autopilot; do
if [ -L "$f" ]; then
[ "$(readlink "$f")" = "$HOME/.dot-files/$f" ] || echo "a different link, left alone: $f -> $(readlink "$f")"
continue
fi
if [ -e "$f" ]; then b="$f.bak-$(date +%F-%H%M%S)"; mv -- "$f" "$b" && echo "backed up: $b"; fi
ln -s "$HOME/.dot-files/$f" "$f"
done
Settings in ~/.claude/settings.json, every other key kept (a dated copy is made first): the fullscreen UI, the theme, no Claude attribution in commits and PRs (the CLAUDE.md forbids it), and the skills that start only when the user types their name:
f=~/.claude/settings.json
[ -f "$f" ] || echo '{}' > "$f"
cp -- "$f" "$f.bak-$(date +%F-%H%M%S)"
by_name='["format","graphify","remotion-captions","remotion-create","remotion-docs","remotion-interactivity",
"remotion-maps","remotion-markup","remotion-multimedia","remotion-render","remotion-saas","remotion-studio",
"remotion-upgrade"]'
jq --argjson s "$by_name" '.tui = "fullscreen" | .theme = "custom:catppuccin-macchiato"
| .attribution = {commit: "", pr: "", sessionUrl: false}
| .skillOverrides = ((.skillOverrides // {}) + ($s | map({key: ., value: "user-invocable-only"}) | from_entries))' \
"$f" > "$f.new" && mv -- "$f.new" "$f"
Plugins. A fresh Claude Code knows only its built-in directory, so the official marketplace is added first (if it says the marketplace already exists, go on):
claude plugin marketplace add anthropics/claude-plugins-official
claude plugin marketplace add DietrichGebert/ponytail
claude plugin marketplace add nextlevelbuilder/ui-ux-pro-max-skill
claude plugin marketplace add ~/.claude/mods
for p in context7 claude-code-setup playground pyright-lsp frontend-design; do
claude plugin install "$p@claude-plugins-official" --scope user
done
claude plugin install ponytail@ponytail --scope user
claude plugin install ui-ux-pro-max@ui-ux-pro-max-skill --scope user
claude plugin install workspace@bilal-mods --scope user
pyright-lsp only works once pyright-langserver is on PATH (sudo npm install -g pyright). The Linux PC has it; ask the user before installing it.
Check: claude plugin list shows the eight plugins enabled, workspace@bilal-mods with Read from: /home/<user>/.claude/mods/workspace, ls -l ~/.claude/CLAUDE.md shows a link into ~/.dot-files, and jq '{tui, theme, attribution, skillOverrides}' ~/.claude/settings.json shows fullscreen, custom:catppuccin-macchiato, the empty attribution and the 13 skills. Then ask the user to quit Claude Code and start it again (the UI mode and the theme apply to new sessions): the Catppuccin colours show, and the pane opens on its own in a window at least 144 columns wide, or with /workspace at any width. In the pane, a text file opens read-only in nvim in a tmux popup; any other file opens in its Windows default app (through ~/.SearchFunctions/sub-functions/windows_action).
~/.zshrc starts the tmux server in its own systemd scope when a systemd user session exists (systemctl --user show-environment succeeds) and without it otherwise; both work.~/.SearchFunctions/sub-functions/windows_action (PowerShell) for Enter (open with the Windows default app), Ctrl-E (show in Explorer) and Ctrl-C (copy the Windows path). Windows interop must stay on (/etc/wsl.conf must not set enabled=false or appendWindowsPath=false under [interop]).maximize = true and window-decoration = none in ~/.config/ghostty/config; if the window cannot be moved or resized under WSLg, set window-decoration = auto there.hooks/register.tsx 475 lines1// Workspace pane: a sidebar beside the fullscreen transcript with the context window's fill, the
2// account's usage limits, git status and a folder tree of the session's directory. Opens by itself
3// when the terminal is fullscreen and wide enough; /workspace shows or hides it.
4// Clicks: a folder in the tree opens or closes, a file in the tree opens in its default app, a file
5// in git status goes into the prompt as @path.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { TreeRow, WorkspaceGit, WorkspaceSnapshot } from '../types'
10import type { LimitReading, SharedLimits } from './lib'
11import {
12 COLOR,
13 contextColor,
14 formatDuration,
15 formatTokens,
16 gitCodeColor,
17 limitRows,
18 modelLabel,
19 parseGitStatus,
20 parseShared,
21 pickLimits,
22 printable,
23 settingsEffort,
24 SNAPSHOT_SCHEMA,
25 sortEntries,
26 tildePath,
27} from './lib'
28
29const PANE = 'workspace'
30const TITLE = 'Workspace'
31const REFRESH_MS = 10_000
32const MODEL_CHECK_MS = 1_000
33const MAX_CHILDREN = 60
34const MAX_TREE_ROWS = 500
35const SKIP = new Set([
36 '.git', 'node_modules', '.venv', 'venv', '__pycache__', '.mypy_cache', '.pytest_cache',
37 '.gradle', '.dart_tool', 'build', 'dist', 'target',
38])
39// GIT_OPTIONAL_LOCKS=0: never take index.lock, so this never collides with a git command Claude runs
40const GIT = { env: { GIT_OPTIONAL_LOCKS: '0' }, timeoutMs: 5_000 }
41
42const snapshot = atom({ plugin: 'workspace', key: 'snapshot' } as const, null)
43const isDismissed = atom({ plugin: 'workspace', key: 'isDismissed' } as const, false)
44const liveEffort = atom({ plugin: 'workspace', key: 'effort' } as const, null)
45const expanded = atom({ plugin: 'workspace', key: 'expanded' } as const, [])
46
47// The newest usage-limit reading any session saw, shared by all sessions (under $HOME)
48const SHARED_LIMITS_FILE = '.cache/claude-workspace-pane/limits.json'
49
50// This session's own limit reading as last seen, and when it last changed (0 = unknown since a
51// (re)load: its age is unknown, so it is not published until it changes)
52let ownSignature: string | undefined
53let ownChangedAt = 0
54
55const message = (err: unknown): string => (err instanceof Error ? err.message : String(err))
56const indent = (depth: number): string => ' '.repeat(depth)
57
58async function readGit($: EngineInterface, cwd: string): Promise<WorkspaceGit | null> {
59 const where = await $.process.run(['git', 'rev-parse', '--show-toplevel', '--show-prefix'], { cwd, ...GIT })
60 if (where.exitCode !== 0) {
61 if (where.stderr.includes('not a git repository')) return null
62 throw new Error(where.stderr.trim() || `git rev-parse exited ${where.exitCode}`)
63 }
64 const [top = '', prefix = ''] = where.stdout.split('\n')
65 const ran = await $.process.run(['git', 'status', '--porcelain=v1', '--branch', '-z'], { cwd, ...GIT })
66 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `git status exited ${ran.exitCode}`)
67 return parseGitStatus(ran.stdout, top, prefix)
68}
69
70// Lists `rel` under `root` into `rows`, descending into the folders the person opened.
71async function readDir(
72 $: EngineInterface,
73 root: string,
74 rel: string,
75 depth: number,
76 open: ReadonlySet<string>,
77 rows: TreeRow[],
78): Promise<void> {
79 const listed = await $.fs.list(rel === '' ? root : `${root}/${rel}`)
80 const entries = sortEntries(listed).filter(entry => !SKIP.has(entry.name))
81 if (entries.length === 0) {
82 const name = listed.length === 0 ? '(empty)' : '(only skipped folders: .git, node_modules …)'
83 rows.push({ depth, name, path: '', kind: 'note', isOpen: false })
84 }
85 for (const entry of entries.slice(0, MAX_CHILDREN)) {
86 if (rows.length >= MAX_TREE_ROWS) {
87 rows.push({ depth, name: '… (list cut here)', path: '', kind: 'note', isOpen: false })
88 return
89 }
90 const path = rel === '' ? entry.name : `${rel}/${entry.name}`
91 const isDir = entry.kind === 'dir'
92 const isOpen = isDir && open.has(path)
93 rows.push({ depth, name: entry.name, path, kind: isDir ? 'dir' : 'file', isOpen })
94 if (!isOpen) continue
95 try {
96 await readDir($, root, path, depth + 1, open, rows)
97 } catch (err) {
98 rows.push({ depth: depth + 1, name: `(${message(err)})`, path: '', kind: 'note', isOpen: false })
99 }
100 }
101 if (entries.length > MAX_CHILDREN) {
102 rows.push({ depth, name: `… ${entries.length - MAX_CHILDREN} more`, path: '', kind: 'note', isOpen: false })
103 }
104}
105
106async function readShared($: EngineInterface, path: string): Promise<SharedLimits | null> {
107 if (!(await $.fs.exists(path))) return null
108 return parseShared(await $.fs.read(path))
109}
110
111// Publishes this session's reading when it changed, then picks the newest of it and the shared one.
112async function readLimits(
113 $: EngineInterface,
114 own: LimitReading[],
115 home: string | undefined,
116 now: number,
117): Promise<LimitReading[]> {
118 if (home === undefined) return own
119 const path = `${home}/${SHARED_LIMITS_FILE}`
120 const signature = JSON.stringify(own)
121 if (ownSignature === undefined) {
122 ownSignature = signature
123 } else if (signature !== ownSignature) {
124 ownSignature = signature
125 ownChangedAt = now
126 if (own.length > 0) await $.fs.write(path, JSON.stringify({ at: now, limits: own }))
127 }
128 return pickLimits(own, ownChangedAt, await readShared($, path))
129}
130
131async function collect($: EngineInterface): Promise<WorkspaceSnapshot> {
132 const errors: string[] = []
133 const cwd = await $.session.cwd()
134 const [model, usage, home, settings, live, opened, now] = await Promise.all([
135 $.session.model(),
136 $.session.usage(),
137 $.env.get('HOME'),
138 $.settings.read(),
139 read($, liveEffort),
140 read($, expanded),
141 $.clock.now(),
142 ])
143 const git = await readGit($, cwd).catch(err => {
144 errors.push(`git: ${message(err)}`)
145 return null
146 })
147 const tree: TreeRow[] = []
148 await readDir($, cwd, '', 0, new Set(opened), tree).catch(err => {
149 errors.push(`files: ${message(err)}`)
150 })
151 const own: LimitReading[] = usage.rateLimits.map(limit =>
152 limit.resetsAt === undefined
153 ? { kind: limit.kind, percentUsed: limit.percentUsed }
154 : { kind: limit.kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt },
155 )
156 const readings = await readLimits($, own, home, now).catch(err => {
157 errors.push(`limits: ${message(err)}`)
158 return own
159 })
160 const limits = limitRows(readings, now)
161 return {
162 schema: SNAPSHOT_SCHEMA,
163 root: cwd,
164 cwdLabel: tildePath(cwd, home),
165 model,
166 effort: live ?? settingsEffort(settings, model),
167 contextPercent: usage.context.percent ?? null,
168 contextTokens: usage.context.tokens ?? null,
169 contextWindow: usage.context.window,
170 limits,
171 git,
172 tree,
173 errors,
174 }
175}
176
177// Module state: reset on every reload, which is fine (a reload re-reads everything).
178let isRefreshing = false
179let isDirty = false
180let hasAutoOpened = false
181// The model the last check saw: a switch (/model, the picker, /config) refreshes the pane at once
182let lastModel: string | null = null
183// The last snapshot of the current shape this module drew: shown while $.state holds one of an
184// older shape (written before a reload, or by an older copy of this mod loaded in the same session)
185let lastDrawn: WorkspaceSnapshot | null = null
186// The last snapshot written, as JSON: an unchanged one is not written again
187let lastWritten = ''
188// A refresh a short dispatch asked for (a ui.render, a press, a tool.call, a turn): the session's
189// 1-second timer runs it on the session.start hook's $, as the short dispatch's $ aborts its calls
190// in flight once that dispatch is abandoned ("$.process.run(git) aborted")
191let isRequested = false
192
193// One refresh at a time; a request during one runs once more afterwards.
194async function refresh($: EngineInterface): Promise<void> {
195 if (isRefreshing) {
196 isDirty = true
197 return
198 }
199 isRefreshing = true
200 try {
201 do {
202 isDirty = false
203 const snap = await collect($)
204 // Every write redraws the pane, and under tmux (no synchronized output) a redraw can flicker
205 const json = JSON.stringify(snap)
206 if (json !== lastWritten) {
207 lastWritten = json
208 await update($, snapshot, () => snap)
209 }
210 } while (isDirty)
211 } catch (err) {
212 $.ui.log(`workspace: refresh failed: ${message(err)}`)
213 } finally {
214 isRefreshing = false
215 }
216}
217
218// Every second: cheap, so a model switch shows at once rather than at the next 10-second refresh.
219// The effort seen on the last request belonged to the old model: forget it, so the new model's
220// effort comes from the settings until its first request reports the real one.
221async function checkModel($: EngineInterface): Promise<void> {
222 const model = await $.session.model()
223 if (lastModel !== null && model !== lastModel) {
224 await update($, liveEffort, () => null)
225 void refresh($)
226 }
227 lastModel = model
228}
229
230async function open($: EngineInterface): Promise<string> {
231 // 44 columns: a narrow sidebar; a width the person drags the dock to wins
232 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: 44 })
233 isRequested = true
234 return opened.isPlaced ? 'Workspace pane opened.' : `Workspace pane is waiting: ${opened.reason}`
235}
236
237async function toggleFolder($: EngineInterface, path: string): Promise<void> {
238 await update($, expanded, list => (list.includes(path) ? list.filter(one => one !== path) : [...list, path]))
239 isRequested = true
240}
241
242// Inside tmux, a text file opens read-only in nvim (his LazyVim config) in a popup over 90% of the
243// terminal. Esc or q closes it: buffer-local maps, so they win over LazyVim's global <Esc> map,
244// which is set later (VeryLazy). Anything else, or outside tmux, opens in its
245// default app: on WSL the Windows one, through the search functions' windows_action (PowerShell).
246// The path reaches the popup as $F, never inside the command text; a # in the title
247// is doubled (tmux formats). setsid -f: the call returns at once, the popup or app lives on.
248const OPEN_FILE = `f=$1
249win=$HOME/.SearchFunctions/sub-functions/windows_action
250if [ -n "$TMUX" ] && command -v nvim >/dev/null &&
251 { [ ! -s "$f" ] || [ "$(file -b --mime-encoding -- "$f")" != binary ]; }; then
252 title=$(basename -- "$f" | sed 's/#/##/g')
253 setsid -f tmux display-popup -E -w 90% -h 90% -T " $title " -e "F=$f" \\
254 'nvim -R -n -c "nnoremap <buffer> <silent> <Esc> <Cmd>qa!<CR>" -c "nnoremap <buffer> <silent> q <Cmd>qa!<CR>" -- "$F"' \\
255 >/dev/null 2>&1
256elif [ -x "$win" ] && grep -qi microsoft /proc/version 2>/dev/null; then
257 setsid -f "$win" open "$f" >/dev/null 2>&1
258else
259 command -v xdg-open >/dev/null || { echo "xdg-open is not installed" >&2; exit 127; }
260 setsid -f xdg-open "$f" >/dev/null 2>&1
261fi`
262
263// Opens the file: a text file in a tmux popup, anything else in its default app (see OPEN_FILE)
264async function openFile($: EngineInterface, path: string): Promise<void> {
265 const ran = await $.process.run(['sh', '-c', OPEN_FILE, 'sh', path], { timeoutMs: 5_000 })
266 if (ran.exitCode !== 0) $.ui.toast(`Could not open ${path}: ${ran.stderr.trim() || `exit ${ran.exitCode}`}`)
267}
268
269async function mention($: EngineInterface, path: string): Promise<void> {
270 const filled = await $.prompt.fill({ text: `@${path} `, mode: 'insert' })
271 if (!filled.isFilled) $.ui.toast(`Could not add @${path} to the prompt (${filled.refusal ?? 'refused'})`)
272}
273
274export const register: Register = on => {
275 on('session.start', async ($, e, next) => {
276 await $.command.register({
277 name: 'workspace',
278 description: 'Show or hide the workspace pane (context, limits, git status, folder tree)',
279 })
280 $.clock.every(REFRESH_MS, () => void refresh($))
281 $.clock.every(MODEL_CHECK_MS, () => {
282 if (isRequested) {
283 isRequested = false
284 void refresh($)
285 }
286 checkModel($).catch(err => $.ui.log(`workspace: model check failed: ${message(err)}`))
287 })
288 void refresh($)
289 return next(e)
290 })
291
292 on('tool.call', async ($, e, next) => {
293 const ran = await next(e)
294 isRequested = true
295 return ran
296 }).catch(($, e, next) => next(e))
297
298 // The effort the engine really sends (after /effort, /model or a downgrade), main loop only
299 on('turn.step', async function* ($, e, next) {
300 if (e.agentId === undefined && e.effort !== undefined) {
301 const level = String(e.effort)
302 if ((await read($, liveEffort)) !== level) {
303 await update($, liveEffort, () => level)
304 isRequested = true
305 }
306 }
307 return yield* next(e)
308 })
309
310 on('turn.complete', async ($, e, next) => {
311 isRequested = true
312 return next(e)
313 })
314
315 on('command.run', { command: 'workspace' }, async $ => {
316 // A pane opened unasked in a narrow terminal is open but not placed: /workspace then places it.
317 const pane = (await $.ui.panes()).find(one => one.id === PANE)
318 if (pane?.isPlaced === true) {
319 await $.ui.close({ id: PANE })
320 return { text: 'Workspace pane closed.' }
321 }
322 await update($, isDismissed, () => false)
323 return { text: await open($) }
324 })
325
326 // A pane the person closed stays closed for this session (until /workspace).
327 on('ui.close', async ($, e, next) => {
328 if (e.id === PANE && e.origin.kind === 'person') await update($, isDismissed, () => true)
329 return next(e)
330 }).catch(($, e, next) => next(e))
331
332 // Opens the pane unasked only where it is a sidebar: the fullscreen terminal layout.
333 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
334 // Waits for the first snapshot (this read subscribes: its write draws this site again), so the
335 // pane appears once, already filled, instead of empty, then "Reading…", then filled
336 const stored = await read($, snapshot)
337 const isReady = stored !== null && stored.schema === SNAPSHOT_SCHEMA
338 if (!hasAutoOpened && isReady && e.viewport?.isFullscreen === true && !(await read($, isDismissed))) {
339 hasAutoOpened = true
340 open($).catch(err => $.ui.log(`workspace: could not open the pane: ${message(err)}`))
341 }
342 return next(e)
343 })
344
345 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
346 const { Box, Button, Text } = $.ui.resolve(e)
347 const stored = await read($, snapshot)
348 if (stored !== null && stored.schema === SNAPSHOT_SCHEMA) lastDrawn = stored
349 const snap = lastDrawn
350 if (snap === null) return <Text dimColor>Reading…</Text>
351 const git = snap.git
352
353 return (
354 <Box flexDirection="column">
355 {snap.errors.map(err => (
356 <Text color={COLOR.red}>{printable(err)}</Text>
357 ))}
358
359 <Text color={COLOR.mauve} weight="bold">Directory</Text>
360 <Text wrap="truncate-start">{printable(snap.cwdLabel)}</Text>
361
362 <Box marginTop={1} flexDirection="column">
363 <Text color={COLOR.mauve} weight="bold">Model</Text>
364 <Text wrap="truncate-end">
365 {modelLabel(snap.model)}
366 {snap.effort === null ? '' : <Text dimColor> · {snap.effort}</Text>}
367 </Text>
368 </Box>
369
370 <Box marginTop={1} flexDirection="column">
371 <Text color={COLOR.mauve} weight="bold">Context</Text>
372 {snap.contextPercent === null ? (
373 <Text dimColor>no reply yet · {formatTokens(snap.contextWindow)} window</Text>
374 ) : (
375 <Text>
376 <Text color={contextColor(snap.contextPercent)}>{snap.contextPercent}%</Text>
377 <Text dimColor>
378 {' '}
379 {formatTokens(snap.contextTokens ?? 0)} / {formatTokens(snap.contextWindow)}
380 </Text>
381 </Text>
382 )}
383 </Box>
384
385 <Box marginTop={1} flexDirection="column">
386 <Text color={COLOR.mauve} weight="bold">Limits</Text>
387 {snap.limits.map(limit => (
388 <Text wrap="truncate-end">
389 <Text dimColor>{limit.label.padEnd(3)} </Text>
390 {limit.percent === null ? (
391 <Text dimColor>—</Text>
392 ) : (
393 <Text color={contextColor(limit.percent)}>{limit.percent}%</Text>
394 )}
395 {limit.resetsInMs !== null && (
396 <Text dimColor> · resets in {formatDuration(limit.resetsInMs)}</Text>
397 )}
398 </Text>
399 ))}
400 </Box>
401
402 <Box marginTop={1} flexDirection="column">
403 <Text color={COLOR.mauve} weight="bold">Git</Text>
404 {git === null ? (
405 <Text dimColor>not a git repository</Text>
406 ) : (
407 <Text wrap="truncate-end">
408 <Text color={COLOR.blue}>{printable(git.branch)}</Text>
409 {git.ahead > 0 ? ` ↑${git.ahead}` : ''}
410 {git.behind > 0 ? ` ↓${git.behind}` : ''}
411 {git.total === 0 ? (
412 <Text color={COLOR.green}> clean</Text>
413 ) : (
414 <Text dimColor> {git.total} changed</Text>
415 )}
416 </Text>
417 )}
418 {git?.files.map(file => {
419 const target = file.mention
420 const row = (
421 <Text wrap="truncate-middle">
422 <Text color={gitCodeColor(file.code)}>{printable(file.code)}</Text> {printable(file.path)}
423 </Text>
424 )
425 return target === null ? (
426 row
427 ) : (
428 <Button key={`g:${file.path}`} plain onPress={() => mention($, target)}>
429 {row}
430 </Button>
431 )
432 })}
433 {git !== null && git.total > git.files.length && (
434 <Text dimColor>… {git.total - git.files.length} more</Text>
435 )}
436 </Box>
437
438 <Box marginTop={1} flexDirection="column">
439 <Text color={COLOR.mauve} weight="bold">Files</Text>
440 {snap.tree.map(row => {
441 if (row.kind === 'note') {
442 return (
443 <Text dimColor wrap="truncate-end">
444 {indent(row.depth)}
445 {printable(row.name)}
446 </Text>
447 )
448 }
449 if (row.kind === 'dir') {
450 return (
451 <Button key={`d:${row.path}`} plain onPress={() => toggleFolder($, row.path)}>
452 <Text color={COLOR.blue} wrap="truncate-end">
453 {indent(row.depth)}
454 {row.isOpen ? '▾ ' : '▸ '}
455 {printable(row.name)}/
456 </Text>
457 </Button>
458 )
459 }
460 return (
461 <Button key={`f:${row.path}`} plain onPress={() => openFile($, `${snap.root}/${row.path}`)}>
462 <Text color={COLOR.text} wrap="truncate-end">
463 {indent(row.depth)}
464 {' '}
465 {printable(row.name)}
466 </Text>
467 </Button>
468 )
469 })}
470 </Box>
471 </Box>
472 )
473 })
474}
475hooks/lib.ts 213 lines1// Pure helpers of the workspace pane (no `$`), so tests can run them directly.
2import type { GitFile, UsageLimit, WorkspaceGit } from '../types'
3
4// Catppuccin Macchiato, to match Ghostty, tmux and the prompt
5export const COLOR = {
6 text: '#cad3f5',
7 dim: '#8087a2',
8 blue: '#8aadf4',
9 green: '#a6da95',
10 yellow: '#eed49f',
11 red: '#ed8796',
12 mauve: '#c6a0f6',
13} as const
14
15export const MAX_GIT_FILES = 12
16
17// Bump when WorkspaceSnapshot changes: $.state survives a module reload, so the pane may meet a
18// snapshot of the old shape until the first refresh of the new module.
19export const SNAPSHOT_SCHEMA = 3
20
21const HEADER = /^## (?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.\S+)?(?: \[([^\]]*)\])?$/
22
23/**
24 * Parses `git status --porcelain=v1 --branch -z` (NUL-separated, paths unquoted, relative to the
25 * repository root). `top` and `prefix` are `git rev-parse --show-toplevel --show-prefix`: they turn
26 * each path into what an @-mention from the session's directory needs.
27 */
28export function parseGitStatus(stdout: string, top: string, prefix: string): WorkspaceGit {
29 const fields = stdout.split('\0')
30 const head = HEADER.exec(fields[0] ?? '')
31 if (head === null) {
32 throw new Error(`unexpected git status header: ${JSON.stringify(fields[0] ?? '')}`)
33 }
34 const tracking = head[2] ?? ''
35 const entries: { code: string; path: string }[] = []
36 for (let i = 1; i < fields.length; i++) {
37 const field = fields[i] ?? ''
38 if (field === '') continue
39 const code = field.slice(0, 2)
40 entries.push({ code, path: field.slice(3) })
41 // a rename or copy is followed by its original path as a field of its own
42 if (/[RC]/.test(code)) i++
43 }
44 const files: GitFile[] = entries.slice(0, MAX_GIT_FILES).map(({ code, path }) => ({
45 code,
46 path,
47 mention: code.includes('D') ? null : gitMention(path, top, prefix),
48 }))
49
50 return {
51 branch: head[1] ?? '?',
52 ahead: Number(/ahead (\d+)/.exec(tracking)?.[1] ?? 0),
53 behind: Number(/behind (\d+)/.exec(tracking)?.[1] ?? 0),
54 files,
55 total: entries.length,
56 }
57}
58
59/** A repository-root path as seen from the session's directory (`prefix`, e.g. `sub/dir/`). */
60export function gitMention(path: string, top: string, prefix: string): string {
61 if (prefix === '') return path
62 return path.startsWith(prefix) ? path.slice(prefix.length) : `${top}/${path}`
63}
64
65/** One rate-limit window as a session's last API response reported it ($.session.usage().rateLimits). */
66export type LimitReading = { kind: string; percentUsed: number; resetsAt?: string }
67
68/** The newest reading any session saw, kept in a small file all sessions share. */
69export type SharedLimits = { at: number; limits: LimitReading[] }
70
71/** Parses the shared file; null when it holds no valid reading. */
72export function parseShared(text: string): SharedLimits | null {
73 const data: unknown = JSON.parse(text)
74 if (typeof data !== 'object' || data === null) return null
75 const { at, limits } = data as Record<string, unknown>
76 if (typeof at !== 'number' || !Array.isArray(limits)) return null
77 const valid = limits.filter(
78 (one): one is LimitReading =>
79 typeof one === 'object' && one !== null &&
80 typeof (one as LimitReading).kind === 'string' && typeof (one as LimitReading).percentUsed === 'number',
81 )
82 return { at, limits: valid }
83}
84
85/**
86 * A session knows the limits only from its own last API response, so an idle session's figures go
87 * stale. `ownAt` is when this session's reading last changed (0 = unknown, e.g. after a reload);
88 * the shared reading wins when it is newer, or when this session has none.
89 */
90export function pickLimits(own: LimitReading[], ownAt: number, shared: SharedLimits | null): LimitReading[] {
91 if (shared === null) return own
92 return own.length === 0 || shared.at > ownAt ? shared.limits : own
93}
94
95const ALWAYS_SHOWN = ['five_hour', 'seven_day']
96
97/**
98 * The rows the pane draws: 5h and 7d always (a window the response leaves out, or whose reset
99 * time has passed, has been reset: 0%), then any other window. With no reading at all, `null`.
100 */
101export function limitRows(readings: LimitReading[], now: number): UsageLimit[] {
102 const kinds = [...ALWAYS_SHOWN, ...readings.map(one => one.kind).filter(kind => !ALWAYS_SHOWN.includes(kind))]
103 return kinds.map(kind => {
104 const reading = readings.find(one => one.kind === kind)
105 if (reading === undefined) {
106 return { label: limitLabel(kind), percent: readings.length === 0 ? null : 0, resetsInMs: null }
107 }
108 // whole minutes: the pane shows minutes, and a figure that changes on every refresh would redraw it
109 const resetsInMs =
110 reading.resetsAt === undefined ? null : Math.ceil((Date.parse(reading.resetsAt) - now) / 60_000) * 60_000
111 if (resetsInMs !== null && resetsInMs <= 0) return { label: limitLabel(kind), percent: 0, resetsInMs: null }
112 return { label: limitLabel(kind), percent: reading.percentUsed, resetsInMs }
113 })
114}
115
116/** `five_hour` -> `5h`, `seven_day` -> `7d` */
117export function limitLabel(kind: string): string {
118 if (kind === 'five_hour') return '5h'
119 if (kind === 'seven_day') return '7d'
120 if (kind === 'spend_limit') return 'spend'
121 return kind
122}
123
124/** 8_100_000 -> "2h 15m", 277_200_000 -> "3d 5h" */
125export function formatDuration(ms: number): string {
126 const minutes = Math.max(0, Math.round(ms / 60_000))
127 if (minutes < 1) return '<1m'
128 if (minutes < 60) return `${minutes}m`
129 const hours = Math.floor(minutes / 60)
130 if (hours < 24) return `${hours}h ${minutes % 60}m`
131 return `${Math.floor(hours / 24)}d ${hours % 24}h`
132}
133
134/** The effort settings.json sets for `model`: modelSettings[model].effortLevel, else effortLevel. */
135export function settingsEffort(settings: Readonly<Record<string, unknown>>, model: string): string | null {
136 const perModel = settings['modelSettings']
137 if (typeof perModel === 'object' && perModel !== null) {
138 const entry = (perModel as Record<string, unknown>)[model]
139 if (typeof entry === 'object' && entry !== null) {
140 const level = (entry as Record<string, unknown>)['effortLevel']
141 if (typeof level === 'string') return level
142 }
143 }
144 const level = settings['effortLevel']
145 return typeof level === 'string' ? level : null
146}
147
148/**
149 * The model's name as Claude Code's banner and /model show it: claude-opus-5-5 -> "Opus 5.5",
150 * claude-fable-5-1[1m] -> "Fable 5.1 (1M)", claude-3-5-sonnet-20241022 -> "Sonnet 3.5". An id of
151 * another shape is shown as it is.
152 */
153export function modelLabel(id: string): string {
154 const context = /\[(\w+)\]$/.exec(id)?.[1]
155 const base = id.replace(/\[\w+\]$/, '').replace(/-\d{8}$/, '')
156 const suffix = context === undefined ? '' : ` (${context.toUpperCase()})`
157 const name = (family: string, major: string, minor?: string): string =>
158 `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor === undefined ? '' : `.${minor}`}${suffix}`
159 const current = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?$/.exec(base)
160 if (current) return name(current[1] ?? '', current[2] ?? '', current[3])
161 const older = /^claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)$/.exec(base)
162 if (older) return name(older[3] ?? '', older[1] ?? '', older[2])
163 return id
164}
165
166/** 84312 -> "84k", 1000000 -> "1M" */
167export function formatTokens(tokens: number): string {
168 if (tokens >= 1_000_000) return `${+(tokens / 1_000_000).toFixed(1)}M`
169 if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
170 return String(tokens)
171}
172
173export function contextColor(percent: number): string {
174 if (percent >= 80) return COLOR.red
175 if (percent >= 60) return COLOR.yellow
176 return COLOR.green
177}
178
179export function gitCodeColor(code: string): string {
180 if (code === '??') return COLOR.dim
181 if (code.includes('U') || code === 'AA' || code === 'DD') return COLOR.red
182 if (code.includes('D')) return COLOR.red
183 if (code.includes('A')) return COLOR.green
184 if (code.includes('R') || code.includes('C')) return COLOR.blue
185 return COLOR.yellow
186}
187
188/**
189 * Text from the disk or from git (file names, paths, the branch, error messages) as it can be drawn:
190 * each control character becomes U+FFFD. The engine refuses a whole tree whose text holds one (a
191 * file named with an escape sequence would make the pane disappear), so only what is drawn goes
192 * through here; clicks keep acting on the real path.
193 */
194export function printable(text: string): string {
195 return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, '�')
196}
197
198/** $HOME/x -> ~/x */
199export function tildePath(path: string, home: string | undefined): string {
200 if (home === undefined || home === '' || home === '/') return path
201 if (path === home) return '~'
202 return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
203}
204
205/** Directories first, then files, each alphabetical (case-insensitive). */
206export function sortEntries<T extends { name: string; kind: string }>(entries: readonly T[]): T[] {
207 return [...entries].sort((a, b) => {
208 const aDir = a.kind === 'dir' ? 0 : 1
209 const bDir = b.kind === 'dir' ? 0 : 1
210 return aDir - bDir || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
211 })
212}
213types/index.d.ts 68 lines1export type GitFile = {
2 /** Porcelain status code, e.g. ` M`, `??`, `R ` */
3 code: string
4 /** Path relative to the repository root, as git prints it */
5 path: string
6 /** What a click puts after `@`: the path relative to the session's directory (absolute when
7 * outside it); null for a deleted file */
8 mention: string | null
9}
10
11export type WorkspaceGit = {
12 branch: string
13 ahead: number
14 behind: number
15 files: GitFile[]
16 total: number
17}
18
19export type TreeRow = {
20 depth: number
21 name: string
22 /** Relative to the session's directory; '' for a note */
23 path: string
24 /** `note` is a "… N more" or error line, not an entry */
25 kind: 'dir' | 'file' | 'note'
26 isOpen: boolean
27}
28
29export type UsageLimit = {
30 /** `5h`, `7d`, `spend`, or the engine's own name */
31 label: string
32 /** null when no session has a reading yet */
33 percent: number | null
34 /** Milliseconds from the snapshot until the window resets; null when unknown */
35 resetsInMs: number | null
36}
37
38export type WorkspaceSnapshot = {
39 /** Shape version: a snapshot kept in $.state from an older module version is not drawn */
40 schema: number
41 /** The session's directory, absolute: the tree's paths are relative to it */
42 root: string
43 /** The session's directory, $HOME shown as ~ */
44 cwdLabel: string
45 model: string
46 /** Reasoning effort of the main loop: the last request's, or the settings' before the first */
47 effort: string | null
48 contextPercent: number | null
49 contextTokens: number | null
50 contextWindow: number
51 limits: UsageLimit[]
52 git: WorkspaceGit | null
53 tree: TreeRow[]
54 errors: string[]
55}
56
57declare module 'claude-code' {
58 interface PluginState {
59 workspace: {
60 snapshot: WorkspaceSnapshot | null
61 isDismissed: boolean
62 effort: string | null
63 /** Folders the person opened in the tree, relative to the session's directory */
64 expanded: string[]
65 }
66 }
67}
68