[Sync] replace the flat layout with the unified stow tree

Supersedes the old flat .config/ layout (last published 2026-06-28) with the
private repo's structure: one shared base plus per-host overlays.

- packages: common/ gui/ lw/ fl/ wm/ plus install.sh and bin/ tooling
  (dotsync, reconcile-hyde.sh)
- new README covering the layout, deploy order and the HyDE dependency
- current HyDE waybar rig (layouts/, cava), pi agent extensions, claude/
  config, tmux, presenterm, aichat roles
- fish: kp (keepassxc-cli + fzf picker, db path from $KP_DB) and
  bind_M_n_history (alt+1..9 recalls the nth history entry)
- drops cruft that should never have been tracked: the duplicate top-level
  .pi/ copy, btop.log, zellij config.kdl.bak, fish_variables
- .pi/agent/auth.json is gitignored; auth.json.example ships instead

Host-specific work sessions and the personal backlog stay in the private
tree. Endpoint locators in the llamacpp/whisper guides are placeholders
($SERVER, <own-domain>) — the guides themselves stay, since they are the
useful part.
This commit is contained in:
coja
2026-08-13 02:53:06 +02:00
parent f62cb40499
commit 1e39e0cb4b
314 changed files with 12306 additions and 10772 deletions
+191
View File
@@ -0,0 +1,191 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
This is **not application source** — it's the on-disk configuration tree for `pi`, the TUI coding agent from [earendil-works/pi](https://github.com/earendil-works/pi) (binary at `/usr/bin/pi`). It lives inside the user's dots repo (`~/.dots`) and is surfaced into `$HOME` via GNU Stow's per-file symlinks (`--no-folding`), e.g. `~/.pi/agent/extensions/notify.ts -> ../../../.dots/common/.pi/agent/extensions/notify.ts`. **Editing** an existing file edits the live config directly — no deploy step. But **adding a new file** (e.g. a new extension) is not surfaced until you re-stow: run `./install.sh` (or `stow --no-folding -R -d ~/.dots -t ~ common`) to create its symlink, on each machine.
Because it's JSON + markdown config, there are no tests/lint/build. The only useful "validation" is JSON well-formedness, e.g. `jq . settings.json` or `python -m json.tool < settings.json`.
## Config directory — single source of truth
**All live config is `.pi/agent/`** (surfaced as `~/.pi/agent`). The pi coding agent reads `$PI_CODING_AGENT_DIR`, which defaults to `~/.pi/agent` and is not overridden here; per pi's docs, settings/auth/trust/sessions/extensions/AGENTS.md all resolve under that one directory. Nothing reads `~/.pi/*` at the top level or `~/.config/pi`.
> History: there used to be three drifted copies of this config (`.pi/*` top-level, `.pi/agent/`, and `~/.config/pi/`). The two unread mirrors were deleted and the dangling `~/.config/pi` stow symlink removed, leaving `.pi/agent/` as the only copy. Don't reintroduce mirrors — edit `.pi/agent/` directly.
## Layout of `.pi/agent/` (the live config)
- `settings.json` — runtime settings: `defaultProvider`/`defaultModel`/`defaultThinkingLevel`, `theme`, `enabledModels` (glob allowlist for the Ctrl+P model picker), `compaction` (auto-summarize long sessions), `retry`, HTTP timeouts, `npmCommand` (pinned to `fnm exec --using=22 -- npm`), `packages` (installed extension packages, currently `npm:pi-vim`) and their config blocks (`piVim` — see Vim input editor).
- `models.json` — the provider + model catalog (see below).
- `auth.json` — maps each provider to its credential. Values are **references, not secrets** (`$DUSKADIY_API_KEY`, `no-key-required`); pi resolves `$VAR` from the environment. **Gitignored** — the tracked copy is `auth.json.example`; a new host needs `cp auth.json.example auth.json` before pi can resolve a provider (stow links whatever is present, tracked or not, so the live file keeps working here).
- `prompts/*.md` — custom slash commands (pi "prompt templates"). `/commit` and `/review` are defined here. Format: YAML frontmatter (`description`, `argument-hint`) + body, with `${1:-default}` positional-arg substitution.
- `themes/*.json` — color themes following the schema at `earendil-works/pi .../theme/theme-schema.json`: a `vars` palette referenced by semantic `colors` keys, plus an `export` block for HTML session export.
- `sessions/` — runtime session transcripts (`.jsonl`), **gitignored**. One subdir per project cwd; each line is an event (`session`, `model_change`, `message`, …).
- `extensions/*/index.ts` — auto-discovered TypeScript extensions, loaded via [jiti](https://github.com/unjs/jiti) (no build step). `import type` from `@earendil-works/*` is erased at runtime; value imports resolve against pi's own bundled packages, so a vendored extension needs no `node_modules`. Editor TS "cannot find module" warnings on these imports are therefore expected noise.
- `npm/` — extension **packages** installed by `pi install` (currently `pi-vim`), **gitignored**; `settings.json > packages` is the tracked source of truth. On a fresh host run `pi install npm:pi-vim` once — plus pi-vim's manual peer-dep install (see Vim input editor), which `pi install` does not do — then `pi update npm:pi-vim` to bump. A package needs no re-stow — unlike a new file under `extensions/`.
- `keybindings.json` — key remaps. A user entry **replaces** the default keys for that action (it does not merge). Action ids and defaults are listed in `/opt/pi-coding-agent/docs/keybindings.md`.
## Plan mode (Shift+Tab)
`extensions/plan-mode/` is pi's bundled plan-mode example (pi has no built-in plan mode), **vendored here and rebound from its upstream `Ctrl+Alt+P` to Shift+Tab**. In plan mode it disables `edit`/`write` and restricts `bash` to a read-only allowlist (footer shows `⏸ plan`); `/plan` also toggles it, `--plan` starts in it. The rebind is two coupled edits — keep them together:
- `extensions/plan-mode/index.ts` (~line 157): `pi.registerShortcut("shift+tab", …)`, and the upstream `import { Key } … }` is removed. This file is a **local fork** of `/opt/pi-coding-agent/examples/extensions/plan-mode/`; on a pi upgrade, re-pull from there and re-apply these two edits.
- `keybindings.json` moves `app.thinking.cycle` off Shift+Tab to `ctrl+shift+t`, so the toggle fires deterministically (otherwise it collides with the built-in thinking-cycle binding).
After editing an extension or `keybindings.json`, run `/reload` in pi to apply without restarting.
## Status bar (custom footer)
`extensions/statusbar.ts` replaces pi's default footer via `ctx.ui.setFooter()`. It
mirrors the Claude Code status line (`~/.config/claude/statusline.py`) but swaps the
emoji for **Nerd Font (Material Design) glyphs** — the same family already used in
tmux (waybar cpu/mem/net glyphs) and nvim — so it renders natively in kitty
(CaskaydiaCove Nerd Font Mono). Colors come from the active theme, not raw ANSI.
Icon legend (each glyph echoes the Claude emoji it stands in for):
| Glyph | Codepoint | Segment | ~ Claude |
|---|---|---|---|
| `󰉋` | `U+F024B` nf-md-folder | cwd (`~`-collapsed) | 📁 |
| `󰘬` | `U+F062C` nf-md-source_branch | git branch | 🌿 |
| `󰚩` | `U+F06A9` nf-md-robot | model id + `· thinking` | 🤖 |
| `󰈚` | `U+F021A` nf-md-text_box | context-window % used (+ tokens) | 📝 |
| `󰓅` | `U+F04C5` nf-md-speedometer | last response's decode throughput (t/s) | — |
| `󰀪` | `U+F002A` nf-md-alert_outline | context-budget warning widget (above editor, ≥80%) | — |
| `↑ ↓` | — | session input / output tokens | 💰 |
Two extras beyond the footer line: a themed "breathing" pulse **working-indicator**
(the streaming spinner), and a **context-budget warning widget** above the editor
that appears once the window is ≥80% full (`warning`, then `error` ≥90%) nudging a
`/compact`. Both are reset when the footer is toggled off.
Segments render left→right and truncate at the terminal edge. git only shows inside
a git repo; context, t/s and tokens only appear after the first response (t/s is
`usage.output ÷ (message_end first streamed token)`, so prompt-eval time is
excluded). Extension statuses (e.g. plan-mode's `⏸ plan`) are preserved at the far
left. The Claude bar's system row (RAM/CPU/temp/disk) is intentionally omitted —
that data isn't in the footer API, and the tmux bar below pi already shows
cpu/mem/net. `/statusbar` toggles it off (restores the built-in footer) and back on;
`/reload` picks up edits to this file (a *new* extension needs a re-stow first — see
top).
## Vim input editor
The modal (vim-like) input editor is the npm package
**[`pi-vim`](https://github.com/lajarre/pi-vim)** (pinned by `settings.json > packages`,
installed into the gitignored `.pi/agent/npm/`, configured under `settings.json > piVim`;
v0.14.1 at adoption). It replaced the hand-rolled local fork on 2026-08-05; the fork was parked
as `extensions/vim-editor.ts.disabled` and **deleted 2026-08-09** once the package had proven
itself in daily use. `git show c6566af^:common/.pi/agent/extensions/vim-editor.ts` brings it back
if it is ever wanted.
**That swap is the whole point: no more re-pull-and-re-apply on every pi upgrade** — use
`pi update npm:pi-vim`, and `pi remove npm:pi-vim` to back out.
⚠️ **This pi build needs pi-vim's peer dep installed by hand.** `pi` here is the AUR
`pi-coding-agent`: a Bun-compiled ELF at `/opt/pi-coding-agent/pi` with the
`@earendil-works/*` packages *embedded* as virtual modules, not present on disk
(`/opt/pi-coding-agent/node_modules/` holds only `@mariozechner`). pi-vim's
`clipboard-mirror.ts` calls `import.meta.resolve("@earendil-works/pi-coding-agent")` at
**module top level** — it bakes that URL into the source of a spawned clipboard helper —
so after a bare `pi install npm:pi-vim` the resolve throws and the whole extension fails
to load: `Cannot find module '@earendil-works/pi-coding-agent'`. No setting dodges it;
`clipboardMirror: "never"` can't, because the call runs at import time. Fix, once per host:
```fish
cd ~/.pi/agent/npm
fnm exec --using=22 -- npm install --save-exact @earendil-works/pi-coding-agent@0.83.0 # match `pi --version`
```
pi's own `pi install` deliberately does *not* pull peers (it would duplicate the runtime),
so this is manual, costs ~170 MB inside the gitignored `npm/`, and wants re-pinning after a
pi upgrade (hence `--save-exact`: npm's default `^` range would let a later `npm install`
drift the peer off `pi --version` on its own). The static
`CustomEditor`/`SettingsManager`/`matchesKey` imports still bind to pi's embedded copy —
verified in the TUI — so the on-disk copy stays inert apart from the spawned helper. Upstream has no issue filed for this (checked 2026-08-05); the proper fix is
making that resolve lazy. `@burneikis/pi-vim` and `pi-vimmode` use no `import.meta.resolve`
and need no peer install, if this ever gets annoying.
Five modes — INSERT, NORMAL, VISUAL, V-LINE, EX. The active one renders as a word label
(` NORMAL `) at the **bottom-right** of the editor border, doubling as a pending-command
display: `3d2w`, `ci"`, `25gg` appear as you type them (` NORMAL 3d2w_ `). That's a
**position change from the fork**, which put a single `[N]` tag bottom-*left*.
- **motions** — `hjkl` · `w`/`b`/`e` + `W`/`B`/`E` · `0`/`^`/`$` · `{`/`}` paragraph ·
`gg`/`G` with counts (`25gg`) · `f`/`F`/`t`/`T` + `;` repeat · `%` matching pair
- **operators** — `d`/`c`/`y` + any motion · `dd`/`cc`/`yy`/`Y` · `x`/`X`/`s`/`S`/`C`/`D` ·
`o`/`O` open line · `r` replace char · `J`/`gJ` join · `p`/`P` put
- **text objects** — `iw`/`aw` · `iW`/`aW` · `i"`/`a"` · `i(`/`a(` · `i[`/`a[` · `i{`/`a{`
- **undo/repeat** — `u` and `Ctrl+R`, scoped to vim changes · `.` repeats the last edit
- **EX** — `:` dispatches real pi commands (`:model`, `:tree`) and shells out via
`:!git status`. pi exposes no command-dispatch API, so it re-submits the line as if
typed, snapshotting and restoring the prompt around it.
- cursor shape follows the mode (bar in INSERT, block elsewhere) via DECSCUSR.
Upstream implements no search (`/`, `?`, `n`, `N`), no macros (`q`/`@`) and no visual-block.
The fork had none of those either, so nothing regressed; `pi-vimmode` has them but is
rougher (5★/18 open issues vs 75★/2 at the time of choosing).
### Config (`settings.json > piVim`)
- `modeColors` — theme tokens, **ported from the fork**: `insert: success` (green),
`normal: accent` (mauve), `visual: warning`. ⚠️ the fork's header comment claimed visual
was "peach", but `warning` resolves to **yellow** `#f9e2af` in catppuccin-mocha — the port
keeps what actually rendered and hands the *peach* (`bashMode`) to the new EX mode.
- `borderSync` all `host`, `labelSync` all `mode` — also the upstream defaults, but pinned
explicitly so an upstream default change can't start repainting the input border. Matches
the fork: only the label is tinted, the border is left to the host.
- `clipboardMirror: "yank"` — only an explicit `y` reaches the **OS** clipboard. Upstream's
default `"all"` mirrors deletes too, so every `dd`/`x` would clobber the Wayland clipboard;
`"never"` is exact fork parity (pi's kill-ring only). Writes go through
`@mariozechner/clipboard` in a spawned helper, not a direct `wl-copy`.
- `exCommand.piDispatch: true` — the `:` bridge above. `copyInputToClipboard: false`: it
copies the composed prompt out, an exfiltration path, so upstream only honors it from the
user-global file (never project settings) — leave it off.
- `modeChange` (unset) — would run a shell command on every INSERT/NORMAL transition.
`/vim` still toggles the editor, now from **`extensions/vim-toggle.ts`** (pi-vim registers no
commands of its own): off drops the editor component for the rest of the session, on calls
`ctx.reload()` — the same flow as `/reload` — which re-runs discovery and reinstalls it.
No clash with `statusbar.ts`: pi-vim draws its label inside its own editor component, not
through `setFooter()`.
## Compaction tuning (small local windows)
`settings.json > compaction` is set for the small local context windows (24k128k;
several presets are only 24k). `reserveTokens: 6144` (headroom for the
response; auto-compaction fires at `contextTokens > contextWindow reserveTokens`)
and `keepRecentTokens: 6000` (kept verbatim, not summarized) — both well below pi's
16384/20000 defaults so short windows aren't dominated by the reserve or thrash into
repeated compaction. These are **global** (pi has no per-model compaction): on the
128k model you could raise `keepRecentTokens` for richer retained context; if you
see responses truncate near a full window, raise `reserveTokens` toward the models'
`maxTokens` (8192). All model `contextWindow`s are verified to match the server
`ctx-size` in `fl/.config/llamacpp/config.ini`.
## Providers and models
Two OpenAI-compatible providers are configured, both serving the same catalog of small local/self-hosted models (Qwen3-Coder-30B, Gemma 4, GLM-4.7-Flash, etc.). All have `cost: 0`:
- **`duskadiy`** — remote, `https://llm.duskadiy.com/api/v1`, key `$DUSKADIY_API_KEY`.
- **`localcpp`** — LAN llama.cpp server at `http://192.168.0.204:11343/v1`, no key.
⚠️ **`duskadiy` is parked out of the model picker** (2026-08-05): `settings.json > enabledModels` lists only `localcpp/*`. pi resolves `auth.json`'s `$DUSKADIY_API_KEY` from the environment, and this host has no `~/.config/fish/conf.d/secrets.fish`, so the credential resolves empty, pi drops the whole provider, and the `duskadiy/*` glob then matched nothing — printing `Warning: No models match pattern "duskadiy/*"` on every single start. Its box is down independently of the missing key: `llm.duskadiy.com` still resolves (`24.135.113.16`) but TCP 443 times out, as does the apex. Provider, catalog and auth entry are all left **intact** — re-add `"duskadiy/*"` to `enabledModels` once the key is back on the host and the box answers.
`defaultProvider` stays `duskadiy` on purpose. With the provider dropped pi falls back to the same model id under `localcpp` (the two catalogs are mirrors), so startup resolves fine today — and when duskadiy returns it is the provider that works **off**-LAN, unlike `localcpp`'s `192.168.0.204`. Don't "simplify" it to `localcpp` without weighing that.
When adding/editing a model, keep `id` exactly matching the server's model id (the section name in `fl/.config/llamacpp/config.ini`), and set `contextWindow` to match the `ctx-size` the model is actually loaded with server-side.
## Secrets
`$DUSKADIY_API_KEY` is the only real secret. It is defined in `.config/fish/conf.d/secrets.fish` (gitignored) and referenced — never inlined — in tracked config. Keep it that way: tracked files (`auth.json`, `models.json`) must contain `$DUSKADIY_API_KEY`, not the literal token.
`auth.json` is **no longer tracked** (2026-08-08, ahead of making the repo public): pi's interactive `/login` rewrites it with the **literal** key, so a tracked copy was one stray `/login` away from committing a real token. `auth.json.example` carries the same `$VAR` references. Every historical version of the old tracked file was audited and only ever contained references — nothing leaked. Installed-package and runtime artifacts (`.pi/agent/npm/`, `git/`, `trust.json`, `sessions/`) are gitignored.
## Running pi
- `pi` — interactive TUI. `pi -p "<prompt>"` — non-interactive, print and exit.
- `pi -c` / `pi -r` — continue / pick a session to resume.
- `pi config` — TUI to enable/disable discovered resources. `pi --list-models [search]` — list available models.
- `--provider` / `--model` / `--thinking` override the `settings.json` defaults per-run.
- pi also auto-discovers `CLAUDE.md`/`AGENTS.md` as context files (disable with `-nc`) — i.e. pi reads this very file too, not just Claude Code.
+160
View File
@@ -0,0 +1,160 @@
# pi config (`~/.pi`)
On-disk configuration for **[pi](https://github.com/earendil-works/pi)**, a TUI coding
agent (binary `/usr/bin/pi`). This tree lives in the dots repo and is surfaced into
`$HOME` by GNU Stow, so `~/.pi/agent/...` symlinks back here — **editing a file here
edits the live config**, no deploy step.
## Install / how it's surfaced
pi reads everything under `$PI_CODING_AGENT_DIR` (default `~/.pi/agent`). Stow
symlinks each file individually (`--no-folding`):
```
~/.pi/agent/settings.json -> ~/.dots/common/.pi/agent/settings.json
~/.pi/agent/extensions/statusbar.ts -> ~/.dots/common/.pi/agent/extensions/statusbar.ts
...
```
- **Editing** an existing tracked file = editing live config immediately.
- **Adding** a new file (e.g. a new extension) is *not* live until you re-stow —
run `./install.sh` (or `stow --no-folding -R -d ~/.dots -t ~ common`) once to
create the symlink.
- After editing an extension, prompt, theme, or `keybindings.json`, run `/reload`
inside pi to apply without restarting.
## What's in here
| Path | Purpose |
|---|---|
| `agent/settings.json` | runtime settings (default model, theme, compaction, retry, timeouts) |
| `agent/models.json` | provider + model catalog (two OpenAI-compatible providers) |
| `agent/auth.json` | provider → credential map (references like `$DUSKADIY_API_KEY`, never literals). **Untracked** — copy `auth.json.example` on a new host |
| `agent/keybindings.json` | key remaps (each entry **replaces** the default for that action) |
| `agent/AGENTS.md` | global system instructions injected into every session |
| `agent/prompts/*.md` | custom `/slash` commands (prompt templates) |
| `agent/themes/*.json` | custom color themes |
| `agent/extensions/**` | auto-discovered TypeScript extensions (loaded via jiti, no build) |
Runtime artifacts (`sessions/`, `npm/`, `git/`, `trust.json`) are gitignored.
---
## Addons vs. stock pi
Stock pi ships with **no extensions enabled, two built-in themes (`dark`/`light`),
default keybindings, and default settings**. Everything below is a deviation added
here. Each is independent — pull the ones you want and delete the rest.
### Extensions
pi **auto-discovers** every `.ts` under `extensions/`. So the universal "remove"
step is: **delete the file (or its folder) and `/reload`.** Toggle-able ones also
have a slash command to disable them for the current session without deleting.
| Extension | What it adds | Origin | Remove |
|---|---|---|---|
| `statusbar.ts` | Replaces the footer with a Claude-Code-style status line (cwd, git branch, model, context %, decode t/s, token counts) in Nerd Font glyphs; adds a streaming pulse + a ≥80%-context warning widget | **custom** | delete file → default footer returns; or `/statusbar` to toggle off for the session |
| `vim-editor.ts` | Modal (vim-like) input editor with `[I]/[N]/[V]` indicator, hjkl/word/line motions, counts, yank/paste via the kill-ring | **local fork** of pi's bundled `examples/extensions/modal-editor.ts` | delete file → default editor; or `/vim` to toggle |
| `plan-mode/` | Read-only "plan" mode (disables edit/write, restricts bash) bound to **Shift+Tab**; `/plan` toggles, `--plan` starts in it | **vendored** copy of pi's bundled `examples/extensions/plan-mode/`, rebound from upstream `Ctrl+Alt+P` | delete the folder **and** revert the `keybindings.json` `app.thinking.cycle` remap (see below) |
| `notify.ts` | Native terminal notification (OSC 777/99, Windows toast) when the agent goes idle | **verbatim** from pi's `examples/extensions/notify.ts` | delete file |
| `questionnaire.ts` | Interactive multi-question overlay the model can call as a tool | **near-verbatim** from pi's `examples/extensions/questionnaire.ts` (one-line fix) | delete file |
> The bundled examples live at `/opt/pi-coding-agent/examples/extensions/` — that's
> where `notify`/`questionnaire`/`modal-editor`/`plan-mode` came from, and where to
> re-pull the two forks after a pi upgrade before re-applying the local edits (the
> forks' own headers document those edits).
### Theme
`themes/catppuccin-mocha.json` is a **custom** theme (stock pi only bundles `dark`
and `light`). `settings.json` sets `"theme": "catppuccin-mocha"`.
**Remove:** delete the file and set `"theme": "dark"` (or `"light"`) in
`settings.json`.
### Custom slash commands (prompt templates)
`prompts/commit.md` and `prompts/review.md` add `/commit` (writes a Conventional
Commits message for the staged diff, never commits) and `/review` (reviews the
working diff). Both are **custom**. Format is YAML frontmatter + body with
`${1:-default}` positional-arg substitution.
**Remove:** delete the file(s). Stock pi has no `/commit` or `/review`.
### Keybindings
`keybindings.json` overrides three actions. **A user entry replaces the default keys
for that action — it does not merge.** Action ids and their stock defaults are in
`/opt/pi-coding-agent/docs/keybindings.md`.
```jsonc
{
"app.thinking.cycle": ["ctrl+shift+t"], // moved OFF Shift+Tab so plan-mode can bind it
"tui.editor.cursorUp": ["up", "ctrl+p"], // adds Ctrl+P as up (Emacs-style)
"app.model.cycleForward": ["alt+p"] // Alt+P cycles models
}
```
The `app.thinking.cycle` remap is **coupled to `plan-mode`**: plan-mode binds
Shift+Tab, so the thinking-cycle default (also Shift+Tab) is moved aside to avoid a
collision. If you drop plan-mode, drop that line too.
**Remove:** delete the whole file to restore all stock keybindings, or delete
individual lines to restore just those.
### `settings.json` tunings
Beyond pointing at local models, these values deviate from pi's defaults:
| Key | Value here | Stock default | Why |
|---|---|---|---|
| `compaction.reserveTokens` | `6144` | 16384 | small local windows (24k128k) — don't let the response reserve dominate a short window |
| `compaction.keepRecentTokens` | `6000` | 20000 | keep less verbatim so short windows don't thrash into repeated compaction |
| `npmCommand` | `fnm exec --using=22 -- npm` | `npm` | pin extension npm installs to Node 22 via fnm |
| `retry` / `httpIdleTimeoutMs` | long (1h provider timeout, 10m idle) | shorter | local models can be slow to first token |
| `defaultThinkingLevel` | `off` | — | most local models here are non-reasoning |
**Remove:** delete each key to fall back to pi's default (or drop the whole
`compaction`/`retry` block).
### Providers & models
`models.json` + `auth.json` define **two OpenAI-compatible providers** serving the
same catalog of small self-hosted models (Qwen3-Coder-30B, Gemma 4, GLM-4.7-Flash,
gpt-oss-20b, …), all `cost: 0`:
- **`localcpp`** — LAN llama.cpp server, `http://192.168.0.204:11343/v1`, no key.
- **`duskadiy`** — remote, `https://llm.duskadiy.com/api/v1`, key `$DUSKADIY_API_KEY`.
This is the part you'd **replace**, not just delete, to point pi at your own backend:
edit `models.json` (baseUrl + model `id`s, keeping each `id` exactly matching your
server's model id and `contextWindow` matching its loaded `ctx-size`) and set the
matching credential in `auth.json`. `settings.json > enabledModels` is a glob
allowlist for the Ctrl+P picker; `defaultProvider`/`defaultModel` pick the startup
model.
> **Secret hygiene:** `$DUSKADIY_API_KEY` lives in
> `.config/fish/conf.d/secrets.fish` (gitignored) and is only *referenced* in tracked
> config. `auth.json` is **not tracked** (gitignored as of 2026-08-08): pi's interactive
> `/login` rewrites it with the **literal** key, and a tracked copy would be one stray
> `/login` away from committing a real token. Bootstrap a new host with
> `cp auth.json.example auth.json`. For bash its `.bash_profile` since it's not tracked by
> git, stow not applied.
---
## Replicating just one piece
- **Just the status bar:** copy `agent/extensions/statusbar.ts` into your
`~/.pi/agent/extensions/`, re-stow/restart, `/reload`. Colors follow your active
theme automatically. Needs a Nerd Font terminal for the glyphs.
- **Just vim input:** copy `agent/extensions/vim-editor.ts`, `/reload`, `/vim`.
- **Just plan mode:** copy `agent/extensions/plan-mode/` **and** add the
`app.thinking.cycle` remap to your `keybindings.json` (else Shift+Tab collides).
- **Just the theme:** copy `agent/themes/catppuccin-mocha.json`, set
`"theme": "catppuccin-mocha"`.
- **Just the slash commands:** copy `agent/prompts/commit.md` / `review.md`.
Validate any JSON edit with `jq . agent/settings.json` (there's no build/test step).
+22
View File
@@ -0,0 +1,22 @@
# Global agent instructions
You are a coding assistant running on small, locally-hosted models. Be precise and economical with tokens.
## Working style
- Act with tools instead of describing what you would do. Keep prose short.
- Do the task that was asked. Don't add unrequested changes, refactors, or files.
- When done, stop. A one- or two-line summary is enough; no recaps of obvious steps.
## Files & edits
- Read a file before you edit it. Never guess at file paths, function names, or APIs — verify first.
- Use the edit tool for changes. Make minimal, targeted diffs; never paste an entire file back to the user.
- Match the surrounding code's style, naming, and imports.
## Shell
- Run one command at a time and check its output before the next.
- Prefer `rg` and `fd` for search. Use read-only commands when exploring.
- Never run destructive or system-changing commands (`rm -rf`, `sudo`, package installs, force-push) unless explicitly asked.
## Honesty
- If you're unsure, say so and check rather than inventing an answer.
- Report failures plainly with the actual error; don't claim success you didn't verify.
+10
View File
@@ -0,0 +1,10 @@
{
"localcpp": {
"type": "api_key",
"key": "no-key-required"
},
"duskadiy": {
"type": "api_key",
"key": "$DUSKADIY_API_KEY"
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Pi Notify Extension
*
* Sends a native terminal notification when Pi agent is done and waiting for input.
* Supports multiple terminal protocols:
* - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
* - OSC 99: Kitty
* - Windows toast: Windows Terminal (WSL)
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
function windowsToastScript(title: string, body: string): string {
const type = "Windows.UI.Notifications";
const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
const template = `[${type}.ToastTemplateType]::ToastText01`;
const toast = `[${type}.ToastNotification]::new($xml)`;
return [
`${mgr} > $null`,
`$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
`$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`,
`[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`,
].join("; ");
}
function notifyOSC777(title: string, body: string): void {
process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
}
function notifyOSC99(title: string, body: string): void {
// Kitty OSC 99: i=notification id, d=0 means not done yet, p=body for second part
process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
}
function notifyWindows(title: string, body: string): void {
const { execFile } = require("child_process");
execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)]);
}
function notify(title: string, body: string): void {
if (process.env.WT_SESSION) {
notifyWindows(title, body);
} else if (process.env.KITTY_WINDOW_ID) {
notifyOSC99(title, body);
} else {
notifyOSC777(title, body);
}
}
export default function (pi: ExtensionAPI) {
pi.on("agent_end", async () => {
notify("Pi", "Ready for input");
});
}
@@ -0,0 +1,66 @@
# Plan Mode Extension
Read-only exploration mode for safe code analysis.
## Features
- **Built-in write tools disabled**: Disables edit/write while preserving other active tools
- **Bash allowlist**: Only read-only bash commands are allowed
- **Plan extraction**: Extracts numbered steps from `Plan:` sections
- **Progress tracking**: Widget shows completion status during execution
- **[DONE:n] markers**: Explicit step completion tracking
- **Session persistence**: State survives session resume
## Commands
- `/plan` - Toggle plan mode
- `/todos` - Show current plan progress
- `Shift+Tab` - Toggle plan mode (shortcut; this fork rebinds it from upstream's Ctrl+Alt+P)
## Usage
1. Enable plan mode with `/plan` or `--plan` flag
2. Ask the agent to analyze code and create a plan
3. The agent should output a numbered plan under a `Plan:` header:
```
Plan:
1. First step description
2. Second step description
3. Third step description
```
4. Choose "Execute the plan" when prompted
5. During execution, the agent marks steps complete with `[DONE:n]` tags
6. Progress widget shows completion status
## How It Works
### Plan Mode (Read-Only)
- Built-in edit/write tools disabled
- Other active tools remain available
- Bash commands filtered through allowlist
- Agent creates a plan without making changes
### Execution Mode
- Full tool access restored
- Agent executes steps in order
- `[DONE:n]` markers track completion
- Widget shows progress
### Command Allowlist
Safe commands (allowed):
- File inspection: `cat`, `head`, `tail`, `less`, `more`
- Search: `grep`, `find`, `rg`, `fd`
- Directory: `ls`, `pwd`, `tree`
- Git read: `git status`, `git log`, `git diff`, `git branch`
- Package info: `npm list`, `npm outdated`, `yarn info`
- System info: `uname`, `whoami`, `date`, `uptime`
Blocked commands:
- File modification: `rm`, `mv`, `cp`, `mkdir`, `touch`
- Git write: `git add`, `git commit`, `git push`
- Package install: `npm install`, `yarn add`, `pip install`
- System: `sudo`, `kill`, `reboot`
- Editors: `vim`, `nano`, `code`
@@ -0,0 +1,388 @@
/**
* Plan Mode Extension
*
* Read-only exploration mode for safe code analysis.
* When enabled, built-in write tools are disabled.
*
* Features:
* - /plan command or Shift+Tab to toggle
* - Bash restricted to allowlisted read-only commands
* - Extracts numbered plan steps from "Plan:" sections
* - [DONE:n] markers to complete steps during execution
* - Progress tracking widget during execution
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.ts";
// Tools
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
const PLAN_MODE_DISABLED_TOOLS = new Set<string>(["edit", "write"]);
const PLAN_MANAGED_TOOLS = new Set<string>([...PLAN_MODE_TOOLS, ...NORMAL_MODE_TOOLS]);
interface PlanModeState {
enabled: boolean;
todos?: TodoItem[];
executing?: boolean;
toolsBeforePlanMode?: string[];
}
// Type guard for assistant messages
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
return m.role === "assistant" && Array.isArray(m.content);
}
// Extract text content from an assistant message
function getTextContent(message: AssistantMessage): string {
return message.content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join("\n");
}
export default function planModeExtension(pi: ExtensionAPI): void {
let planModeEnabled = false;
let executionMode = false;
let todoItems: TodoItem[] = [];
let toolsBeforePlanMode: string[] | undefined;
pi.registerFlag("plan", {
description: "Start in plan mode (read-only exploration)",
type: "boolean",
default: false,
});
function updateStatus(ctx: ExtensionContext): void {
// Footer status
if (executionMode && todoItems.length > 0) {
const completed = todoItems.filter((t) => t.completed).length;
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
} else if (planModeEnabled) {
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
} else {
ctx.ui.setStatus("plan-mode", undefined);
}
// Widget showing todo list
if (executionMode && todoItems.length > 0) {
const lines = todoItems.map((item) => {
if (item.completed) {
return (
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
);
}
return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
});
ctx.ui.setWidget("plan-todos", lines);
} else {
ctx.ui.setWidget("plan-todos", undefined);
}
}
function uniqueToolNames(toolNames: string[]): string[] {
return [...new Set(toolNames)];
}
function getPlanModeTools(activeToolNames: string[]): string[] {
return uniqueToolNames([
...activeToolNames.filter((name) => !PLAN_MODE_DISABLED_TOOLS.has(name)),
...PLAN_MODE_TOOLS,
]);
}
function getNormalModeTools(activeToolNames: string[]): string[] {
return uniqueToolNames([
...NORMAL_MODE_TOOLS,
...activeToolNames.filter((name) => !PLAN_MANAGED_TOOLS.has(name)),
]);
}
function enablePlanModeTools(): void {
if (toolsBeforePlanMode === undefined) {
toolsBeforePlanMode = pi.getActiveTools();
}
pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
}
function restoreNormalModeTools(): void {
pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools()));
toolsBeforePlanMode = undefined;
}
function persistState(): void {
pi.appendEntry("plan-mode", {
enabled: planModeEnabled,
todos: todoItems,
executing: executionMode,
toolsBeforePlanMode,
});
}
function togglePlanMode(ctx: ExtensionContext): void {
planModeEnabled = !planModeEnabled;
executionMode = false;
todoItems = [];
if (planModeEnabled) {
enablePlanModeTools();
ctx.ui.notify("Plan mode enabled. Built-in write tools disabled.");
} else {
restoreNormalModeTools();
ctx.ui.notify("Plan mode disabled. Full access restored.");
}
updateStatus(ctx);
persistState();
}
pi.registerCommand("plan", {
description: "Toggle plan mode (read-only exploration)",
handler: async (_args, ctx) => togglePlanMode(ctx),
});
pi.registerCommand("todos", {
description: "Show current plan todo list",
handler: async (_args, ctx) => {
if (todoItems.length === 0) {
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
return;
}
const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
ctx.ui.notify(`Plan Progress:\n${list}`, "info");
},
});
pi.registerShortcut("shift+tab", {
description: "Toggle plan mode",
handler: async (ctx) => togglePlanMode(ctx),
});
// Block destructive bash commands in plan mode
pi.on("tool_call", async (event) => {
if (!planModeEnabled || event.toolName !== "bash") return;
const command = event.input.command as string;
if (!isSafeCommand(command)) {
return {
block: true,
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
};
}
});
// Filter out stale plan mode context when not in plan mode
pi.on("context", async (event) => {
if (planModeEnabled) return;
return {
messages: event.messages.filter((m) => {
const msg = m as AgentMessage & { customType?: string };
if (msg.customType === "plan-mode-context") return false;
if (msg.role !== "user") return true;
const content = msg.content;
if (typeof content === "string") {
return !content.includes("[PLAN MODE ACTIVE]");
}
if (Array.isArray(content)) {
return !content.some(
(c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
);
}
return true;
}),
};
});
// Inject plan/execution context before agent starts
pi.on("before_agent_start", async () => {
if (planModeEnabled) {
return {
message: {
customType: "plan-mode-context",
content: `[PLAN MODE ACTIVE]
You are in plan mode - a read-only exploration mode for safe code analysis.
Restrictions:
- Built-in edit and write tools are disabled
- Other currently active tools remain available
- Bash is restricted to an allowlist of read-only commands
Ask clarifying questions using the questionnaire tool.
Create a detailed numbered plan under a "Plan:" header:
Plan:
1. First step description
2. Second step description
...
Do NOT attempt to make changes - just describe what you would do.`,
display: false,
},
};
}
if (executionMode && todoItems.length > 0) {
const remaining = todoItems.filter((t) => !t.completed);
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
return {
message: {
customType: "plan-execution-context",
content: `[EXECUTING PLAN - Full tool access enabled]
Remaining steps:
${todoList}
Execute each step in order.
After completing a step, include a [DONE:n] tag in your response.`,
display: false,
},
};
}
});
// Track progress after each turn
pi.on("turn_end", async (event, ctx) => {
if (!executionMode || todoItems.length === 0) return;
if (!isAssistantMessage(event.message)) return;
const text = getTextContent(event.message);
if (markCompletedSteps(text, todoItems) > 0) {
updateStatus(ctx);
}
persistState();
});
// Handle plan completion and plan mode UI
pi.on("agent_end", async (event, ctx) => {
// Check if execution is complete
if (executionMode && todoItems.length > 0) {
if (todoItems.every((t) => t.completed)) {
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
pi.sendMessage(
{ customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
{ triggerTurn: false },
);
executionMode = false;
todoItems = [];
updateStatus(ctx);
persistState(); // Save cleared state so resume doesn't restore old execution mode
}
return;
}
if (!planModeEnabled || !ctx.hasUI) return;
// Extract todos from last assistant message
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
if (lastAssistant) {
const extracted = extractTodoItems(getTextContent(lastAssistant));
if (extracted.length > 0) {
todoItems = extracted;
}
}
if (todoItems.length === 0) return;
persistState();
// Show plan steps and prompt for next action
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
const planTodoListMessage = {
customType: "plan-todo-list",
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
display: true,
};
const choice = await ctx.ui.select("Plan mode - what next?", [
"Execute the plan (track progress)",
"Stay in plan mode",
"Refine the plan",
]);
if (choice?.startsWith("Execute")) {
const firstTodoItem = todoItems[0];
if (!firstTodoItem) return;
planModeEnabled = false;
executionMode = true;
restoreNormalModeTools();
updateStatus(ctx);
persistState();
const remainingList = todoItems.map((t) => `${t.step}. ${t.text}`).join("\n");
const execMessage = `Execute the plan.
Remaining steps:
${remainingList}
Start with: ${firstTodoItem.text}
After completing a step, include a [DONE:n] tag in your response.`;
pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
pi.sendMessage(
{ customType: "plan-mode-execute", content: execMessage, display: true },
{ triggerTurn: true, deliverAs: "followUp" },
);
} else if (choice === "Refine the plan") {
const refinement = await ctx.ui.editor("Refine the plan:", "");
if (refinement?.trim()) {
pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
pi.sendUserMessage(refinement.trim(), { deliverAs: "followUp" });
}
}
});
// Restore state on session start/resume
pi.on("session_start", async (_event, ctx) => {
if (pi.getFlag("plan") === true) {
planModeEnabled = true;
}
const entries = ctx.sessionManager.getEntries();
// Restore persisted state
const planModeEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
.pop() as { data?: PlanModeState } | undefined;
if (planModeEntry?.data) {
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
todoItems = planModeEntry.data.todos ?? todoItems;
executionMode = planModeEntry.data.executing ?? executionMode;
toolsBeforePlanMode = planModeEntry.data.toolsBeforePlanMode ?? toolsBeforePlanMode;
}
// On resume: re-scan messages to rebuild completion state
// Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
const isResume = planModeEntry !== undefined;
if (isResume && executionMode && todoItems.length > 0) {
// Find the index of the last plan-mode-execute entry (marks when current execution started)
let executeIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i] as { type: string; customType?: string };
if (entry.customType === "plan-mode-execute") {
executeIndex = i;
break;
}
}
// Only scan messages after the execute marker
const messages: AssistantMessage[] = [];
for (let i = executeIndex + 1; i < entries.length; i++) {
const entry = entries[i];
if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
messages.push(entry.message as AssistantMessage);
}
}
const allText = messages.map(getTextContent).join("\n");
markCompletedSteps(allText, todoItems);
}
if (planModeEnabled) {
enablePlanModeTools();
}
updateStatus(ctx);
});
}
@@ -0,0 +1,168 @@
/**
* Pure utility functions for plan mode.
* Extracted for testability.
*/
// Destructive commands blocked in plan mode
const DESTRUCTIVE_PATTERNS = [
/\brm\b/i,
/\brmdir\b/i,
/\bmv\b/i,
/\bcp\b/i,
/\bmkdir\b/i,
/\btouch\b/i,
/\bchmod\b/i,
/\bchown\b/i,
/\bchgrp\b/i,
/\bln\b/i,
/\btee\b/i,
/\btruncate\b/i,
/\bdd\b/i,
/\bshred\b/i,
/(^|[^<])>(?!>)/,
/>>/,
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
/\byarn\s+(add|remove|install|publish)/i,
/\bpnpm\s+(add|remove|install|publish)/i,
/\bpip\s+(install|uninstall)/i,
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
/\bbrew\s+(install|uninstall|upgrade)/i,
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
/\bsudo\b/i,
/\bsu\b/i,
/\bkill\b/i,
/\bpkill\b/i,
/\bkillall\b/i,
/\breboot\b/i,
/\bshutdown\b/i,
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
/\bservice\s+\S+\s+(start|stop|restart)/i,
/\b(vim?|nano|emacs|code|subl)\b/i,
];
// Safe read-only commands allowed in plan mode
const SAFE_PATTERNS = [
/^\s*cat\b/,
/^\s*head\b/,
/^\s*tail\b/,
/^\s*less\b/,
/^\s*more\b/,
/^\s*grep\b/,
/^\s*find\b/,
/^\s*ls\b/,
/^\s*pwd\b/,
/^\s*echo\b/,
/^\s*printf\b/,
/^\s*wc\b/,
/^\s*sort\b/,
/^\s*uniq\b/,
/^\s*diff\b/,
/^\s*file\b/,
/^\s*stat\b/,
/^\s*du\b/,
/^\s*df\b/,
/^\s*tree\b/,
/^\s*which\b/,
/^\s*whereis\b/,
/^\s*type\b/,
/^\s*env\b/,
/^\s*printenv\b/,
/^\s*uname\b/,
/^\s*whoami\b/,
/^\s*id\b/,
/^\s*date\b/,
/^\s*cal\b/,
/^\s*uptime\b/,
/^\s*ps\b/,
/^\s*top\b/,
/^\s*htop\b/,
/^\s*free\b/,
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
/^\s*git\s+ls-/i,
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
/^\s*yarn\s+(list|info|why|audit)/i,
/^\s*node\s+--version/i,
/^\s*python\s+--version/i,
/^\s*curl\s/i,
/^\s*wget\s+-O\s*-/i,
/^\s*jq\b/,
/^\s*sed\s+-n/i,
/^\s*awk\b/,
/^\s*rg\b/,
/^\s*fd\b/,
/^\s*bat\b/,
/^\s*eza\b/,
];
export function isSafeCommand(command: string): boolean {
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
return !isDestructive && isSafe;
}
export interface TodoItem {
step: number;
text: string;
completed: boolean;
}
export function cleanStepText(text: string): string {
let cleaned = text
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
.replace(/`([^`]+)`/g, "$1") // Remove code
.replace(
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
"",
)
.replace(/\s+/g, " ")
.trim();
if (cleaned.length > 0) {
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
if (cleaned.length > 50) {
cleaned = `${cleaned.slice(0, 47)}...`;
}
return cleaned;
}
export function extractTodoItems(message: string): TodoItem[] {
const items: TodoItem[] = [];
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
if (!headerMatch) return items;
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
for (const match of planSection.matchAll(numberedPattern)) {
const text = match[2]
.trim()
.replace(/\*{1,2}$/, "")
.trim();
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
const cleaned = cleanStepText(text);
if (cleaned.length > 3) {
items.push({ step: items.length + 1, text: cleaned, completed: false });
}
}
}
return items;
}
export function extractDoneSteps(message: string): number[] {
const steps: number[] = [];
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
const step = Number(match[1]);
if (Number.isFinite(step)) steps.push(step);
}
return steps;
}
export function markCompletedSteps(text: string, items: TodoItem[]): number {
const doneSteps = extractDoneSteps(text);
for (const step of doneSteps) {
const item = items.find((t) => t.step === step);
if (item) item.completed = true;
}
return doneSteps.length;
}
@@ -0,0 +1,449 @@
/**
* Questionnaire Tool - Unified tool for asking single or multiple questions
*
* Single question: simple options list
* Multiple questions: tab bar navigation between questions
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
Editor,
type EditorTheme,
Key,
matchesKey,
Text,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { Type } from "typebox";
// Types
interface QuestionOption {
value: string;
label: string;
description?: string;
}
type RenderOption = QuestionOption & { isOther?: boolean };
interface Question {
id: string;
label: string;
prompt: string;
options: QuestionOption[];
allowOther: boolean;
}
interface Answer {
id: string;
value: string;
label: string;
wasCustom: boolean;
index?: number;
}
interface QuestionnaireResult {
questions: Question[];
answers: Answer[];
cancelled: boolean;
}
// Schema
const QuestionOptionSchema = Type.Object({
value: Type.String({ description: "The value returned when selected" }),
label: Type.String({ description: "Display label for the option" }),
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
});
const QuestionSchema = Type.Object({
id: Type.String({ description: "Unique identifier for this question" }),
label: Type.Optional(
Type.String({
description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
}),
),
prompt: Type.String({ description: "The full question text to display" }),
options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }),
allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })),
});
const QuestionnaireParams = Type.Object({
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
});
function errorResult(
message: string,
questions: Question[] = [],
): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
return {
content: [{ type: "text", text: message }],
details: { questions, answers: [], cancelled: true },
};
}
export default function questionnaire(pi: ExtensionAPI) {
pi.registerTool({
name: "questionnaire",
label: "Questionnaire",
description:
"Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.",
parameters: QuestionnaireParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
if (ctx.mode !== "tui") {
return errorResult("Error: UI not available (running in non-interactive mode)");
}
if (params.questions.length === 0) {
return errorResult("Error: No questions provided");
}
// Normalize questions with defaults
const questions: Question[] = params.questions.map((q, i) => ({
...q,
label: q.label || `Q${i + 1}`,
allowOther: q.allowOther !== false,
}));
const isMulti = questions.length > 1;
const totalTabs = questions.length + 1; // questions + Submit
const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
// State
let currentTab = 0;
let optionIndex = 0;
let inputMode = false;
let inputQuestionId: string | null = null;
let cachedLines: string[] | undefined;
const answers = new Map<string, Answer>();
// Editor for "Type something" option
const editorTheme: EditorTheme = {
borderColor: (s) => theme.fg("accent", s),
selectList: {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
},
};
const editor = new Editor(tui, editorTheme);
// Helpers
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function submit(cancelled: boolean) {
done({ questions, answers: Array.from(answers.values()), cancelled });
}
function currentQuestion(): Question | undefined {
return questions[currentTab];
}
function currentOptions(): RenderOption[] {
const q = currentQuestion();
if (!q) return [];
const opts: RenderOption[] = [...q.options];
if (q.allowOther) {
opts.push({ value: "__other__", label: "Type something.", isOther: true });
}
return opts;
}
function allAnswered(): boolean {
return questions.every((q) => answers.has(q.id));
}
function advanceAfterAnswer() {
if (!isMulti) {
submit(false);
return;
}
if (currentTab < questions.length - 1) {
currentTab++;
} else {
currentTab = questions.length; // Submit tab
}
optionIndex = 0;
refresh();
}
function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) {
answers.set(questionId, { id: questionId, value, label, wasCustom, index });
}
// Editor submit callback
editor.onSubmit = (value) => {
if (!inputQuestionId) return;
const trimmed = value.trim() || "(no response)";
saveAnswer(inputQuestionId, trimmed, trimmed, true);
inputMode = false;
inputQuestionId = null;
editor.setText("");
advanceAfterAnswer();
};
function handleInput(data: string) {
// Input mode: route to editor
if (inputMode) {
if (matchesKey(data, Key.escape)) {
inputMode = false;
inputQuestionId = null;
editor.setText("");
refresh();
return;
}
editor.handleInput(data);
refresh();
return;
}
const q = currentQuestion();
const opts = currentOptions();
// Tab navigation (multi-question only)
if (isMulti) {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
currentTab = (currentTab + 1) % totalTabs;
optionIndex = 0;
refresh();
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
currentTab = (currentTab - 1 + totalTabs) % totalTabs;
optionIndex = 0;
refresh();
return;
}
}
// Submit tab
if (currentTab === questions.length) {
if (matchesKey(data, Key.enter) && allAnswered()) {
submit(false);
} else if (matchesKey(data, Key.escape)) {
submit(true);
}
return;
}
// Option navigation
if (matchesKey(data, Key.up)) {
optionIndex = Math.max(0, optionIndex - 1);
refresh();
return;
}
if (matchesKey(data, Key.down)) {
optionIndex = Math.min(opts.length - 1, optionIndex + 1);
refresh();
return;
}
// Select option
if (matchesKey(data, Key.enter) && q) {
const opt = opts[optionIndex];
if (!opt) return; // empty options with allowOther=false
if (opt.isOther) {
inputMode = true;
inputQuestionId = q.id;
editor.setText("");
refresh();
return;
}
saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
advanceAfterAnswer();
return;
}
// Cancel
if (matchesKey(data, Key.escape)) {
submit(true);
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const renderWidth = Math.max(1, width);
const q = currentQuestion();
const opts = currentOptions();
function addWrapped(text: string) {
lines.push(...wrapTextWithAnsi(text, renderWidth));
}
function addWrappedWithPrefix(prefix: string, text: string) {
const prefixWidth = visibleWidth(prefix);
if (prefixWidth >= renderWidth) {
addWrapped(prefix + text);
return;
}
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
const continuationPrefix = " ".repeat(prefixWidth);
for (let i = 0; i < wrapped.length; i++) {
lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`);
}
}
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
// Tab bar (multi-question only)
if (isMulti) {
const tabs: string[] = ["← "];
for (let i = 0; i < questions.length; i++) {
const isActive = i === currentTab;
const isAnswered = answers.has(questions[i].id);
const lbl = questions[i].label;
const box = isAnswered ? "■" : "□";
const color = isAnswered ? "success" : "muted";
const text = ` ${box} ${lbl} `;
const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text);
tabs.push(`${styled} `);
}
const canSubmit = allAnswered();
const isSubmitTab = currentTab === questions.length;
const submitText = " ✓ Submit ";
const submitStyled = isSubmitTab
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg(canSubmit ? "success" : "dim", submitText);
tabs.push(`${submitStyled}`);
addWrappedWithPrefix(" ", tabs.join(""));
lines.push("");
}
// Helper to render options list
function renderOptions() {
for (let i = 0; i < opts.length; i++) {
const opt = opts[i];
const selected = i === optionIndex;
const isOther = opt.isOther === true;
const prefix = selected ? theme.fg("accent", "> ") : " ";
const label = `${i + 1}. ${opt.label}${isOther && inputMode ? " ✎" : ""}`;
const color = selected || (isOther && inputMode) ? "accent" : "text";
addWrappedWithPrefix(prefix, theme.fg(color, label));
if (opt.description) {
addWrappedWithPrefix(" ", theme.fg("muted", opt.description));
}
}
}
// Content
if (inputMode && q) {
addWrappedWithPrefix(" ", theme.fg("text", q.prompt));
lines.push("");
// Show options for reference
renderOptions();
lines.push("");
addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
for (const line of editor.render(Math.max(1, renderWidth - 2))) {
lines.push(` ${line}`);
}
lines.push("");
addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to cancel"));
} else if (currentTab === questions.length) {
addWrappedWithPrefix(" ", theme.fg("accent", theme.bold("Ready to submit")));
lines.push("");
for (const question of questions) {
const answer = answers.get(question.id);
if (answer) {
const prefix = answer.wasCustom ? "(wrote) " : "";
const summary = `${theme.fg("muted", `${question.label}: `)}${theme.fg("text", prefix + answer.label)}`;
addWrappedWithPrefix(" ", summary);
}
}
lines.push("");
if (allAnswered()) {
addWrappedWithPrefix(" ", theme.fg("success", "Press Enter to submit"));
} else {
const missing = questions
.filter((q) => !answers.has(q.id))
.map((q) => q.label)
.join(", ");
addWrappedWithPrefix(" ", theme.fg("warning", `Unanswered: ${missing}`));
}
} else if (q) {
addWrappedWithPrefix(" ", theme.fg("text", q.prompt));
lines.push("");
renderOptions();
}
lines.push("");
if (!inputMode) {
const help = isMulti
? "Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
: "↑↓ navigate • Enter select • Esc cancel";
addWrappedWithPrefix(" ", theme.fg("dim", help));
}
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
cachedLines = lines;
return lines;
}
return {
render,
invalidate: () => {
cachedLines = undefined;
},
handleInput,
};
});
if (result.cancelled) {
return {
content: [{ type: "text", text: "User cancelled the questionnaire" }],
details: result,
};
}
const answerLines = result.answers.map((a) => {
const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
if (a.wasCustom) {
return `${qLabel}: user wrote: ${a.label}`;
}
return `${qLabel}: user selected: ${a.index}. ${a.label}`;
});
return {
content: [{ type: "text", text: answerLines.join("\n") }],
details: result,
};
},
renderCall(args, theme, _context) {
const qs = (args.questions as Question[]) || [];
const count = qs.length;
const labels = qs.map((q) => q.label || q.id).join(", ");
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
if (labels) {
text += theme.fg("dim", ` (${labels})`);
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme, _context) {
const details = result.details as QuestionnaireResult | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.cancelled) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
const lines = details.answers.map((a) => {
if (a.wasCustom) {
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`;
}
const display = a.index ? `${a.index}. ${a.label}` : a.label;
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
});
return new Text(lines.join("\n"), 0, 0);
},
});
}
+257
View File
@@ -0,0 +1,257 @@
/**
* Pi status bar (custom footer)
*
* Replaces pi's default footer via `ctx.ui.setFooter()`. It mirrors the layout
* and *meaning* of the Claude Code status line (`~/.config/claude/statusline.py`),
* but swaps the emoji for Nerd Font glyphs — the same Material Design set already
* used across tmux (waybar cpu/mem/net glyphs) and nvim — so it reads natively in
* kitty (CaskaydiaCove Nerd Font Mono). Colors come from the active pi theme
* (catppuccin-mocha), not hard-coded ANSI.
*
* Segments render left → right and truncate at the terminal edge, so the most
* useful info stays visible on a narrow pane. Each icon echoes the Claude emoji
* it replaces:
*
* 󰉋 dir cwd, ~-collapsed (Claude 📁) U+F024B nf-md-folder
* 󰘬 git current branch (only inside a repo) (Claude 🌿) U+F062C nf-md-source_branch
* 󰚩 model model id + · thinking level (Claude 🤖) U+F06A9 nf-md-robot
* 󰈚 context % of context window used (+ tokens) (Claude 📝) U+F021A nf-md-text_box
* 󰓅 t/s last response's decode throughput U+F04C5 nf-md-speedometer
* ↑ ↓ tokens session input / output tokens (Claude 💰) plain arrows
*
* git, context, t/s and tokens only appear once there is data for them (i.e. after
* the first response, and git only inside a git repo). Extension statuses (e.g.
* plan-mode's "⏸ plan") are preserved at the far left. The Claude bar's system row
* (RAM/CPU/temp/disk) is intentionally omitted — that data isn't in the footer API,
* and the tmux bar below pi already shows cpu/mem/net.
*
* Two extras beyond the footer line:
* - a themed pulse working-indicator (the spinner shown while pi streams), and
* - a 󰀪 context-budget warning widget above the editor once the window passes
* 80% full, nudging a /compact before things get truncated.
*
* Restore the built-in footer (and reset the indicator/widget) with `/statusbar`.
*/
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { truncateToWidth } from "@earendil-works/pi-tui";
// Nerd Font (Material Design) glyphs — verified to render in the user's font.
const ICON = {
dir: "\u{F024B}", // 󰉋 nf-md-folder
git: "\u{F062C}", // 󰘬 nf-md-source_branch
model: "\u{F06A9}", // 󰚩 nf-md-robot
context: "\u{F021A}", // 󰈚 nf-md-text_box
tps: "\u{F04C5}", // 󰓅 nf-md-speedometer
warn: "\u{F002A}", // 󰀪 nf-md-alert_outline (same glyph nvim uses for warnings)
up: "↑", // ↑
down: "↓", // ↓
};
// Widget key for the context-budget warning shown above the editor.
const CTX_WIDGET = "statusbar-context-warning";
// Show the warning once the context window is this full (%).
const CTX_WARN_AT = 80;
// Current thinking level, tracked via thinking_level_select (footer render has no
// direct access to it). Defaults to the settings.json default of "off".
let thinkingLevel = "off";
// Decode throughput (tokens/sec) of the most recent assistant response, measured
// from the first streamed token to message end so prompt-eval time is excluded.
let lastTps = 0;
let genFirstTokenAt = 0;
let sawToken = false;
// Set from inside the footer factory so events can trigger a re-render.
let requestRender: (() => void) | undefined;
function collapseHome(p: string): string {
const home = process.env.HOME || "";
if (p === home) return "~";
if (home && p.startsWith(`${home}/`)) return `~${p.slice(home.length)}`;
return p;
}
function fmtTokens(n: number): string {
return n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`;
}
/** Cumulative session tokens, plus the last request's input tokens (~ current
* context-window fill). */
function tokenStats(ctx: ExtensionContext): {
input: number;
output: number;
lastInput: number;
} {
let input = 0;
let output = 0;
let lastInput = 0;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "assistant") {
const usage = (entry.message as AssistantMessage).usage;
if (!usage) continue; // aborted stream: no usage — a throwing render kills the TUI
input += usage.input;
output += usage.output;
lastInput = usage.input;
}
}
return { input, output, lastInput };
}
/** Show/hide the above-editor warning when the context window is nearly full. */
function updateContextWidget(ctx: ExtensionContext): void {
if (ctx.mode !== "tui") return;
const contextWindow = ctx.model?.contextWindow;
const { lastInput } = tokenStats(ctx);
if (!contextWindow || lastInput <= 0) {
ctx.ui.setWidget(CTX_WIDGET, undefined);
return;
}
const pct = (lastInput / contextWindow) * 100;
if (pct < CTX_WARN_AT) {
ctx.ui.setWidget(CTX_WIDGET, undefined);
return;
}
const th = ctx.ui.theme;
const col = pct >= 90 ? "error" : "warning";
const line =
th.fg(col, `${ICON.warn} context ${pct.toFixed(0)}%`) +
th.fg("dim", ` (${fmtTokens(lastInput)}/${fmtTokens(contextWindow)}) — /compact soon`);
ctx.ui.setWidget(CTX_WIDGET, [line]);
}
/** A gentle catppuccin "breathing" pulse for the streaming working-indicator. */
function pulseIndicator(ctx: ExtensionContext) {
const th = ctx.ui.theme;
return {
frames: [th.fg("dim", "·"), th.fg("muted", "•"), th.fg("accent", "●"), th.fg("muted", "•")],
intervalMs: 120,
};
}
export default function (pi: ExtensionAPI) {
let enabled = true;
pi.on("thinking_level_select", async (event) => {
thinkingLevel = event.level;
requestRender?.();
});
pi.on("model_select", async () => {
requestRender?.();
});
// --- t/s timing: bracket each assistant response and clock its generation ---
pi.on("message_start", async (event) => {
if (event.message?.role !== "assistant") return;
sawToken = false;
genFirstTokenAt = 0;
});
pi.on("message_update", async (event) => {
if (event.message?.role !== "assistant") return;
if (!sawToken) {
sawToken = true;
genFirstTokenAt = Date.now();
}
});
pi.on("message_end", async (event, ctx) => {
if (event.message?.role !== "assistant") return;
const output = (event.message as AssistantMessage).usage?.output ?? 0;
const secs = genFirstTokenAt ? (Date.now() - genFirstTokenAt) / 1000 : 0;
if (secs > 0 && output > 0) lastTps = output / secs;
requestRender?.();
if (enabled) updateContextWidget(ctx);
});
const install = (ctx: ExtensionContext) => {
if (ctx.mode !== "tui") return;
ctx.ui.setWorkingIndicator(pulseIndicator(ctx));
updateContextWidget(ctx);
ctx.ui.setFooter((tui, theme, footerData) => {
const sep = theme.fg("dim", " │ ");
requestRender = () => tui.requestRender();
const unsub = footerData.onBranchChange(() => tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
const parts: string[] = [];
// Preserve extension statuses (plan-mode "⏸ plan", etc.) up front.
for (const status of footerData.getExtensionStatuses().values()) {
if (status) parts.push(status);
}
// 󰉋 directory
parts.push(theme.fg("accent", ICON.dir) + " " + theme.fg("text", collapseHome(ctx.cwd)));
// 󰘬 git branch (only inside a repo)
const branch = footerData.getGitBranch();
if (branch) parts.push(theme.fg("accent", ICON.git) + " " + theme.fg("success", branch));
// 󰚩 model (+ · thinking level)
if (ctx.model?.id) {
let model = theme.fg("accent", ICON.model) + " " + theme.fg("text", ctx.model.id);
if (thinkingLevel && thinkingLevel !== "off") {
model += theme.fg("dim", ` · ${thinkingLevel}`);
}
parts.push(model);
}
// 󰈚 context window usage (% when the window is known, else raw tokens)
const { input, output, lastInput } = tokenStats(ctx);
if (lastInput > 0) {
const contextWindow = ctx.model?.contextWindow;
let seg = theme.fg("accent", ICON.context) + " ";
if (contextWindow) {
const pct = Math.min(100, (lastInput / contextWindow) * 100);
const col = pct < 50 ? "success" : pct < 80 ? "warning" : "error";
seg += theme.fg(col, `${pct.toFixed(0)}%`) + theme.fg("dim", ` (${fmtTokens(lastInput)})`);
} else {
seg += theme.fg("text", fmtTokens(lastInput));
}
parts.push(seg);
}
// 󰓅 tokens/sec of the last response
if (lastTps > 0) {
parts.push(
theme.fg("accent", ICON.tps) + " " + theme.fg("text", lastTps.toFixed(1)) + theme.fg("dim", " t/s"),
);
}
// ↑ ↓ cumulative session tokens (Claude's 💰 slot; local models are $0)
if (input || output) {
parts.push(theme.fg("dim", `${ICON.up}${fmtTokens(input)} ${ICON.down}${fmtTokens(output)}`));
}
return [truncateToWidth(parts.join(sep), width)];
},
};
});
};
pi.on("session_start", async (_event, ctx) => {
if (enabled) install(ctx);
});
pi.registerCommand("statusbar", {
description: "Toggle the custom pi status bar footer",
handler: async (_args, ctx) => {
enabled = !enabled;
if (enabled) {
install(ctx);
ctx.ui.notify("Custom status bar enabled", "info");
} else {
ctx.ui.setFooter(undefined);
ctx.ui.setWorkingIndicator(); // restore pi's default spinner
ctx.ui.setWidget(CTX_WIDGET, undefined);
ctx.ui.notify("Default footer restored", "info");
}
},
});
}
+44
View File
@@ -0,0 +1,44 @@
/**
* `/vim` — toggle the modal input editor off/on
*
* The modal editor itself is now the **npm package `pi-vim`** (declared in
* `settings.json > packages`, configured under `settings.json > piVim`). That
* package registers no commands, so this file restores the `/vim` toggle the
* local `vim-editor.ts` fork used to provide — the fork is kept beside it as
* `vim-editor.ts.disabled` (see ../../CLAUDE.md).
*
* OFF — drop the custom editor component, which puts pi's stock editor back for
* the rest of the session (handy for a big paste, or any key the modal
* layer swallows).
* ON — `ctx.reload()`, the same flow as `/reload`, re-runs extension discovery
* and so re-installs pi-vim's editor. That reloads statusbar/plan-mode too
* and re-instantiates *this* file, which is why turning it back on needs no
* bookkeeping: the fresh instance starts at `enabled = true` again.
*
* Nothing is installed on `session_start` — pi-vim already does that, so this
* file only ever reacts to the command and never races it for the editor slot.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// pi-vim installs its editor at session_start, so we start out enabled.
let enabled = true;
pi.registerCommand("vim", {
description: "Toggle the vim (modal) input editor",
handler: async (_args, ctx) => {
if (ctx.mode !== "tui") return; // editor components are TUI-only
if (enabled) {
ctx.ui.setEditorComponent(undefined);
enabled = false;
ctx.ui.notify("Default editor restored — /vim to re-enable", "info");
return;
}
// Notify *before* awaiting: after the reload this call frame belongs to
// the pre-reload instance, and its UI handle is on the way out.
ctx.ui.notify("Vim editor enabled", "info");
await ctx.reload();
},
});
}
+5
View File
@@ -0,0 +1,5 @@
{
"app.thinking.cycle": ["ctrl+shift+t"],
"tui.editor.cursorUp": ["up", "ctrl+p"],
"app.model.cycleForward": ["alt+p"]
}
+214
View File
@@ -0,0 +1,214 @@
{
"providers": {
"duskadiy": {
"baseUrl": "https://llm.duskadiy.com/api/v1",
"api": "openai-completions",
"apiKey": "$DUSKADIY_API_KEY",
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false,
"maxTokensField": "max_tokens"
},
"models": [
{
"id": "gemma-4-E4B-it-UD-Q8_K_XL",
"name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs",
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 65536,
"maxTokens": 16384,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gemma-4-26B-A4B-it-UD-IQ4_XS",
"name": "Gemma 4 26B · 24k · vision — quality generalist",
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL",
"name": "Qwen3 Coder 30B · 32k — main agent coder",
"reasoning": false,
"input": ["text"],
"contextWindow": 32768,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3-Coder-Next-UD-IQ3_XXS",
"name": "Qwen3 Coder Next 80B · 128k — long sessions",
"reasoning": false,
"input": ["text"],
"contextWindow": 131072,
"maxTokens": 16384,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS",
"name": "Qwen3.6 35B · 24k · vision — daily driver",
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3.6-35B-A3B-Thinking",
"name": "Qwen3.6 35B Thinking · 24k · vision — hard problems",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3.5-9B-UD-Q6_K_XL",
"name": "Qwen3.5 9B · 32k · vision — quick tasks",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text", "image"],
"contextWindow": 32768,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "GLM-4.7-Flash-UD-Q4_K_XL",
"name": "GLM-4.7 Flash · 24k — quality coder",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text"],
"contextWindow": 24576,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-oss-20b",
"name": "gpt-oss 20B · 64k — fast reasoning + tools",
"reasoning": true,
"input": ["text"],
"contextWindow": 65536,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-oss-20b-low",
"name": "gpt-oss 20B low · 64k — snappy answers",
"reasoning": true,
"input": ["text"],
"contextWindow": 65536,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
}
]
},
"localcpp": {
"baseUrl": "http://192.168.0.204:11343/v1",
"api": "openai-completions",
"apiKey": "no-key-required",
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false,
"maxTokensField": "max_tokens"
},
"models": [
{
"id": "gemma-4-E4B-it-UD-Q8_K_XL",
"name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs",
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 65536,
"maxTokens": 16384,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gemma-4-26B-A4B-it-UD-IQ4_XS",
"name": "Gemma 4 26B · 24k · vision — quality generalist",
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL",
"name": "Qwen3 Coder 30B · 32k — main agent coder",
"reasoning": false,
"input": ["text"],
"contextWindow": 32768,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3-Coder-Next-UD-IQ3_XXS",
"name": "Qwen3 Coder Next 80B · 128k — long sessions",
"reasoning": false,
"input": ["text"],
"contextWindow": 131072,
"maxTokens": 16384,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS",
"name": "Qwen3.6 35B · 24k · vision — daily driver",
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3.6-35B-A3B-Thinking",
"name": "Qwen3.6 35B Thinking · 24k · vision — hard problems",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text", "image"],
"contextWindow": 24576,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "Qwen3.5-9B-UD-Q6_K_XL",
"name": "Qwen3.5 9B · 32k · vision — quick tasks",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text", "image"],
"contextWindow": 32768,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "GLM-4.7-Flash-UD-Q4_K_XL",
"name": "GLM-4.7 Flash · 24k — quality coder",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template" },
"input": ["text"],
"contextWindow": 24576,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-oss-20b",
"name": "gpt-oss 20B · 64k — fast reasoning + tools",
"reasoning": true,
"input": ["text"],
"contextWindow": 65536,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-oss-20b-low",
"name": "gpt-oss 20B low · 64k — snappy answers",
"reasoning": true,
"input": ["text"],
"contextWindow": 65536,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
}
]
}
}
}
+11
View File
@@ -0,0 +1,11 @@
---
description: Write a Conventional Commits message for the staged changes
argument-hint: "[extra context]"
---
Inspect the staged changes with `git diff --cached` (and `git status` for context). Then write a single Conventional Commits message:
- A `<type>(<scope>): <subject>` summary line, imperative mood, ≤72 chars (types: feat, fix, refactor, docs, chore, test, perf, build).
- An optional short body explaining the *why* only when it isn't obvious from the diff.
- Do NOT run `git commit`. Output only the message inside a code block so I can review it.
Extra context to incorporate: ${1:-none}
+14
View File
@@ -0,0 +1,14 @@
---
description: Review the current diff for bugs and issues
argument-hint: "[focus area]"
---
Review the current changes. Check both `git diff` (unstaged) and `git diff --cached` (staged). Focus on:
- Correctness and logic bugs
- Error handling and missing edge cases
- Security issues (injection, secrets, unsafe input)
- Anything that doesn't match the surrounding code's conventions
List concrete findings as `file:line — issue — suggested fix`, ordered by severity. Be concise; skip praise. If nothing is wrong, say so.
Extra focus this round: ${1:-general correctness}
+64
View File
@@ -0,0 +1,64 @@
{
"defaultProvider": "duskadiy",
"defaultModel": "gemma-4-26B-A4B-it-UD-IQ4_XS",
"defaultThinkingLevel": "off",
"theme": "catppuccin-mocha",
"quietStartup": false,
"defaultProjectTrust": "ask",
"enableInstallTelemetry": false,
"enabledModels": [
"duskadiy/*",
"localcpp/*"
],
"compaction": {
"enabled": true,
"reserveTokens": 6144,
"keepRecentTokens": 6000
},
"retry": {
"enabled": true,
"maxRetries": 3,
"baseDelayMs": 2000,
"provider": {
"timeoutMs": 3600000
}
},
"httpIdleTimeoutMs": 600000,
"npmCommand": [
"fnm",
"exec",
"--using=22",
"--",
"npm"
],
"showHardwareCursor": true,
"piVim": {
"clipboardMirror": "yank",
"exCommand": {
"piDispatch": true,
"copyInputToClipboard": false
},
"modeColors": {
"insert": "success",
"normal": "accent",
"visual": "warning",
"ex": "bashMode"
},
"borderSync": {
"insert": "host",
"normal": "host",
"visual": "host",
"ex": "host"
},
"labelSync": {
"insert": "mode",
"normal": "mode",
"visual": "mode",
"ex": "mode"
}
},
"lastChangelogVersion": "0.83.0",
"packages": [
"npm:pi-vim"
]
}
@@ -0,0 +1,90 @@
{
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "catppuccin-mocha",
"vars": {
"rosewater": "#f5e0dc",
"flamingo": "#f2cdcd",
"pink": "#f5c2e7",
"mauve": "#cba6f7",
"red": "#f38ba8",
"maroon": "#eba0ac",
"peach": "#fab387",
"yellow": "#f9e2af",
"green": "#a6e3a1",
"teal": "#94e2d5",
"sky": "#89dceb",
"sapphire": "#74c7ec",
"blue": "#89b4fa",
"lavender": "#b4befe",
"fg": "#cdd6f4",
"subtext1": "#bac2de",
"subtext0": "#a6adc8",
"overlay2": "#9399b2",
"overlay1": "#7f849c",
"overlay0": "#6c7086",
"surface2": "#585b70",
"surface1": "#45475a",
"surface0": "#313244",
"base": "#1e1e2e",
"mantle": "#181825",
"crust": "#11111b"
},
"colors": {
"accent": "mauve",
"border": "surface1",
"borderAccent": "mauve",
"borderMuted": "surface0",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "subtext0",
"dim": "overlay0",
"text": "fg",
"thinkingText": "overlay1",
"selectedBg": "surface0",
"userMessageBg": "surface0",
"userMessageText": "fg",
"customMessageBg": "surface0",
"customMessageText": "fg",
"customMessageLabel": "mauve",
"toolPendingBg": "mantle",
"toolSuccessBg": "#1e2b22",
"toolErrorBg": "#2b1e22",
"toolTitle": "mauve",
"toolOutput": "fg",
"mdHeading": "mauve",
"mdLink": "blue",
"mdLinkUrl": "sapphire",
"mdCode": "green",
"mdCodeBlock": "fg",
"mdCodeBlockBorder": "surface1",
"mdQuote": "subtext0",
"mdQuoteBorder": "surface2",
"mdHr": "surface1",
"mdListBullet": "mauve",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "overlay0",
"syntaxComment": "overlay0",
"syntaxKeyword": "mauve",
"syntaxFunction": "blue",
"syntaxVariable": "fg",
"syntaxString": "green",
"syntaxNumber": "peach",
"syntaxType": "yellow",
"syntaxOperator": "sky",
"syntaxPunctuation": "overlay2",
"thinkingOff": "surface1",
"thinkingMinimal": "overlay0",
"thinkingLow": "blue",
"thinkingMedium": "teal",
"thinkingHigh": "peach",
"thinkingXhigh": "red",
"bashMode": "peach"
},
"export": {
"pageBg": "#11111b",
"cardBg": "#1e1e2e",
"infoBg": "#313244"
}
}