SLOPSHOPPER

workspace

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

panebandguardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-23bilalakbana/dot-files-wsl/.claude/mods/workspace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workspace
│ ┃ Workspace ✕ › fix the failing auth test and add an audit log call │ ┃ git: unexpected git status header: " M │ ┃ src/auth.ts\n?? src/auth.test.ts\n" ⏺ Read(src/auth.ts) │ ┃ Directory ⎿ Read 6 lines │ ┃ /work/app ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Model ⏺ Bash(bun test) │ ┃ Opus 5.5 ⎿ 3 pass, 1 fail │ ┃ │ ┃ Context ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 49% 97k / 200k │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Limits │ ┃ 5h 31% · resets in NaNd NaNh › /workspace │ ┃ 7d 0% ⎿ workspace: Workspace pane opened. │ ┃ │ ┃ Git │ ┃ not a git repository │ ┃ │ ┃ Files │ ┃ f:package.json │ ┃ f:README.md │ ┃ f:src │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workspace
git: unexpected git status header: " M src/auth.ts\n?? src/auth.test.ts\n" Directory /work/app Model Opus 5.5 Context 49% 97k / 200k Limits 5h 31% · resets in NaNd NaNh 7d 0% Git not a git repository Files f:package.json f:README.md f:src
README

dot-files-wsl

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.


For Claude Code: how to work through this

  • Do the steps in order and run each step's Check before going on. If a check fails, stop and report the command, its output and what you think is wrong. Do not work around a failure silently.
  • Never delete anything. When a file or folder that would be replaced already exists, rename it to <name>.bak-<date>-<time> (the commands below do this) and tell the user.
  • sudo needs the user's password, which you cannot type. Every 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.
  • If 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.
  • Versions below were current on 2026-10-16. Where a step says latest release, look the version up (curl -s https://api.github.com/repos/OWNER/REPO/releases/latest | grep tag_name) and use it.
  • At the end, give the user a short report: what is installed (with versions), what was skipped, what was backed up, and anything they still have to do by hand.

1. Packages from Ubuntu (user runs)

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.

2. fd and bat under their usual names

Ubuntu 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.

3. fzf, latest release, into ~/.local/bin

Ubuntu'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.

4. ugrep, built from source

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.

5. Rust, ripgrep and ripgrep-all

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.

6. git-split-diffs (user runs)

sudo npm install -g git-split-diffs

Check: command -v git-split-diffs prints a path.

7. Fonts

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.

8. The dotfiles

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.

9. search-functions (WSL edition) into ~/.SearchFunctions

The 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).

10. zsh as the login shell, first start (user runs)

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).

11. Neovim + LazyVim

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).

12. Claude Code: theme, workspace pane, CLAUDE.md, plugins, skills

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).

13. WSL notes

  • systemd: ~/.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.
  • Clipboard / opening files: the search functions use ~/.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]).
  • Ghostty window: 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.
Source 3 files
hooks/register.tsx 475 lines
1// 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}
475
hooks/lib.ts 213 lines
1// 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}
213
types/index.d.ts 68 lines
1export 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