[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:
@@ -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 (24k–128k;
|
||||
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.
|
||||
@@ -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 (24k–128k) — 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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"localcpp": {
|
||||
"type": "api_key",
|
||||
"key": "no-key-required"
|
||||
},
|
||||
"duskadiy": {
|
||||
"type": "api_key",
|
||||
"key": "$DUSKADIY_API_KEY"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"app.thinking.cycle": ["ctrl+shift+t"],
|
||||
"tui.editor.cursorUp": ["up", "ctrl+p"],
|
||||
"app.model.cycleForward": ["alt+p"]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user