Compare commits

..

24 Commits

Author SHA1 Message Date
Jonas H
0b11a0d315 install split 2026-03-19 08:02:02 +01:00
Jonas H
508eddef62 pi ignores 2026-03-19 08:01:49 +01:00
Jonas H
529764d15e zsh autocomplete list 2026-03-19 08:01:27 +01:00
Jonas H
871caa5adc pi config update 2026-03-19 07:58:49 +01:00
Jonas H
a3c9183485 explorer agent update 2026-03-19 07:49:08 +01:00
Jonas H
601dfb2332 +1 2026-03-19 07:48:57 +01:00
Jonas H
af15044f5c lazygit nvim plugin 2026-03-19 07:48:40 +01:00
Jonas H
9c82b54a52 Merge branch 'main' of https://gitea.haugesenspil.dk/jonas/dotfiles 2026-03-12 09:44:20 +01:00
Jonas H
6a92aa066a Merge branch 'main' of https://gitea.haugesenspil.dk/jonas/dotfiles
accidental backup file commit fix
2026-03-12 09:44:06 +01:00
Jonas H
54642fe7e4 Merge branch 'main' of https://gitea.haugesenspil.dk/jonas/dotfiles 2026-03-12 09:37:37 +01:00
Jonas H
bdf1f84e71 Merge branch 'main' of https://gitea.haugesenspil.dk/jonas/dotfiles 2026-03-12 09:37:14 +01:00
Jonas H
d308af7454 Merge branch 'main' of https://gitea.haugesenspil.dk/jonas/dotfiles 2026-03-12 09:35:06 +01:00
Jonas H
c419eb5f92 install consolidation 2026-03-12 09:12:41 +01:00
Jonas H
de49d03182 pi update 2026-03-12 09:12:31 +01:00
f1a0806dbb Merge branch 'main' of https://gitea.haugesenspil.dk/jonas/dotfiles 2026-03-12 09:07:02 +01:00
8069f6f150 pacman refresh 2026-03-12 08:58:49 +01:00
e149dac313 submodules support 2026-03-12 08:58:03 +01:00
Jonas H
870dc6ac58 pi explorer agent update 2026-03-09 11:55:42 +01:00
Jonas H
209dad84a1 ignored some pi cache 2026-03-09 11:55:26 +01:00
Jonas H
6bf59a4528 zsh added zoxide 2026-03-09 11:53:54 +01:00
Jonas H
afedf4d3c9 sway smart term fix 2026-03-09 11:53:39 +01:00
Jonas H
8e1bef3e17 starship config 2026-03-09 11:53:27 +01:00
Jonas H
bea8fa19b3 pi extension fixes 2026-03-09 11:53:09 +01:00
Jonas H
cf8623bca1 add .gitmodules for zsh plugins 2026-03-08 22:20:21 +01:00
39 changed files with 7372 additions and 976 deletions

7
.gitignore vendored
View File

@@ -5,3 +5,10 @@ pi/.pi/agent/sessions
pi/.pi/agent/auth.json
pi/.pi/agent/settings.json
pi/.pi/agent/usage-cache.json
pi/.pi/agent/mcp-cache.json
pi/.pi/agent/auth.json.current
pi/.pi/agent/run-history.jsonl
pi/.pi/agent/auth.json.lock
pi/.pi/context.md
pi/.pi/agent/cache/modules
pi/.pi/agent/extensions/*/node_modules

11
.gitmodules vendored Normal file
View File

@@ -0,0 +1,11 @@
[submodule "zshrc/.zsh/plugins/zsh-autosuggestions"]
path = zshrc/.zsh/plugins/zsh-autosuggestions
url = https://github.com/zsh-users/zsh-autosuggestions
[submodule "zshrc/.zsh/plugins/zsh-history-substring-search"]
path = zshrc/.zsh/plugins/zsh-history-substring-search
url = https://github.com/zsh-users/zsh-history-substring-search
[submodule "zshrc/.zsh/plugins/zsh-syntax-highlighting"]
path = zshrc/.zsh/plugins/zsh-syntax-highlighting
url = https://github.com/zsh-users/zsh-syntax-highlighting

15
dot-add
View File

@@ -45,6 +45,21 @@ add_one() {
local REL="${FILE#$HOME/}"
local DEST="$DOTFILES_DIR/$PACKAGE/$REL"
# If the path is a git repo, add it as a submodule instead of moving files
if [[ -d "$FILE/.git" ]]; then
local REMOTE
REMOTE="$(git -C "$FILE" remote get-url origin 2>/dev/null || true)"
if [[ -z "$REMOTE" ]]; then
echo "Error: '$FILE' is a git repo but has no remote origin — can't add as submodule" >&2
return 1
fi
mkdir -p "$(dirname "$DEST")"
rm -rf "$FILE"
git -C "$DOTFILES_DIR" submodule add "$REMOTE" "$PACKAGE/$REL"
echo "Added submodule: $REMOTE -> $DEST"
return 0
fi
mkdir -p "$(dirname "$DEST")"
mv "$FILE" "$DEST"
echo "Moved: $FILE -> $DEST"

205
install-deps.sh Executable file
View File

@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# install-deps.sh: Install all system and tooling dependencies.
# Run this after cloning on a new machine (before install-stow.sh).
set -euo pipefail
DOTFILES_DIR="$(cd "$(dirname "$0")" && pwd)"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ok() { echo "$*"; }
info() { echo "$*"; }
warn() { echo "$*"; }
header() { echo ""; echo "==> $*"; }
have() { command -v "$1" &>/dev/null; }
# ---------------------------------------------------------------------------
# Detect Linux distribution
# ---------------------------------------------------------------------------
detect_distro() {
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
. /etc/os-release
echo "$ID"
else
echo "unknown"
fi
}
DISTRO=$(detect_distro)
# ---------------------------------------------------------------------------
# System packages
# ---------------------------------------------------------------------------
header "System packages"
COMMON_PACKAGES=(
stow
swayidle
mako
libnotify
fuzzel
wireplumber
brightnessctl
alsa-utils
wf-recorder
slurp
zenity
jq
firefox
zoxide
)
case "$DISTRO" in
endeavouros|arch)
DISTRO_PACKAGES=(
pipewire-pulse
ffmpeg
)
PACKAGES=("${COMMON_PACKAGES[@]}" "${DISTRO_PACKAGES[@]}")
# Warn about pending upgrades (partial upgrades are unsupported on Arch)
sudo pacman -Sy --noconfirm &>/dev/null
updates=$(pacman -Qu 2>/dev/null | wc -l)
if [[ "$updates" -gt 0 ]]; then
warn "$updates system package(s) are out of date."
warn "If pacman fails with dependency conflicts, run 'sudo pacman -Syu'"
warn "locally (not over SSH) then re-run this script."
echo ""
fi
# Only install packages that aren't already present
MISSING=()
for pkg in "${PACKAGES[@]}"; do
if ! pacman -Q "$pkg" &>/dev/null; then
MISSING+=("$pkg")
else
ok "$pkg"
fi
done
if [[ ${#MISSING[@]} -gt 0 ]]; then
info "Installing: ${MISSING[*]}"
sudo pacman -S --noconfirm "${MISSING[@]}"
fi
;;
fedora|fedora-asahi-remix)
DISTRO_PACKAGES=(
pipewire-utils
ffmpeg
)
PACKAGES=("${COMMON_PACKAGES[@]}" "${DISTRO_PACKAGES[@]}")
MISSING=()
for pkg in "${PACKAGES[@]}"; do
if rpm -q "$pkg" &>/dev/null; then
ok "$pkg"
else
MISSING+=("$pkg")
fi
done
if [[ ${#MISSING[@]} -gt 0 ]]; then
info "Installing: ${MISSING[*]}"
sudo dnf install -y "${MISSING[@]}"
fi
;;
*)
warn "Unsupported distribution: $DISTRO — skipping system packages."
;;
esac
# ---------------------------------------------------------------------------
# Tools not in distro repos — warn if missing
# ---------------------------------------------------------------------------
header "Optional tools (manual install)"
warn_missing() {
local cmd="$1" msg="$2"
if have "$cmd"; then
ok "$cmd"
else
warn "'$cmd' not found — $msg"
fi
}
warn_missing autotiling "install via pip: pip install --user autotiling"
warn_missing eww "install from https://github.com/elkowar/eww/releases"
warn_missing wezterm "install from https://wezfurlong.org/wezterm/install/linux.html"
# ---------------------------------------------------------------------------
# Starship
# ---------------------------------------------------------------------------
header "Starship"
if have starship; then
ok "starship already installed"
else
info "Installing Starship..."
curl -sS https://starship.rs/install.sh | sh
fi
# ---------------------------------------------------------------------------
# npm global packages
# ---------------------------------------------------------------------------
header "npm global packages"
if npm list -g --depth=0 @mariozechner/pi-coding-agent &>/dev/null; then
ok "@mariozechner/pi-coding-agent already installed"
else
info "Installing @mariozechner/pi-coding-agent..."
npm install -g @mariozechner/pi-coding-agent
fi
# ---------------------------------------------------------------------------
# pi extension dependencies
# ---------------------------------------------------------------------------
header "pi extension node_modules"
for ext_dir in "$DOTFILES_DIR"/pi/.pi/agent/extensions/*/; do
[[ -f "$ext_dir/package.json" ]] || continue
ext_name="$(basename "$ext_dir")"
if [[ -d "$ext_dir/node_modules" ]]; then
ok "$ext_name (node_modules already present)"
else
info "Running npm install for $ext_name..."
npm install --prefix "$ext_dir" --ignore-scripts
fi
done
# ---------------------------------------------------------------------------
# Fonts
# ---------------------------------------------------------------------------
header "Fonts"
FONT_DIR="$HOME/.local/share/fonts/JetBrainsMono"
if [[ -d "$FONT_DIR" ]]; then
ok "JetBrains Mono Nerd Font already installed"
else
info "Installing JetBrains Mono Nerd Font..."
TMP="$(mktemp -d)"
curl -fLo "$TMP/JetBrainsMono.zip" \
"https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip"
mkdir -p "$FONT_DIR"
unzip -q "$TMP/JetBrainsMono.zip" -d "$FONT_DIR"
rm -rf "$TMP"
fc-cache -f "$FONT_DIR"
ok "Font installed"
fi
# ---------------------------------------------------------------------------
echo ""
echo "All dependencies done. Run ./install-stow.sh to link configs."

149
install-stow.sh Executable file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# install-stow.sh: Stow dotfile packages into $HOME with per-package prompts.
# Packages that are already fully stowed are skipped automatically.
# Run after install-deps.sh.
set -euo pipefail
DOTFILES_DIR="$(cd "$(dirname "$0")" && pwd)"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ok() { echo "$*"; }
info() { echo "$*"; }
warn() { echo "$*"; }
header() { echo ""; echo "==> $*"; }
# Ask yes/no. Returns 0 for yes, 1 for no.
ask_yn() {
local prompt="$1"
while true; do
read -r -p "$prompt [y/N] " answer
case "${answer,,}" in
y|yes) return 0 ;;
n|no|"") return 1 ;;
*) echo " Please answer y or n." ;;
esac
done
}
# ---------------------------------------------------------------------------
# Check if a package is already fully stowed
# (every file in the package has a correct symlink in $HOME)
# ---------------------------------------------------------------------------
is_stowed() {
local pkg="$1"
local pkg_dir="$DOTFILES_DIR/$pkg"
local all_ok=true
while IFS= read -r -d '' src; do
local rel="${src#$pkg_dir/}"
local target="$HOME/$rel"
# Must exist as a symlink pointing at this exact source
if [[ ! -L "$target" ]] || [[ "$(readlink -f "$target")" != "$(readlink -f "$src")" ]]; then
all_ok=false
break
fi
done < <(find "$pkg_dir" -type f -print0 2>/dev/null)
$all_ok
}
# List any real files (non-symlinks) that would be overwritten by stowing
conflicts_for() {
local pkg="$1"
local pkg_dir="$DOTFILES_DIR/$pkg"
local conflicts=()
while IFS= read -r -d '' src; do
local rel="${src#$pkg_dir/}"
local target="$HOME/$rel"
if [[ -e "$target" ]] && [[ ! -L "$target" ]]; then
conflicts+=("$target")
fi
done < <(find "$pkg_dir" -type f -print0 2>/dev/null)
printf '%s\n' "${conflicts[@]}"
}
# ---------------------------------------------------------------------------
# Collect packages (top-level dirs, skip hidden)
# ---------------------------------------------------------------------------
header "Stowing dotfile packages"
PACKAGES=()
for d in "$DOTFILES_DIR"/*/; do
pkg="$(basename "$d")"
[[ "$pkg" == .* ]] && continue
PACKAGES+=("$pkg")
done
STOWED=()
SKIPPED=()
for pkg in "${PACKAGES[@]}"; do
echo ""
echo "Package: $pkg"
if is_stowed "$pkg"; then
ok "already stowed — skipping"
STOWED+=("$pkg (already)")
continue
fi
# Show conflicts, if any
mapfile -t conflicts < <(conflicts_for "$pkg")
if [[ ${#conflicts[@]} -gt 0 ]]; then
warn "Stowing would overwrite these real files:"
for f in "${conflicts[@]}"; do
echo " $f"
done
echo " Back them up or delete them first, then re-run."
fi
if ask_yn " Stow '$pkg'?"; then
stow --dir="$DOTFILES_DIR" --target="$HOME" --restow "$pkg"
ok "stowed"
STOWED+=("$pkg")
else
info "skipped"
SKIPPED+=("$pkg")
fi
done
# ---------------------------------------------------------------------------
# Configure shell (after stowing zshrc)
# ---------------------------------------------------------------------------
header "Shell configuration"
if [[ ! -f "$HOME/.zshrc" ]]; then
touch "$HOME/.zshrc"
fi
if ! grep -q 'eval "$(starship init zsh)"' "$HOME/.zshrc"; then
echo 'eval "$(starship init zsh)"' >> "$HOME/.zshrc"
ok "Added starship init to ~/.zshrc"
else
ok "Starship already configured in ~/.zshrc"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
header "Summary"
if [[ ${#STOWED[@]} -gt 0 ]]; then
echo " Stowed: ${STOWED[*]}"
fi
if [[ ${#SKIPPED[@]} -gt 0 ]]; then
echo " Skipped: ${SKIPPED[*]}"
fi
echo ""
echo "Done."

View File

@@ -1,246 +0,0 @@
#!/usr/bin/env bash
# install.sh: Install dependencies and stow all packages.
# Run this after cloning on a new machine.
set -euo pipefail
DOTFILES_DIR="$(cd "$(dirname "$0")" && pwd)"
# ---------------------------------------------------------------------------
# Detect Linux distribution
# ---------------------------------------------------------------------------
detect_distro() {
if [[ -f /etc/os-release ]]; then
. /etc/os-release
echo "$ID"
else
echo "unknown"
fi
}
DISTRO=$(detect_distro)
# ---------------------------------------------------------------------------
# Dependencies
# ---------------------------------------------------------------------------
case "$DISTRO" in
endeavouros)
# EndeavourOS is Arch-based, use pacman
PACMAN_PACKAGES=(
stow
# sway ecosystem
swayidle
# bar & notifications
mako
libnotify # notify-send
# terminal & launcher
fuzzel
# audio & display
wireplumber # wpctl
pipewire-pulse # pactl
brightnessctl
alsa-utils # speaker-test
# screen recording
wf-recorder
slurp
zenity
ffmpeg
# misc
jq
firefox
)
echo "Installing packages for EndeavourOS..."
sudo pacman -S --noconfirm "${PACMAN_PACKAGES[@]}"
;;
fedora)
DNF_PACKAGES=(
stow
# sway ecosystem
swayidle
# bar & notifications
mako
libnotify # notify-send
# terminal & launcher
fuzzel
# audio & display
wireplumber # wpctl
pipewire-utils # pactl
brightnessctl
alsa-utils # speaker-test
# screen recording
wf-recorder
slurp
zenity
ffmpeg # needs RPM Fusion: https://rpmfusion.org
# misc
jq
firefox
)
echo "Installing packages for Fedora..."
sudo dnf install -y "${DNF_PACKAGES[@]}"
;;
arch)
PACMAN_PACKAGES=(
stow
# sway ecosystem
swayidle
# bar & notifications
mako
libnotify # notify-send
# terminal & launcher
fuzzel
# audio & display
wireplumber # wpctl
pipewire-pulse # pactl
brightnessctl
alsa-utils # speaker-test
# screen recording
wf-recorder
slurp
zenity
ffmpeg
# misc
jq
firefox
)
echo "Installing packages for Arch Linux..."
sudo pacman -S --noconfirm "${PACMAN_PACKAGES[@]}"
;;
*)
echo "ERROR: Unsupported distribution: $DISTRO"
echo "Supported distributions: fedora, arch, endeavouros"
exit 1
;;
esac
# ---------------------------------------------------------------------------
# Packages not in Fedora repos - install manually if missing
# ---------------------------------------------------------------------------
warn_missing() {
local cmd="$1" msg="$2"
if ! command -v "$cmd" &>/dev/null; then
echo "WARNING: '$cmd' not found - $msg"
fi
}
warn_missing autotiling "install via pip: pip install --user autotiling"
warn_missing eww "install from https://github.com/elkowar/eww/releases"
warn_missing wezterm "install from https://wezfurlong.org/wezterm/install/linux.html"
# ---------------------------------------------------------------------------
# Starship
# ---------------------------------------------------------------------------
if ! command -v starship &>/dev/null; then
echo ""
echo "Installing Starship..."
curl -sS https://starship.rs/install.sh | sh
else
echo ""
echo "Starship already installed, skipping."
fi
# ---------------------------------------------------------------------------
# NPM packages
# ---------------------------------------------------------------------------
echo ""
echo "Installing npm packages..."
npm install -g @mariozechner/pi-coding-agent
# ---------------------------------------------------------------------------
# Fonts
# ---------------------------------------------------------------------------
FONT_DIR="$HOME/.local/share/fonts/JetBrainsMono"
if [[ ! -d "$FONT_DIR" ]]; then
echo ""
echo "Installing JetBrains Mono Nerd Font..."
TMP="$(mktemp -d)"
curl -fLo "$TMP/JetBrainsMono.zip" \
"https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip"
mkdir -p "$FONT_DIR"
unzip -q "$TMP/JetBrainsMono.zip" -d "$FONT_DIR"
rm -rf "$TMP"
fc-cache -f "$FONT_DIR"
echo "Font installed."
else
echo "JetBrains Mono Nerd Font already installed, skipping."
fi
# ---------------------------------------------------------------------------
# Stow all packages
# ---------------------------------------------------------------------------
echo ""
echo "Stowing dotfiles..."
# Check for conflicts before stowing
CONFLICTS=()
for PACKAGE in "$DOTFILES_DIR"/*/; do
PACKAGE="$(basename "$PACKAGE")"
# Skip hidden directories and .git
[[ "$PACKAGE" == .* ]] && continue
# Check if package has files that would conflict
while IFS= read -r -d '' dotfile; do
# Get the target path this would create
TARGET_PATH="${HOME}/${dotfile#$DOTFILES_DIR/$PACKAGE/}"
# If target exists and is NOT a symlink to the correct location, it's a conflict
if [[ -e "$TARGET_PATH" ]] && [[ ! -L "$TARGET_PATH" ]]; then
CONFLICTS+=("$PACKAGE (file: $TARGET_PATH)")
fi
done < <(find "$DOTFILES_DIR/$PACKAGE" -type f -print0 2>/dev/null || true)
done
if [[ ${#CONFLICTS[@]} -gt 0 ]]; then
echo "WARNING: Found conflicts with existing configs:"
for conflict in "${CONFLICTS[@]}"; do
echo "$conflict"
done
echo ""
fi
# Stow all packages
for PACKAGE in "$DOTFILES_DIR"/*/; do
PACKAGE="$(basename "$PACKAGE")"
# Skip hidden directories
[[ "$PACKAGE" == .* ]] && continue
echo " $PACKAGE"
stow --dir="$DOTFILES_DIR" --target="$HOME" --restow "$PACKAGE"
done
# ---------------------------------------------------------------------------
# Configure shell (after stowing)
# ---------------------------------------------------------------------------
echo ""
echo "Configuring zsh..."
# Ensure .zshrc exists (stow should have created it via symlink)
if [[ ! -f "$HOME/.zshrc" ]]; then
touch "$HOME/.zshrc"
fi
# Ensure starship is initialized in .zshrc
if ! grep -q 'eval "$(starship init zsh)"' "$HOME/.zshrc"; then
echo 'eval "$(starship init zsh)"' >> "$HOME/.zshrc"
echo " Added starship initialization to ~/.zshrc"
else
echo " Starship already configured in ~/.zshrc"
fi
echo "Done."

View File

@@ -7,6 +7,7 @@
"fzf-lua": { "branch": "main", "commit": "fb8c50ba62a0daa433b7ac2b78834f318322b879" },
"gitsigns.nvim": { "branch": "main", "commit": "31217271a7314c343606acb4072a94a039a19fb5" },
"lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
"lazygit.nvim": { "branch": "main", "commit": "a04ad0dbc725134edbee3a5eea29290976695357" },
"leap.nvim": { "branch": "main", "commit": "9a26da7a14c09cd84c05a4e8326890ef0f92a590" },
"lualine.nvim": { "branch": "master", "commit": "47f91c416daef12db467145e16bed5bbfe00add8" },
"mason-lspconfig.nvim": { "branch": "main", "commit": "ae609525ddf01c153c39305730b1791800ffe4fe" },

View File

@@ -32,6 +32,25 @@ return {
end,
},
-- LazyGit - Git UI
{
"kdheepak/lazygit.nvim",
lazy = true,
cmd = {
"LazyGit",
"LazyGitConfig",
"LazyGitCurrentFile",
"LazyGitFilter",
"LazyGitFilterCurrentFile",
},
dependencies = {
"nvim-lua/plenary.nvim",
},
keys = {
{ "<leader>lg", "<cmd>LazyGit<cr>", desc = "LazyGit" }
}
},
-- Fuzzy finder
{
"ibhagwan/fzf-lua",

View File

@@ -1,45 +1,20 @@
---
name: explorer
description: Deep codebase and knowledge-base explorer using Claude Haiku with semantic search (QMD) and HDC-indexed context retrieval (opty). Use for thorough exploration, cross-cutting queries across docs and code, or when the local scout's Qwen model isn't cutting it.
description: Comprehensive codebase and knowledge-base explorer. Maps architecture, traces dependencies, synthesizes cross-cutting context with full code snippets and rationale. Use for deep refactoring, architectural decisions, or understanding complex subsystems. Do NOT use when the user has already provided explicit file paths or when a direct file read would suffice — only invoke for open-ended exploration where the relevant files are unknown.
tools: read, bash, write, mcp:qmd, mcp:opty
model: anthropic/claude-haiku-4-5
output: context.md
defaultProgress: true
---
You are an explorer. Thoroughly investigate a codebase or knowledge base and return structured, actionable findings.
You are an explorer. Thoroughly investigate a codebase or knowledge base and synthesize your findings into a comprehensive document.
Prefer semantic tools first:
1. Use qmd_query / qmd_get / qmd_multi_get for semantic and hybrid search of indexed docs and code
2. Use opty MCP tools for HDC-indexed context retrieval
3. Fall back to bash (grep/find) only when semantic tools don't surface what you need
4. Read key sections of files — not entire files unless necessary
**CRITICAL**: Use the `write` tool to save your complete findings to `/home/jonas/.pi/context.md`. This must be a full document with:
- Architecture overview and structure
- Complete file contents (not summaries)
- Dependency chains and relationships
- Key patterns and design decisions
- ASCII diagrams where helpful
Thoroughness (infer from task, default thorough):
- Quick: targeted lookups, answer from search results alone
- Medium: follow the most important cross-references, read critical sections
- Thorough: trace all dependencies, check related files, synthesize a full picture
Be thorough and comprehensive — include all relevant code snippets and context needed to understand the codebase.
Your output format (context.md):
# Exploration Context
## Query
What was explored and why.
## Files & Docs Retrieved
List with exact line ranges or doc IDs:
1. `path/to/file.ts` (lines 10-50) — Description
2. `#docid` — Description
## Key Findings
Critical types, interfaces, functions, or facts with actual snippets.
## Architecture / Structure
How the pieces connect; data flow; key abstractions.
## Gaps & Unknowns
What couldn't be determined and why.
## Start Here
Which file or doc to look at first and why.
After writing to the file, confirm completion with: "Context saved to: /home/jonas/.pi/context.md"

View File

@@ -1 +0,0 @@
work

View File

@@ -1,9 +1,16 @@
/**
* Claude Account Switch Extension
*
* Switches between two Claude Pro accounts (personal and work).
* Tokens are saved from pi's own OAuth sessions (not Claude CLI),
* so token refresh works correctly.
* Switches between two Claude Pro accounts (personal and work) **without
* restarting pi**. Works by swapping auth.json files at the filesystem level,
* then reloading the auth storage and forcing an immediate token refresh to
* validate the switch.
*
* Why file-level swaps? Anthropic's OAuth rotates refresh tokens on every
* refresh. Calling authStorage.set() can appear to work, but the next
* getApiKey() call triggers refreshOAuthTokenWithLock(), which re-reads
* auth.json from disk — overwriting in-memory changes if persistence
* silently failed. Working at the file level avoids this entirely.
*
* Setup (one-time per account):
* 1. /login → authenticate with personal account
@@ -20,15 +27,17 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { execSync } from "node:child_process";
const HOME = os.homedir();
const AUTH_FILE = path.join(HOME, ".pi/agent/auth.json");
const AGENT_DIR = path.join(HOME, ".pi/agent");
const AUTH_JSON = path.join(HOME, ".pi/agent/auth.json");
const MARKER_FILE = path.join(HOME, ".pi/agent/auth.json.current");
const PROFILES_DIR = path.join(HOME, ".pi/agent/profiles");
type Account = "personal" | "work";
// ── Profile helpers ────────────────────────────────────────────────────────
function profilePath(account: Account): string {
return path.join(PROFILES_DIR, `auth-${account}.json`);
}
@@ -37,17 +46,30 @@ function hasProfile(account: Account): boolean {
return fs.existsSync(profilePath(account));
}
function saveProfile(account: Account): void {
/**
* Save auth.json content directly to a profile file.
* This captures the exact on-disk state, including any tokens that were
* refreshed behind our back by the auth system.
*/
function saveCurrentAuthToProfile(account: Account): void {
fs.mkdirSync(PROFILES_DIR, { recursive: true });
fs.copyFileSync(AUTH_FILE, profilePath(account));
if (fs.existsSync(AUTH_JSON)) {
fs.copyFileSync(AUTH_JSON, profilePath(account));
fs.chmodSync(profilePath(account), 0o600);
fs.writeFileSync(MARKER_FILE, account);
}
}
function loadProfile(account: Account): void {
fs.copyFileSync(profilePath(account), AUTH_FILE);
fs.chmodSync(AUTH_FILE, 0o600);
fs.writeFileSync(MARKER_FILE, account);
/**
* Copy a profile file to auth.json. This is an atomic-ish swap that
* replaces the entire file rather than merging per-provider.
*/
function restoreProfileToAuth(account: Account): void {
fs.copyFileSync(profilePath(account), AUTH_JSON);
fs.chmodSync(AUTH_JSON, 0o600);
}
function setMarker(account: Account): void {
fs.writeFileSync(MARKER_FILE, account, "utf-8");
}
function getCurrentAccount(): Account | "unknown" {
@@ -58,132 +80,240 @@ function getCurrentAccount(): Account | "unknown" {
return "unknown";
}
// ── Other session detection ────────────────────────────────────────────────
function otherPiSessions(): number[] {
try {
const myPid = process.pid;
// Use a character class [c] trick so pgrep doesn't match its own process
const out = execSync("pgrep -f 'pi-[c]oding-agent' 2>/dev/null || true", {
encoding: "utf-8",
});
const pids = out
.trim()
.split("\n")
.map(Number)
.filter((p) => p && p !== myPid && !isNaN(p));
return pids;
} catch {
return [];
}
}
function killOtherSessions(pids: number[]): number {
let killed = 0;
for (const pid of pids) {
try {
process.kill(pid);
killed++;
} catch {
// already dead or permission denied
}
}
return killed;
}
// ── UI helpers ─────────────────────────────────────────────────────────────
function statusLabel(account: Account | "unknown"): string {
switch (account) {
case "personal": return "🏠 personal";
case "work": return "💼 work";
default: return "❓ claude";
case "personal":
return " personal";
case "work":
return "󰃖 work";
default:
return " claude";
}
}
/**
* Sync auth.json into the active profile file whenever pi rotates tokens.
*
* Pi writes fresh OAuth tokens to auth.json (via direct write or atomic
* rename). Without this watcher the profile file would keep the original
* snapshot token, which Anthropic invalidates after its first use as a
* refresh-token rotation. The next /switch-claude would restore the dead
* refresh token and force a full re-login.
*
* We watch the agent directory (more reliable than watching the file
* directly across atomic renames) and copy auth.json → active profile
* on every change, debounced to avoid duplicate writes.
*/
function startProfileSyncer(getAccount: () => Account | "unknown"): fs.FSWatcher | null {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const syncNow = () => {
const account = getAccount();
if (account === "unknown") return;
if (!fs.existsSync(AUTH_FILE)) return;
try {
const content = fs.readFileSync(AUTH_FILE, "utf8");
JSON.parse(content); // only sync valid JSON
const dest = profilePath(account);
fs.mkdirSync(PROFILES_DIR, { recursive: true });
fs.writeFileSync(dest, content, "utf8");
fs.chmodSync(dest, 0o600);
} catch {
// ignore transient read errors (file mid-write, etc.)
}
};
try {
return fs.watch(AGENT_DIR, { persistent: false }, (_event, filename) => {
if (filename !== "auth.json") return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(syncNow, 200);
});
} catch {
return null;
}
}
// ── Extension ──────────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
let currentAccount: Account | "unknown" = "unknown";
let syncer: fs.FSWatcher | null = null;
pi.on("session_start", async (_event, ctx) => {
// Proper-lockfile creates auth.json.lock as a *directory* (atomic mkdir).
// If a regular file exists at that path (e.g. left by an older pi version),
// rmdir fails with ENOTDIR → lock acquisition throws → loadError is set →
// credentials are never persisted after /login. Delete the stale file and
// reload so this session has working auth persistence.
const lockPath = AUTH_JSON + ".lock";
try {
const stat = fs.statSync(lockPath);
if (stat.isFile()) {
fs.unlinkSync(lockPath);
ctx.modelRegistry.authStorage.reload();
}
} catch {
// lock doesn't exist or we can't stat it — nothing to fix
}
currentAccount = getCurrentAccount();
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
// Start watching auth.json so token refreshes are mirrored into
// the active profile file. Only one watcher is needed per session.
if (!syncer) {
syncer = startProfileSyncer(() => currentAccount);
}
});
pi.on("session_shutdown", async () => {
if (syncer) {
syncer.close();
syncer = null;
}
});
pi.registerCommand("switch-claude", {
description: "Switch between personal (🏠) and work (💼) Claude accounts. Use 'save <name>' to save current login as a profile.",
description:
"Switch between personal () and work (󰃖) Claude accounts. Use 'save <name>' to save current login as a profile.",
handler: async (args, ctx) => {
const authStorage = ctx.modelRegistry.authStorage;
const trimmed = args?.trim() ?? "";
// Save current auth.json as a named profile
// ── Save current auth state as a named profile ──────────────────
if (trimmed.startsWith("save ")) {
const name = trimmed.slice(5).trim();
if (name !== "personal" && name !== "work") {
ctx.ui.notify("Usage: /switch-claude save personal|work", "warning");
ctx.ui.notify(
"Usage: /switch-claude save personal|work",
"warning",
);
return;
}
saveProfile(name as Account);
if (!authStorage.has("anthropic")) {
ctx.ui.notify(
"No Anthropic credentials found. Run /login first.",
"warning",
);
return;
}
saveCurrentAuthToProfile(name as Account);
currentAccount = name as Account;
setMarker(currentAccount);
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
ctx.ui.notify(`Saved current login as ${statusLabel(name as Account)} profile`, "info");
ctx.ui.notify(
`Saved current login as ${statusLabel(name as Account)} profile`,
"info",
);
return;
}
// Switch between profiles
const personalLabel = `🏠 personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}`;
const workLabel = `💼 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}`;
// ── Resolve target account (direct arg or interactive) ──────────
let newAccount: Account;
if (trimmed === "personal" || trimmed === "work") {
newAccount = trimmed;
} else if (trimmed === "") {
const personalLabel = ` personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}`;
const workLabel = `󰃖 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}`;
const choice = await ctx.ui.select("Switch Claude account:", [personalLabel, workLabel]);
if (choice === undefined) return;
const newAccount: Account = choice.startsWith("🏠") ? "personal" : "work";
const accountChoice = await ctx.ui.select(
"Switch Claude account:",
[personalLabel, workLabel],
);
if (accountChoice === undefined) return;
newAccount = accountChoice.startsWith("") ? "personal" : "work";
} else {
ctx.ui.notify(
"Usage: /switch-claude [personal|work|save <name>]",
"warning",
);
return;
}
if (newAccount === currentAccount) {
ctx.ui.notify(`Already using ${statusLabel(newAccount)}`, "info");
ctx.ui.notify(
`Already using ${statusLabel(newAccount)}`,
"info",
);
return;
}
// ── Warn about other sessions ───────────────────────────────────
const otherPids = otherPiSessions();
if (otherPids.length > 0) {
const sessionChoice = await ctx.ui.select(
`⚠️ ${otherPids.length} other pi session(s) detected`,
[
"Continue anyway",
`Kill ${otherPids.length} other instance(s) and continue`,
"Cancel",
],
);
if (sessionChoice === undefined || sessionChoice.includes("Cancel"))
return;
if (sessionChoice.includes("Kill")) {
const killed = killOtherSessions(otherPids);
ctx.ui.notify(`Killed ${killed} pi session(s)`, "info");
}
}
if (!hasProfile(newAccount)) {
ctx.ui.notify(
`No profile saved for ${newAccount}. Run /login then /switch-claude save ${newAccount}`,
"warning"
"warning",
);
return;
}
// ── Perform the switch ──────────────────────────────────────────
try {
// Persist any token refreshes pi made since the last save so
// we don't restore a stale refresh token when we come back.
if (currentAccount !== "unknown") saveProfile(currentAccount);
loadProfile(newAccount);
// 1. Snapshot current auth.json → outgoing profile.
// This captures any tokens that were silently refreshed
// since the last save (the file is the source of truth,
// not the in-memory snapshot from getAll()).
if (currentAccount !== "unknown") {
saveCurrentAuthToProfile(currentAccount);
}
// 2. Copy incoming profile → auth.json (full file replace).
restoreProfileToAuth(newAccount);
// 3. Tell AuthStorage to re-read the file. This updates
// the in-memory credential cache from the new auth.json.
authStorage.reload();
// 4. Force an immediate token refresh to validate the switch.
// If the stored refresh token is stale, this will fail now
// rather than on the next chat message.
const apiKey = await authStorage.getApiKey("anthropic");
if (!apiKey) {
// Refresh failed → roll back to the previous account.
if (currentAccount !== "unknown") {
restoreProfileToAuth(currentAccount);
authStorage.reload();
}
ctx.ui.notify(
`❌ Switch failed: could not authenticate as ${newAccount}. ` +
`The saved refresh token may have expired. ` +
`Run /login then /switch-claude save ${newAccount} to re-save.`,
"error",
);
return;
}
// 5. Success — the refresh worked, auth.json now has fresh
// tokens. Save them back to the profile so next switch
// has the latest refresh token.
saveCurrentAuthToProfile(newAccount);
currentAccount = newAccount;
setMarker(currentAccount);
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
pi.events.emit("claude-account:switched", { account: newAccount });
ctx.ui.notify(`Switched to ${statusLabel(newAccount)} — restart pi to apply`, "info");
ctx.ui.notify(
`Switched to ${statusLabel(newAccount)}`,
"info",
);
} catch (e: unknown) {
// Something went wrong → try to roll back.
try {
if (currentAccount !== "unknown" && hasProfile(currentAccount)) {
restoreProfileToAuth(currentAccount);
authStorage.reload();
}
} catch {
// rollback failed too — nothing more we can do
}
const msg = e instanceof Error ? e.message : String(e);
ctx.ui.notify(`Failed to switch: ${msg}`, "error");
ctx.ui.notify(
`❌ Switch failed: ${msg}. Rolled back to ${statusLabel(currentAccount)}. ` +
`You may need to /login and /switch-claude save ${newAccount}.`,
"error",
);
}
},
});

View File

@@ -0,0 +1,191 @@
/**
* Footer Display Extension
*
* Replaces the built-in pi footer with a single clean line that assembles
* status from all other extensions:
*
* \uEF85 | S ⣿⣶⣀⣀⣀ 34% 2h 55m | W ⣿⣿⣷⣀⣀ 68% ⟳ Fri 09:00 | C ⣿⣀⣀⣀⣀ 20% | Sonnet 4.6 | rust-analyzer | MCP: 1/2
*
* Status sources:
* "claude-account" — set by claude-account-switch.ts → just the icon
* "usage-bars" — set by usage-bars extension → S/W bars (pass-through)
* ctx.getContextUsage() → C bar (rendered here)
* ctx.model → model short name
* "lsp" — set by lsp-pi extension → strip "LSP " prefix
* "mcp" — set by pi-mcp-adapter → strip " servers" suffix
*/
import os from "os";
import path from "path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { truncateToWidth } from "@mariozechner/pi-tui";
// ---------------------------------------------------------------------------
// Braille gradient bar — used here only for the context (C) bar
// ---------------------------------------------------------------------------
const BRAILLE_GRADIENT = "\u28C0\u28C4\u28E4\u28E6\u28F6\u28F7\u28FF";
const BRAILLE_EMPTY = "\u28C0";
const BAR_WIDTH = 5;
function renderBrailleBar(theme: any, value: number): string {
const v = Math.max(0, Math.min(100, Math.round(value)));
const levels = BRAILLE_GRADIENT.length - 1;
const totalSteps = BAR_WIDTH * levels;
const filledSteps = Math.round((v / 100) * totalSteps);
const full = Math.floor(filledSteps / levels);
const partial = filledSteps % levels;
const empty = BAR_WIDTH - full - (partial ? 1 : 0);
const color = v >= 90 ? "error" : v >= 70 ? "warning" : "success";
const filled = BRAILLE_GRADIENT[BRAILLE_GRADIENT.length - 1]!.repeat(Math.max(0, full));
const partialChar = partial ? BRAILLE_GRADIENT[partial]! : "";
const emptyChars = BRAILLE_EMPTY.repeat(Math.max(0, empty));
return theme.fg(color, filled + partialChar) + theme.fg("dim", emptyChars);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
}
function getModelShortName(modelId: string): string {
// claude-haiku-4-5 → "Haiku 4.5", claude-sonnet-4-6 → "Sonnet 4.6"
const m = modelId.match(/^claude-([a-z]+)-([\d]+(?:-[\d]+)*)(?:-\d{8})?$/);
if (m) {
const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1);
return `${family} ${m[2]!.replace(/-/g, ".")}`;
}
// claude-3-5-sonnet, claude-3-opus, etc.
const m2 = modelId.match(/^claude-[\d-]+-([a-z]+)/);
if (m2) return m2[1]!.charAt(0).toUpperCase() + m2[1]!.slice(1);
return modelId.replace(/^claude-/, "");
}
// Nerd Font codepoints matched to what claude-account-switch.ts emits
const ICON_PERSONAL = "\uEF85"; // U+EF85 — home
const ICON_WORK = "\uDB80\uDCD6"; // U+F00D6 — briefcase (surrogate pair)
const ICON_UNKNOWN = "\uF420"; // U+F420 — claude default
export default function (pi: ExtensionAPI) {
let ctx: any = null;
let tuiRef: any = null;
let footerDataRef: any = null;
// ---------------------------------------------------------------------------
// Footer line builder — called on every render
// ---------------------------------------------------------------------------
function buildFooterLine(theme: any): string {
const sep = theme.fg("dim", " · ");
const pipeSep = theme.fg("dim", " | ");
const parts: string[] = [];
const statuses: ReadonlyMap<string, string> =
footerDataRef?.getExtensionStatuses?.() ?? new Map();
// 1. Current working directory
const home = os.homedir();
const cwd = process.cwd();
const dir = cwd.startsWith(home)
? "~" + path.sep + path.relative(home, cwd)
: cwd;
parts.push(theme.fg("dim", dir));
// 2. Account icon
const acctRaw = statuses.get("claude-account");
if (acctRaw !== undefined) {
const clean = stripAnsi(acctRaw).trim();
let icon: string;
if (clean.includes("personal")) icon = ICON_PERSONAL;
else if (clean.includes("work")) icon = ICON_WORK;
else icon = ICON_UNKNOWN;
parts.push(theme.fg("dim", icon));
}
// 3. S / W usage bars + C bar — joined as one |-separated block
const usageRaw = statuses.get("usage-bars");
const contextUsage = ctx?.getContextUsage?.();
{
let block = usageRaw ?? "";
if (contextUsage && contextUsage.percent !== null) {
const pct = Math.round(contextUsage.percent);
const cBar =
theme.fg("muted", "C ") +
renderBrailleBar(theme, pct) +
" " +
theme.fg("dim", `${pct}%`);
block = block ? block + pipeSep + cBar : cBar;
}
if (block) parts.push(block);
}
// 4. Model short name
const modelId = ctx?.model?.id;
if (modelId) parts.push(theme.fg("dim", getModelShortName(modelId)));
// 5. LSP — strip "LSP" prefix and activity dot
const lspRaw = statuses.get("lsp");
if (lspRaw) {
const clean = stripAnsi(lspRaw).trim().replace(/^LSP\s*[•·]?\s*/i, "").trim();
if (clean) parts.push(theme.fg("dim", clean));
}
// 6. MCP — strip " servers" suffix
const mcpRaw = statuses.get("mcp");
if (mcpRaw) {
const clean = stripAnsi(mcpRaw).trim().replace(/\s*servers?.*$/, "").trim();
if (clean) parts.push(theme.fg("dim", clean));
}
return parts.join(sep);
}
// ---------------------------------------------------------------------------
// Footer installation
// ---------------------------------------------------------------------------
function installFooter(_ctx: any) {
if (!_ctx?.hasUI) return;
_ctx.ui.setFooter((_tui: any, theme: any, footerData: any) => {
tuiRef = _tui;
footerDataRef = footerData;
const unsub = footerData.onBranchChange(() => _tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
return [truncateToWidth(buildFooterLine(theme) || "", width)];
},
};
});
}
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
pi.on("session_start", async (_event, _ctx) => {
ctx = _ctx;
installFooter(_ctx);
});
pi.on("session_shutdown", (_event, _ctx) => {
if (_ctx?.hasUI) _ctx.ui.setFooter(undefined);
});
// Re-render after turns so context usage stays current
pi.on("turn_end", (_event, _ctx) => {
ctx = _ctx;
if (tuiRef) tuiRef.requestRender();
});
// Re-render when model changes (updates model name in footer)
pi.on("model_select", (_event, _ctx) => {
ctx = _ctx;
if (tuiRef) tuiRef.requestRender();
});
// Re-render when account switches (usage:update comes from usage-bars setStatus which
// already triggers a render, but account icon needs a nudge too)
pi.events.on("claude-account:switched", () => {
if (tuiRef) tuiRef.requestRender();
});
}

View File

@@ -0,0 +1,99 @@
/**
* Git Checkout Guard Extension
*
* Prevents models from using `git checkout` or `git restore` to silently
* discard uncommitted changes in files.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
import { execSync } from "child_process";
/**
* Parse file paths from a git checkout/restore command.
* Returns null if the command doesn't look like a file-restore operation.
*/
function parseFileRestoreArgs(command: string): string[] | null {
// Normalize whitespace
const cmd = command.trim().replace(/\s+/g, " ");
// Match: git checkout -- <files>
// Match: git checkout <ref> -- <files>
const checkoutDashDash = cmd.match(/\bgit\s+checkout\b.*?\s--\s+(.+)/);
if (checkoutDashDash) {
return checkoutDashDash[1].trim().split(/\s+/);
}
// Match: git restore [--staged] [--source=<ref>] <files>
// (git restore always operates on files)
const restore = cmd.match(/\bgit\s+restore\s+(.+)/);
if (restore) {
// Filter out flags like --staged, --source=..., --worktree, --patch
const args = restore[1].trim().split(/\s+/);
const files = args.filter((a) => !a.startsWith("-"));
return files.length > 0 ? files : null;
}
return null;
}
/**
* Check which of the given file paths have uncommitted changes (staged or unstaged).
* Returns the subset that are dirty.
*/
function getDirtyFiles(files: string[], cwd: string): string[] {
const dirty: string[] = [];
for (const file of files) {
try {
// --porcelain output is empty for clean files
const out = execSync(`git status --porcelain -- ${JSON.stringify(file)}`, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (out.length > 0) {
dirty.push(file);
}
} catch {
// Not a git repo or other error — skip
}
}
return dirty;
}
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (!isToolCallEventType("bash", event)) return undefined;
const command: string = event.input.command ?? "";
const files = parseFileRestoreArgs(command);
if (!files || files.length === 0) return undefined;
const cwd = process.cwd();
const dirty = getDirtyFiles(files, cwd);
if (dirty.length === 0) return undefined; // nothing to protect
const fileList = dirty.map((f) => `${f}`).join("\n");
if (!ctx.hasUI) {
return {
block: true,
reason: `git-checkout-guard: the following files have uncommitted changes and cannot be silently reverted:\n${fileList}\nShow the diff to the user and ask for explicit confirmation first.`,
};
}
const choice = await ctx.ui.select(
`⚠️ git-checkout-guard\n\nThe command:\n ${command}\n\nwould discard uncommitted changes in:\n${fileList}\n\nProceed?`,
["No, cancel (show diff instead)", "Yes, discard changes anyway"],
);
if (choice !== "Yes, discard changes anyway") {
return {
block: true,
reason: `Blocked by git-checkout-guard. Run \`git diff ${dirty.join(" ")}\` and review before discarding.`,
};
}
return undefined;
});
}

View File

@@ -1,201 +0,0 @@
/**
* llama-server Schema Sanitization Proxy
*
* llama-server strictly validates JSON Schema and rejects any schema node
* that lacks a `type` field. Some of pi's built-in tools (e.g. `subagent`)
* have complex union-type parameters represented as `{"description": "..."}` with
* no `type`, which causes llama-server to return a 400 error.
*
* This extension starts a tiny local HTTP proxy on port 8081 that:
* 1. Intercepts outgoing OpenAI-compatible API calls
* 2. Walks tool schemas and adds `"type": "string"` to any schema node
* that is missing a type declaration
* 3. Forwards the fixed request to llama-server on port 8080
* 4. Streams the response back transparently
*
* It also overrides the `llama-cpp` provider's baseUrl to point at the proxy,
* so no changes to models.json are needed (beyond what's already there).
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import * as http from "http";
const PROXY_PORT = 8081;
const TARGET_HOST = "127.0.0.1";
const TARGET_PORT = 8080;
// ---------------------------------------------------------------------------
// Schema sanitizer
// ---------------------------------------------------------------------------
/**
* Recursively walk a JSON Schema object and add `"type": "string"` to any
* node that has no `type` and no composition keywords (oneOf/anyOf/allOf/$ref).
* This satisfies llama-server's strict validation without breaking valid nodes.
*/
function sanitizeSchema(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(sanitizeSchema);
const obj = schema as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
result[key] = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, sanitizeSchema(v)]),
);
} else if (key === "items") {
result[key] = sanitizeSchema(value);
} else if (key === "additionalProperties" && value && typeof value === "object") {
result[key] = sanitizeSchema(value);
} else if (
(key === "oneOf" || key === "anyOf" || key === "allOf") &&
Array.isArray(value)
) {
result[key] = value.map(sanitizeSchema);
} else {
result[key] = value;
}
}
// If this schema node has no type and no composition keywords, default to "string"
const hasType = "type" in result;
const hasComposition =
"oneOf" in result || "anyOf" in result || "allOf" in result || "$ref" in result;
const hasEnum = "enum" in result || "const" in result;
if (!hasType && !hasComposition && !hasEnum) {
result["type"] = "string";
}
return result;
}
/**
* Patch the `tools` array in a parsed request body, if present.
*/
function sanitizeRequestBody(body: Record<string, unknown>): Record<string, unknown> {
if (!Array.isArray(body.tools)) return body;
return {
...body,
tools: (body.tools as unknown[]).map((tool) => {
if (!tool || typeof tool !== "object") return tool;
const t = tool as Record<string, unknown>;
if (!t.function || typeof t.function !== "object") return t;
const fn = t.function as Record<string, unknown>;
if (!fn.parameters) return t;
return {
...t,
function: {
...fn,
parameters: sanitizeSchema(fn.parameters),
},
};
}),
};
}
// ---------------------------------------------------------------------------
// Proxy server
// ---------------------------------------------------------------------------
function startProxy(): http.Server {
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf-8");
// Attempt to sanitize schemas in JSON bodies
let forwardBody = rawBody;
const contentType = req.headers["content-type"] ?? "";
if (contentType.includes("application/json") && rawBody.trim().startsWith("{")) {
try {
const parsed = JSON.parse(rawBody) as Record<string, unknown>;
const sanitized = sanitizeRequestBody(parsed);
forwardBody = JSON.stringify(sanitized);
} catch {
// Not valid JSON — send as-is
}
}
const forwardBuffer = Buffer.from(forwardBody, "utf-8");
// Build forwarded headers, updating host and content-length
const forwardHeaders: Record<string, string | string[]> = {};
for (const [k, v] of Object.entries(req.headers)) {
if (k === "host") continue; // rewrite below
if (v !== undefined) forwardHeaders[k] = v as string | string[];
}
forwardHeaders["host"] = `${TARGET_HOST}:${TARGET_PORT}`;
forwardHeaders["content-length"] = String(forwardBuffer.byteLength);
const proxyReq = http.request(
{
host: TARGET_HOST,
port: TARGET_PORT,
path: req.url,
method: req.method,
headers: forwardHeaders,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
proxyRes.pipe(res, { end: true });
},
);
proxyReq.on("error", (err) => {
const msg = `Proxy error forwarding to llama-server: ${err.message}`;
if (!res.headersSent) {
res.writeHead(502, { "content-type": "text/plain" });
}
res.end(msg);
});
proxyReq.write(forwardBuffer);
proxyReq.end();
});
req.on("error", (err) => {
console.error("[llama-proxy] request error:", err);
});
});
server.listen(PROXY_PORT, "127.0.0.1", () => {
// Server is up
});
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.warn(
`[llama-proxy] Port ${PROXY_PORT} already in use — proxy not started. ` +
`If a previous pi session left it running, kill it and reload.`,
);
} else {
console.error("[llama-proxy] Server error:", err);
}
});
return server;
}
// ---------------------------------------------------------------------------
// Extension entry point
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
const server = startProxy();
// Override the llama-cpp provider's baseUrl to route through our proxy.
// models.json model definitions are preserved; only the endpoint changes.
pi.registerProvider("llama-cpp", {
baseUrl: `http://127.0.0.1:${PROXY_PORT}/v1`,
});
pi.on("session_end", async () => {
server.close();
});
}

View File

@@ -0,0 +1,178 @@
# LSP Extension
Language Server Protocol integration for pi-coding-agent.
## Highlights
- **Hook** (`lsp.ts`): Auto-diagnostics (default at agent end; optional per `write`/`edit`)
- **Tool** (`lsp-tool.ts`): On-demand LSP queries (definitions, references, hover, symbols, diagnostics, signatures)
- Manages one LSP server per project root and reuses them across turns
- **Efficient**: Bounded memory usage via LRU cache and idle file cleanup
- Supports TypeScript/JavaScript, Vue, Svelte, Dart/Flutter, Python, Go, Kotlin, Swift, and Rust
## Supported Languages
| Language | Server | Detection |
|----------|--------|-----------|
| TypeScript/JavaScript | `typescript-language-server` | `package.json`, `tsconfig.json` |
| Vue | `vue-language-server` | `package.json`, `vite.config.ts` |
| Svelte | `svelteserver` | `svelte.config.js` |
| Dart/Flutter | `dart language-server` | `pubspec.yaml` |
| Python | `pyright-langserver` | `pyproject.toml`, `requirements.txt` |
| Go | `gopls` | `go.mod` |
| Kotlin | `kotlin-ls` | `settings.gradle(.kts)`, `build.gradle(.kts)`, `pom.xml` |
| Swift | `sourcekit-lsp` | `Package.swift`, Xcode (`*.xcodeproj` / `*.xcworkspace`) |
| Rust | `rust-analyzer` | `Cargo.toml` |
### Known Limitations
**rust-analyzer**: Very slow to initialize (30-60+ seconds) because it compiles the entire Rust project before returning diagnostics. This is a known rust-analyzer behavior, not a bug in this extension. For quick feedback, consider using `cargo check` directly.
## Usage
### Installation
Install the package and enable extensions:
```bash
pi install npm:lsp-pi
pi config
```
Dependencies are installed automatically during `pi install`.
### Prerequisites
Install the language servers you need:
```bash
# TypeScript/JavaScript
npm i -g typescript-language-server typescript
# Vue
npm i -g @vue/language-server
# Svelte
npm i -g svelte-language-server
# Python
npm i -g pyright
# Go (install gopls via go install)
go install golang.org/x/tools/gopls@latest
# Kotlin (kotlin-ls)
brew install JetBrains/utils/kotlin-lsp
# Swift (sourcekit-lsp; macOS)
# Usually available via Xcode / Command Line Tools
xcrun sourcekit-lsp --help
# Rust (install via rustup)
rustup component add rust-analyzer
```
The extension spawns binaries from your PATH.
## How It Works
### Hook (auto-diagnostics)
1. On `session_start`, warms up LSP for detected project type
2. Tracks files touched by `write`/`edit`
3. Default (`agent_end`): at agent end, sends touched files to LSP and posts a diagnostics message
4. Optional (`edit_write`): per `write`/`edit`, appends diagnostics to the tool result
5. Shows notification with diagnostic summary
6. **Memory Management**: Keeps up to 30 files open per LSP server (LRU eviction), automatically closes idle files (> 60s), and shuts down all LSP servers after 2 minutes of post-agent inactivity (servers restart lazily when files are read again).
7. **Robustness**: Reuses cached diagnostics if a server doesn't re-publish them for unchanged files, avoiding false timeouts on re-analysis.
### Tool (on-demand queries)
The `lsp` tool provides these actions:
| Action | Description | Requires |
|--------|-------------|----------|
| `definition` | Jump to definition | `file` + (`line`/`column` or `query`) |
| `references` | Find all references | `file` + (`line`/`column` or `query`) |
| `hover` | Get type/docs info | `file` + (`line`/`column` or `query`) |
| `symbols` | List symbols in file | `file`, optional `query` filter |
| `diagnostics` | Get single file diagnostics | `file`, optional `severity` filter |
| `workspace-diagnostics` | Get diagnostics for multiple files | `files` array, optional `severity` filter |
| `signature` | Get function signature | `file` + (`line`/`column` or `query`) |
| `rename` | Rename symbol across files | `file` + (`line`/`column` or `query`) + `newName` |
| `codeAction` | Get available quick fixes/refactors | `file` + `line`/`column`, optional `endLine`/`endColumn` |
**Query resolution**: For position-based actions, you can provide a `query` (symbol name) instead of `line`/`column`. The tool will find the symbol in the file and use its position.
**Severity filtering**: For `diagnostics` and `workspace-diagnostics` actions, use the `severity` parameter to filter results:
- `all` (default): Show all diagnostics
- `error`: Only errors
- `warning`: Errors and warnings
- `info`: Errors, warnings, and info
- `hint`: All including hints
**Workspace diagnostics**: The `workspace-diagnostics` action analyzes multiple files at once. Pass an array of file paths in the `files` parameter. Each file will be opened, analyzed by the appropriate LSP server, and diagnostics returned. Files are cleaned up after analysis to prevent memory bloat.
```bash
# Find all TypeScript files and check for errors
find src -name "*.ts" -type f | xargs ...
# Example tool call
lsp action=workspace-diagnostics files=["src/index.ts", "src/utils.ts"] severity=error
```
Example questions the LLM can answer using this tool:
- "Where is `handleSessionStart` defined in `lsp-hook.ts`?"
- "Find all references to `getManager`"
- "What type does `getDefinition` return?"
- "List symbols in `lsp-core.ts`"
- "Check all TypeScript files in src/ for errors"
- "Get only errors from `index.ts`"
- "Rename `oldFunction` to `newFunction`"
- "What quick fixes are available at line 10?"
## Settings
Use `/lsp` to configure the auto diagnostics hook:
- Mode: default at agent end; can run after each edit/write or be disabled
- Scope: session-only or global (`~/.pi/agent/settings.json`)
To disable auto diagnostics, choose "Disabled" in `/lsp` or set in `~/.pi/agent/settings.json`:
```json
{
"lsp": {
"hookMode": "disabled"
}
}
```
Other values: `"agent_end"` (default) and `"edit_write"`.
Agent-end mode analyzes files touched during the full agent response (after all tool calls complete) and posts a diagnostics message only once. Disabling the hook does not disable the `/lsp` tool.
## File Structure
| File | Purpose |
|------|---------|
| `lsp.ts` | Hook extension (auto-diagnostics; default at agent end) |
| `lsp-tool.ts` | Tool extension (on-demand LSP queries) |
| `lsp-core.ts` | LSPManager class, server configs, singleton manager |
| `package.json` | Declares both extensions via "pi" field |
## Testing
```bash
# Unit tests (root detection, configuration)
npm test
# Tool tests
npm run test:tool
# Integration tests (spawns real language servers)
npm run test:integration
# Run rust-analyzer tests (slow, disabled by default)
RUST_LSP_TEST=1 npm run test:integration
```
## License
MIT

View File

@@ -0,0 +1,12 @@
/**
* Combined lsp-pi extension entry point.
* Loads both the hook extension (lsp.ts) and the tool extension (lsp-tool.ts).
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import lspHook from "./lsp.js";
import lspTool from "./lsp-tool.js";
export default function (pi: ExtensionAPI) {
lspHook(pi);
lspTool(pi);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,382 @@
/**
* LSP Tool Extension for pi-coding-agent
*
* Provides Language Server Protocol tool for:
* - definitions, references, hover, signature help
* - document symbols, diagnostics, workspace diagnostics
* - rename, code actions
*
* Supported languages:
* - Dart/Flutter (dart language-server)
* - TypeScript/JavaScript (typescript-language-server)
* - Vue (vue-language-server)
* - Svelte (svelteserver)
* - Python (pyright-langserver)
* - Go (gopls)
* - Kotlin (kotlin-ls)
* - Swift (sourcekit-lsp)
* - Rust (rust-analyzer)
*
* Usage:
* pi --extension ./lsp-tool.ts
*
* Or use the combined lsp.ts extension for both hook and tool functionality.
*/
import * as path from "node:path";
import { Type, type Static } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { getOrCreateManager, formatDiagnostic, filterDiagnosticsBySeverity, uriToPath, resolvePosition, type SeverityFilter } from "./lsp-core.js";
const PREVIEW_LINES = 10;
const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
function diagnosticsWaitMsForFile(filePath: string): number {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".kt" || ext === ".kts") return 30000;
if (ext === ".swift") return 20000;
if (ext === ".rs") return 20000;
return DIAGNOSTICS_WAIT_MS_DEFAULT;
}
const ACTIONS = ["definition", "references", "hover", "symbols", "diagnostics", "workspace-diagnostics", "signature", "rename", "codeAction"] as const;
const SEVERITY_FILTERS = ["all", "error", "warning", "info", "hint"] as const;
const LspParams = Type.Object({
action: StringEnum(ACTIONS),
file: Type.Optional(Type.String({ description: "File path (required for most actions)" })),
files: Type.Optional(Type.Array(Type.String(), { description: "File paths for workspace-diagnostics" })),
line: Type.Optional(Type.Number({ description: "Line (1-indexed). Required for position-based actions unless query provided." })),
column: Type.Optional(Type.Number({ description: "Column (1-indexed). Required for position-based actions unless query provided." })),
endLine: Type.Optional(Type.Number({ description: "End line for range-based actions (codeAction)" })),
endColumn: Type.Optional(Type.Number({ description: "End column for range-based actions (codeAction)" })),
query: Type.Optional(Type.String({ description: "Symbol name filter (for symbols) or to resolve position (for definition/references/hover/signature)" })),
newName: Type.Optional(Type.String({ description: "New name for rename action" })),
severity: Type.Optional(StringEnum(SEVERITY_FILTERS, { description: 'Filter diagnostics: "all"|"error"|"warning"|"info"|"hint"' })),
});
type LspParamsType = Static<typeof LspParams>;
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(new Error("aborted"));
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
cleanup();
reject(new Error("aborted"));
};
const cleanup = () => {
signal.removeEventListener("abort", onAbort);
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
cleanup();
resolve(value);
},
(err) => {
cleanup();
reject(err);
},
);
});
}
function isAbortedError(e: unknown): boolean {
return e instanceof Error && e.message === "aborted";
}
function cancelledToolResult() {
return {
content: [{ type: "text" as const, text: "Cancelled" }],
details: { cancelled: true },
};
}
type ExecuteArgs = {
signal: AbortSignal | undefined;
onUpdate: ((update: { content: Array<{ type: "text"; text: string }>; details?: Record<string, unknown> }) => void) | undefined;
ctx: { cwd: string };
};
function isAbortSignalLike(value: unknown): value is AbortSignal {
return !!value
&& typeof value === "object"
&& "aborted" in value
&& typeof (value as any).aborted === "boolean"
&& typeof (value as any).addEventListener === "function";
}
function isContextLike(value: unknown): value is { cwd: string } {
return !!value && typeof value === "object" && typeof (value as any).cwd === "string";
}
function normalizeExecuteArgs(onUpdateArg: unknown, ctxArg: unknown, signalArg: unknown): ExecuteArgs {
// Runtime >= 0.51: (signal, onUpdate, ctx)
if (isContextLike(signalArg)) {
return {
signal: isAbortSignalLike(onUpdateArg) ? onUpdateArg : undefined,
onUpdate: typeof ctxArg === "function" ? ctxArg as ExecuteArgs["onUpdate"] : undefined,
ctx: signalArg,
};
}
// Runtime <= 0.50: (onUpdate, ctx, signal)
if (isContextLike(ctxArg)) {
return {
signal: isAbortSignalLike(signalArg) ? signalArg : undefined,
onUpdate: typeof onUpdateArg === "function" ? onUpdateArg as ExecuteArgs["onUpdate"] : undefined,
ctx: ctxArg,
};
}
throw new Error("Invalid tool execution context");
}
function formatLocation(loc: { uri: string; range?: { start?: { line: number; character: number } } }, cwd?: string): string {
const abs = uriToPath(loc.uri);
const display = cwd && path.isAbsolute(abs) ? path.relative(cwd, abs) : abs;
const { line, character: col } = loc.range?.start ?? {};
return typeof line === "number" && typeof col === "number" ? `${display}:${line + 1}:${col + 1}` : display;
}
function formatHover(contents: unknown): string {
if (typeof contents === "string") return contents;
if (Array.isArray(contents)) return contents.map(c => typeof c === "string" ? c : (c as any)?.value ?? "").filter(Boolean).join("\n\n");
if (contents && typeof contents === "object" && "value" in contents) return String((contents as any).value);
return "";
}
function formatSignature(help: any): string {
if (!help?.signatures?.length) return "No signature help available.";
const sig = help.signatures[help.activeSignature ?? 0] ?? help.signatures[0];
let text = sig.label ?? "Signature";
if (sig.documentation) text += `\n${typeof sig.documentation === "string" ? sig.documentation : sig.documentation?.value ?? ""}`;
if (sig.parameters?.length) {
const params = sig.parameters.map((p: any) => typeof p.label === "string" ? p.label : Array.isArray(p.label) ? p.label.join("-") : "").filter(Boolean);
if (params.length) text += `\nParameters: ${params.join(", ")}`;
}
return text;
}
function collectSymbols(symbols: any[], depth = 0, lines: string[] = [], query?: string): string[] {
for (const sym of symbols) {
const name = sym?.name ?? "<unknown>";
if (query && !name.toLowerCase().includes(query.toLowerCase())) {
if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
continue;
}
const loc = sym?.range?.start ? `${sym.range.start.line + 1}:${sym.range.start.character + 1}` : "";
lines.push(`${" ".repeat(depth)}${name}${loc ? ` (${loc})` : ""}`);
if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
}
return lines;
}
function formatWorkspaceEdit(edit: any, cwd?: string): string {
const lines: string[] = [];
if (edit.documentChanges?.length) {
for (const change of edit.documentChanges) {
if (change.textDocument?.uri) {
const fp = uriToPath(change.textDocument.uri);
const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
lines.push(`${display}:`);
for (const e of change.edits || []) {
const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
lines.push(` [${loc}] → "${e.newText}"`);
}
}
}
}
if (edit.changes) {
for (const [uri, edits] of Object.entries(edit.changes)) {
const fp = uriToPath(uri);
const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
lines.push(`${display}:`);
for (const e of edits as any[]) {
const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
lines.push(` [${loc}] → "${e.newText}"`);
}
}
}
return lines.length ? lines.join("\n") : "No edits.";
}
function formatCodeActions(actions: any[]): string[] {
return actions.map((a, i) => {
const title = a.title || a.command?.title || "Untitled action";
const kind = a.kind ? ` (${a.kind})` : "";
const isPreferred = a.isPreferred ? " ★" : "";
return `${i + 1}. ${title}${kind}${isPreferred}`;
});
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "lsp",
label: "LSP",
description: `Query language server for definitions, references, types, symbols, diagnostics, rename, and code actions.
Actions: definition, references, hover, signature, rename (require file + line/column or query), symbols (file, optional query), diagnostics (file), workspace-diagnostics (files array), codeAction (file + position).
Use bash to find files: find src -name "*.ts" -type f`,
parameters: LspParams,
async execute(_toolCallId, params, onUpdateArg, ctxArg, signalArg) {
const { signal, onUpdate, ctx } = normalizeExecuteArgs(onUpdateArg, ctxArg, signalArg);
if (signal?.aborted) return cancelledToolResult();
if (onUpdate) {
onUpdate({ content: [{ type: "text", text: "Working..." }], details: { status: "working" } });
}
const manager = getOrCreateManager(ctx.cwd);
const { action, file, files, line, column, endLine, endColumn, query, newName, severity } = params as LspParamsType;
const sevFilter: SeverityFilter = severity || "all";
const needsFile = action !== "workspace-diagnostics";
const needsPos = ["definition", "references", "hover", "signature", "rename", "codeAction"].includes(action);
try {
if (needsFile && !file) throw new Error(`Action "${action}" requires a file path.`);
let rLine = line, rCol = column, fromQuery = false;
if (needsPos && (rLine === undefined || rCol === undefined) && query && file) {
const resolved = await abortable(resolvePosition(manager, file, query), signal);
if (resolved) { rLine = resolved.line; rCol = resolved.column; fromQuery = true; }
}
if (needsPos && (rLine === undefined || rCol === undefined)) {
throw new Error(`Action "${action}" requires line/column or a query matching a symbol.`);
}
const qLine = query ? `query: ${query}\n` : "";
const sevLine = sevFilter !== "all" ? `severity: ${sevFilter}\n` : "";
const posLine = fromQuery && rLine && rCol ? `resolvedPosition: ${rLine}:${rCol}\n` : "";
switch (action) {
case "definition": {
const results = await abortable(manager.getDefinition(file!, rLine!, rCol!), signal);
const locs = results.map(l => formatLocation(l, ctx?.cwd));
const payload = locs.length ? locs.join("\n") : fromQuery ? `${file}:${rLine}:${rCol}` : "No definitions found.";
return { content: [{ type: "text", text: `action: definition\n${qLine}${posLine}${payload}` }], details: results };
}
case "references": {
const results = await abortable(manager.getReferences(file!, rLine!, rCol!), signal);
const locs = results.map(l => formatLocation(l, ctx?.cwd));
return { content: [{ type: "text", text: `action: references\n${qLine}${posLine}${locs.length ? locs.join("\n") : "No references found."}` }], details: results };
}
case "hover": {
const result = await abortable(manager.getHover(file!, rLine!, rCol!), signal);
const payload = result ? formatHover(result.contents) || "No hover information." : "No hover information.";
return { content: [{ type: "text", text: `action: hover\n${qLine}${posLine}${payload}` }], details: result ?? null };
}
case "symbols": {
const symbols = await abortable(manager.getDocumentSymbols(file!), signal);
const lines = collectSymbols(symbols, 0, [], query);
const payload = lines.length ? lines.join("\n") : query ? `No symbols matching "${query}".` : "No symbols found.";
return { content: [{ type: "text", text: `action: symbols\n${qLine}${payload}` }], details: symbols };
}
case "diagnostics": {
const result = await abortable(manager.touchFileAndWait(file!, diagnosticsWaitMsForFile(file!)), signal);
const filtered = filterDiagnosticsBySeverity(result.diagnostics, sevFilter);
const payload = (result as any).unsupported
? `Unsupported: ${(result as any).error || "No LSP for this file."}`
: !result.receivedResponse
? "Timeout: LSP server did not respond. Try again."
: filtered.length ? filtered.map(formatDiagnostic).join("\n") : "No diagnostics.";
return { content: [{ type: "text", text: `action: diagnostics\n${sevLine}${payload}` }], details: { ...result, diagnostics: filtered } };
}
case "workspace-diagnostics": {
if (!files?.length) throw new Error('Action "workspace-diagnostics" requires a "files" array.');
const waitMs = Math.max(...files.map(diagnosticsWaitMsForFile));
const result = await abortable(manager.getDiagnosticsForFiles(files, waitMs), signal);
const out: string[] = [];
let errors = 0, warnings = 0, filesWithIssues = 0;
for (const item of result.items) {
const display = ctx?.cwd && path.isAbsolute(item.file) ? path.relative(ctx.cwd, item.file) : item.file;
if (item.status !== 'ok') { out.push(`${display}: ${item.error || item.status}`); continue; }
const filtered = filterDiagnosticsBySeverity(item.diagnostics, sevFilter);
if (filtered.length) {
filesWithIssues++;
out.push(`${display}:`);
for (const d of filtered) {
if (d.severity === 1) errors++; else if (d.severity === 2) warnings++;
out.push(` ${formatDiagnostic(d)}`);
}
}
}
const summary = `Analyzed ${result.items.length} file(s): ${errors} error(s), ${warnings} warning(s) in ${filesWithIssues} file(s)`;
return { content: [{ type: "text", text: `action: workspace-diagnostics\n${sevLine}${summary}\n\n${out.length ? out.join("\n") : "No diagnostics."}` }], details: result };
}
case "signature": {
const result = await abortable(manager.getSignatureHelp(file!, rLine!, rCol!), signal);
return { content: [{ type: "text", text: `action: signature\n${qLine}${posLine}${formatSignature(result)}` }], details: result ?? null };
}
case "rename": {
if (!newName) throw new Error('Action "rename" requires a "newName" parameter.');
const result = await abortable(manager.rename(file!, rLine!, rCol!, newName), signal);
if (!result) return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}No rename available at this position.` }], details: null };
const edits = formatWorkspaceEdit(result, ctx?.cwd);
return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}newName: ${newName}\n\n${edits}` }], details: result };
}
case "codeAction": {
const result = await abortable(manager.getCodeActions(file!, rLine!, rCol!, endLine, endColumn), signal);
const actions = formatCodeActions(result);
return { content: [{ type: "text", text: `action: codeAction\n${qLine}${posLine}${actions.length ? actions.join("\n") : "No code actions available."}` }], details: result };
}
}
} catch (e) {
if (signal?.aborted || isAbortedError(e)) return cancelledToolResult();
throw e;
}
},
renderCall(args, theme) {
const params = args as LspParamsType;
let text = theme.fg("toolTitle", theme.bold("lsp ")) + theme.fg("accent", params.action || "...");
if (params.file) text += " " + theme.fg("muted", params.file);
else if (params.files?.length) text += " " + theme.fg("muted", `${params.files.length} file(s)`);
if (params.query) text += " " + theme.fg("dim", `query="${params.query}"`);
else if (params.line !== undefined && params.column !== undefined) text += theme.fg("warning", `:${params.line}:${params.column}`);
if (params.severity && params.severity !== "all") text += " " + theme.fg("dim", `[${params.severity}]`);
return new Text(text, 0, 0);
},
renderResult(result, options, theme) {
if (options.isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
const textContent = (result.content?.find((c: any) => c.type === "text") as any)?.text || "";
const lines = textContent.split("\n");
let headerEnd = 0;
for (let i = 0; i < lines.length; i++) {
if (/^(action|query|severity|resolvedPosition):/.test(lines[i])) headerEnd = i + 1;
else break;
}
const header = lines.slice(0, headerEnd);
const content = lines.slice(headerEnd);
const maxLines = options.expanded ? content.length : PREVIEW_LINES;
const display = content.slice(0, maxLines);
const remaining = content.length - maxLines;
let out = header.map((l: string) => theme.fg("muted", l)).join("\n");
if (display.length) {
if (out) out += "\n";
out += display.map((l: string) => theme.fg("toolOutput", l)).join("\n");
}
if (remaining > 0) out += theme.fg("dim", `\n... (${remaining} more lines)`);
return new Text(out, 0, 0);
},
});
}

View File

@@ -0,0 +1,604 @@
/**
* LSP Hook Extension for pi-coding-agent
*
* Provides automatic diagnostics feedback (default: agent end).
* Can run after each write/edit or once per agent response.
*
* Usage:
* pi --extension ./lsp.ts
*
* Or load the directory to get both hook and tool:
* pi --extension ./lsp/
*/
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
import { type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { type Diagnostic } from "vscode-languageserver-protocol";
import { LSP_SERVERS, formatDiagnostic, getOrCreateManager, shutdownManager } from "./lsp-core.js";
type HookScope = "session" | "global";
type HookMode = "edit_write" | "agent_end" | "disabled";
const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
function diagnosticsWaitMsForFile(filePath: string): number {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".kt" || ext === ".kts") return 30000;
if (ext === ".swift") return 20000;
if (ext === ".rs") return 20000;
return DIAGNOSTICS_WAIT_MS_DEFAULT;
}
const DIAGNOSTICS_PREVIEW_LINES = 10;
const LSP_IDLE_SHUTDOWN_MS = 2 * 60 * 1000;
const DIM = "\x1b[2m", GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m";
const DEFAULT_HOOK_MODE: HookMode = "agent_end";
const SETTINGS_NAMESPACE = "lsp";
const LSP_CONFIG_ENTRY = "lsp-hook-config";
const WARMUP_MAP: Record<string, string> = {
"pubspec.yaml": ".dart",
"package.json": ".ts",
"pyproject.toml": ".py",
"go.mod": ".go",
"Cargo.toml": ".rs",
"settings.gradle": ".kt",
"settings.gradle.kts": ".kt",
"build.gradle": ".kt",
"build.gradle.kts": ".kt",
"pom.xml": ".kt",
"gradlew": ".kt",
"gradle.properties": ".kt",
"Package.swift": ".swift",
};
const MODE_LABELS: Record<HookMode, string> = {
edit_write: "After each edit/write",
agent_end: "At agent end",
disabled: "Disabled",
};
function normalizeHookMode(value: unknown): HookMode | undefined {
if (value === "edit_write" || value === "agent_end" || value === "disabled") return value;
if (value === "turn_end") return "agent_end";
return undefined;
}
interface HookConfigEntry {
scope: HookScope;
hookMode?: HookMode;
}
export default function (pi: ExtensionAPI) {
type LspActivity = "idle" | "loading" | "working";
let activeClients: Set<string> = new Set();
let statusUpdateFn: ((key: string, text: string | undefined) => void) | null = null;
let hookMode: HookMode = DEFAULT_HOOK_MODE;
let hookScope: HookScope = "global";
let activity: LspActivity = "idle";
let diagnosticsAbort: AbortController | null = null;
let shuttingDown = false;
let idleShutdownTimer: NodeJS.Timeout | null = null;
const touchedFiles: Map<string, boolean> = new Map();
const globalSettingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
function readSettingsFile(filePath: string): Record<string, unknown> {
try {
if (!fs.existsSync(filePath)) return {};
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
function getGlobalHookMode(): HookMode | undefined {
const settings = readSettingsFile(globalSettingsPath);
const lspSettings = settings[SETTINGS_NAMESPACE];
const hookValue = (lspSettings as { hookMode?: unknown; hookEnabled?: unknown } | undefined)?.hookMode;
const normalized = normalizeHookMode(hookValue);
if (normalized) return normalized;
const legacyEnabled = (lspSettings as { hookEnabled?: unknown } | undefined)?.hookEnabled;
if (typeof legacyEnabled === "boolean") return legacyEnabled ? "edit_write" : "disabled";
return undefined;
}
function setGlobalHookMode(mode: HookMode): boolean {
try {
const settings = readSettingsFile(globalSettingsPath);
const existing = settings[SETTINGS_NAMESPACE];
const nextNamespace = (existing && typeof existing === "object")
? { ...(existing as Record<string, unknown>), hookMode: mode }
: { hookMode: mode };
settings[SETTINGS_NAMESPACE] = nextNamespace;
fs.mkdirSync(path.dirname(globalSettingsPath), { recursive: true });
fs.writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
return true;
} catch {
return false;
}
}
function getLastHookEntry(ctx: ExtensionContext): HookConfigEntry | undefined {
const branchEntries = ctx.sessionManager.getBranch();
let latest: HookConfigEntry | undefined;
for (const entry of branchEntries) {
if (entry.type === "custom" && entry.customType === LSP_CONFIG_ENTRY) {
latest = entry.data as HookConfigEntry | undefined;
}
}
return latest;
}
function restoreHookState(ctx: ExtensionContext): void {
const entry = getLastHookEntry(ctx);
if (entry?.scope === "session") {
const normalized = normalizeHookMode(entry.hookMode);
if (normalized) {
hookMode = normalized;
hookScope = "session";
return;
}
const legacyEnabled = (entry as { hookEnabled?: unknown }).hookEnabled;
if (typeof legacyEnabled === "boolean") {
hookMode = legacyEnabled ? "edit_write" : "disabled";
hookScope = "session";
return;
}
}
const globalSetting = getGlobalHookMode();
hookMode = globalSetting ?? DEFAULT_HOOK_MODE;
hookScope = "global";
}
function persistHookEntry(entry: HookConfigEntry): void {
pi.appendEntry<HookConfigEntry>(LSP_CONFIG_ENTRY, entry);
}
function labelForMode(mode: HookMode): string {
return MODE_LABELS[mode];
}
function messageContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((item) => (item && typeof item === "object" && "type" in item && (item as any).type === "text")
? String((item as any).text ?? "")
: "")
.filter(Boolean)
.join("\n");
}
return "";
}
function formatDiagnosticsForDisplay(text: string): string {
return text
.replace(/\n?This file has errors, please fix\n/gi, "\n")
.replace(/<\/?file_diagnostics>\n?/gi, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function setActivity(next: LspActivity): void {
activity = next;
updateLspStatus();
}
function clearIdleShutdownTimer(): void {
if (!idleShutdownTimer) return;
clearTimeout(idleShutdownTimer);
idleShutdownTimer = null;
}
async function shutdownLspServersForIdle(): Promise<void> {
diagnosticsAbort?.abort();
diagnosticsAbort = null;
setActivity("idle");
await shutdownManager();
activeClients.clear();
updateLspStatus();
}
function scheduleIdleShutdown(): void {
clearIdleShutdownTimer();
idleShutdownTimer = setTimeout(() => {
idleShutdownTimer = null;
if (shuttingDown) return;
void shutdownLspServersForIdle();
}, LSP_IDLE_SHUTDOWN_MS);
(idleShutdownTimer as any).unref?.();
}
function updateLspStatus(): void {
if (!statusUpdateFn) return;
const clients = activeClients.size > 0 ? [...activeClients].join(", ") : "";
const clientsText = clients ? `${DIM}${clients}${RESET}` : "";
const activityHint = activity === "idle" ? "" : `${DIM}${RESET}`;
if (hookMode === "disabled") {
const text = clientsText
? `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}: ${clientsText}`
: `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}`;
statusUpdateFn("lsp", text);
return;
}
let text = `${GREEN}LSP${RESET}`;
if (activityHint) text += ` ${activityHint}`;
if (clientsText) text += ` ${clientsText}`;
statusUpdateFn("lsp", text);
}
function normalizeFilePath(filePath: string, cwd: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
}
pi.registerMessageRenderer("lsp-diagnostics", (message, options, theme) => {
const content = formatDiagnosticsForDisplay(messageContentToText(message.content));
if (!content) return new Text("", 0, 0);
const expanded = options.expanded === true;
const lines = content.split("\n");
const maxLines = expanded ? lines.length : DIAGNOSTICS_PREVIEW_LINES;
const display = lines.slice(0, maxLines);
const remaining = lines.length - display.length;
const styledLines = display.map((line) => {
if (line.startsWith("File: ")) return theme.fg("muted", line);
return theme.fg("toolOutput", line);
});
if (!expanded && remaining > 0) {
styledLines.push(theme.fg("dim", `... (${remaining} more lines)`));
}
return new Text(styledLines.join("\n"), 0, 0);
});
function getServerConfig(filePath: string) {
const ext = path.extname(filePath);
return LSP_SERVERS.find((s) => s.extensions.includes(ext));
}
function ensureActiveClientForFile(filePath: string, cwd: string): string | undefined {
const absPath = normalizeFilePath(filePath, cwd);
const cfg = getServerConfig(absPath);
if (!cfg) return undefined;
if (!activeClients.has(cfg.id)) {
activeClients.add(cfg.id);
updateLspStatus();
}
return absPath;
}
function extractLspFiles(input: Record<string, unknown>): string[] {
const files: string[] = [];
if (typeof input.file === "string") files.push(input.file);
if (Array.isArray(input.files)) {
for (const item of input.files) {
if (typeof item === "string") files.push(item);
}
}
return files;
}
function buildDiagnosticsOutput(
filePath: string,
diagnostics: Diagnostic[],
cwd: string,
includeFileHeader: boolean,
): { notification: string; errorCount: number; output: string } {
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
const relativePath = path.relative(cwd, absPath);
const errorCount = diagnostics.filter((e) => e.severity === 1).length;
const MAX = 5;
const lines = diagnostics.slice(0, MAX).map((e) => {
const sev = e.severity === 1 ? "ERROR" : "WARN";
return `${sev}[${e.range.start.line + 1}] ${e.message.split("\n")[0]}`;
});
let notification = `📋 ${relativePath}\n${lines.join("\n")}`;
if (diagnostics.length > MAX) notification += `\n... +${diagnostics.length - MAX} more`;
const header = includeFileHeader ? `File: ${relativePath}\n` : "";
const output = `\n${header}This file has errors, please fix\n<file_diagnostics>\n${diagnostics.map(formatDiagnostic).join("\n")}\n</file_diagnostics>\n`;
return { notification, errorCount, output };
}
async function collectDiagnostics(
filePath: string,
ctx: ExtensionContext,
includeWarnings: boolean,
includeFileHeader: boolean,
notify = true,
): Promise<string | undefined> {
const manager = getOrCreateManager(ctx.cwd);
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
if (!absPath) return undefined;
try {
const result = await manager.touchFileAndWait(absPath, diagnosticsWaitMsForFile(absPath));
if (!result.receivedResponse) return undefined;
const diagnostics = includeWarnings
? result.diagnostics
: result.diagnostics.filter((d) => d.severity === 1);
if (!diagnostics.length) return undefined;
const report = buildDiagnosticsOutput(filePath, diagnostics, ctx.cwd, includeFileHeader);
if (notify) {
if (ctx.hasUI) ctx.ui.notify(report.notification, report.errorCount > 0 ? "error" : "warning");
else console.error(report.notification);
}
return report.output;
} catch {
return undefined;
}
}
pi.registerCommand("lsp", {
description: "LSP settings (auto diagnostics hook)",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("LSP settings require UI", "warning");
return;
}
const currentMark = " ✓";
const modeOptions = ([
"edit_write",
"agent_end",
"disabled",
] as HookMode[]).map((mode) => ({
mode,
label: mode === hookMode ? `${labelForMode(mode)}${currentMark}` : labelForMode(mode),
}));
const modeChoice = await ctx.ui.select(
"LSP auto diagnostics hook mode:",
modeOptions.map((option) => option.label),
);
if (!modeChoice) return;
const nextMode = modeOptions.find((option) => option.label === modeChoice)?.mode;
if (!nextMode) return;
const scopeOptions = [
{
scope: "session" as HookScope,
label: "Session only",
},
{
scope: "global" as HookScope,
label: "Global (all sessions)",
},
];
const scopeChoice = await ctx.ui.select(
"Apply LSP auto diagnostics hook setting to:",
scopeOptions.map((option) => option.label),
);
if (!scopeChoice) return;
const scope = scopeOptions.find((option) => option.label === scopeChoice)?.scope;
if (!scope) return;
if (scope === "global") {
const ok = setGlobalHookMode(nextMode);
if (!ok) {
ctx.ui.notify("Failed to update global settings", "error");
return;
}
}
hookMode = nextMode;
hookScope = scope;
touchedFiles.clear();
persistHookEntry({ scope, hookMode: nextMode });
updateLspStatus();
ctx.ui.notify(`LSP hook: ${labelForMode(hookMode)} (${hookScope})`, "info");
},
});
pi.on("session_start", async (_event, ctx) => {
restoreHookState(ctx);
statusUpdateFn = ctx.hasUI && ctx.ui.setStatus ? ctx.ui.setStatus.bind(ctx.ui) : null;
updateLspStatus();
if (hookMode === "disabled") return;
const manager = getOrCreateManager(ctx.cwd);
for (const [marker, ext] of Object.entries(WARMUP_MAP)) {
if (fs.existsSync(path.join(ctx.cwd, marker))) {
setActivity("loading");
manager.getClientsForFile(path.join(ctx.cwd, `dummy${ext}`))
.then((clients) => {
if (clients.length > 0) {
const cfg = LSP_SERVERS.find((s) => s.extensions.includes(ext));
if (cfg) activeClients.add(cfg.id);
}
})
.catch(() => {})
.finally(() => setActivity("idle"));
break;
}
}
});
pi.on("session_switch", async (_event, ctx) => {
restoreHookState(ctx);
updateLspStatus();
});
pi.on("session_tree", async (_event, ctx) => {
restoreHookState(ctx);
updateLspStatus();
});
pi.on("session_fork", async (_event, ctx) => {
restoreHookState(ctx);
updateLspStatus();
});
pi.on("session_shutdown", async () => {
shuttingDown = true;
clearIdleShutdownTimer();
diagnosticsAbort?.abort();
diagnosticsAbort = null;
setActivity("idle");
await shutdownManager();
activeClients.clear();
statusUpdateFn?.("lsp", undefined);
});
pi.on("tool_call", async (event, ctx) => {
const input = (event.input && typeof event.input === "object")
? event.input as Record<string, unknown>
: {};
if (event.toolName === "lsp") {
clearIdleShutdownTimer();
const files = extractLspFiles(input);
for (const file of files) {
ensureActiveClientForFile(file, ctx.cwd);
}
return;
}
if (event.toolName !== "read" && event.toolName !== "write" && event.toolName !== "edit") return;
clearIdleShutdownTimer();
const filePath = typeof input.path === "string" ? input.path : undefined;
if (!filePath) return;
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
if (!absPath) return;
void getOrCreateManager(ctx.cwd).getClientsForFile(absPath).catch(() => {});
});
pi.on("agent_start", async () => {
clearIdleShutdownTimer();
diagnosticsAbort?.abort();
diagnosticsAbort = null;
setActivity("idle");
touchedFiles.clear();
});
function agentWasAborted(event: any): boolean {
const messages = Array.isArray(event?.messages) ? event.messages : [];
return messages.some((m: any) =>
m &&
typeof m === "object" &&
(m as any).role === "assistant" &&
(((m as any).stopReason === "aborted") || ((m as any).stopReason === "error"))
);
}
pi.on("agent_end", async (event, ctx) => {
try {
if (hookMode !== "agent_end") return;
if (agentWasAborted(event)) {
// Don't run diagnostics on aborted/error runs.
touchedFiles.clear();
return;
}
if (touchedFiles.size === 0) return;
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
const abort = new AbortController();
diagnosticsAbort?.abort();
diagnosticsAbort = abort;
setActivity("working");
const files = Array.from(touchedFiles.entries());
touchedFiles.clear();
try {
const outputs: string[] = [];
for (const [filePath, includeWarnings] of files) {
if (shuttingDown || abort.signal.aborted) return;
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
abort.abort();
return;
}
const output = await collectDiagnostics(filePath, ctx, includeWarnings, true, false);
if (abort.signal.aborted) return;
if (output) outputs.push(output);
}
if (shuttingDown || abort.signal.aborted) return;
if (outputs.length) {
pi.sendMessage({
customType: "lsp-diagnostics",
content: outputs.join("\n"),
display: true,
}, {
triggerTurn: true,
deliverAs: "followUp",
});
}
} finally {
if (diagnosticsAbort === abort) diagnosticsAbort = null;
if (!shuttingDown) setActivity("idle");
}
} finally {
if (!shuttingDown) scheduleIdleShutdown();
}
});
pi.on("tool_result", async (event, ctx) => {
if (event.toolName !== "write" && event.toolName !== "edit") return;
const filePath = event.input.path as string;
if (!filePath) return;
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
if (!absPath) return;
if (hookMode === "disabled") return;
if (hookMode === "agent_end") {
const includeWarnings = event.toolName === "write";
const existing = touchedFiles.get(absPath) ?? false;
touchedFiles.set(absPath, existing || includeWarnings);
return;
}
const includeWarnings = event.toolName === "write";
const output = await collectDiagnostics(absPath, ctx, includeWarnings, false);
if (!output) return;
return { content: [...event.content, { type: "text" as const, text: output }] as Array<{ type: "text"; text: string }> };
});
}

View File

@@ -0,0 +1,54 @@
{
"name": "lsp-pi",
"version": "1.0.3",
"description": "LSP extension for pi-coding-agent - provides language server tool and diagnostics feedback for Dart/Flutter, TypeScript, Vue, Svelte, Python, Go, Kotlin, Swift, Rust",
"scripts": {
"test": "npx tsx tests/lsp.test.ts",
"test:tool": "npx tsx tests/index.test.ts",
"test:integration": "npx tsx tests/lsp-integration.test.ts",
"test:all": "npm test && npm run test:tool && npm run test:integration"
},
"keywords": [
"lsp",
"language-server",
"dart",
"flutter",
"typescript",
"vue",
"svelte",
"python",
"go",
"kotlin",
"swift",
"rust",
"pi-coding-agent",
"extension",
"pi-package"
],
"author": "",
"license": "MIT",
"type": "module",
"pi": {
"extensions": [
"./lsp.ts",
"./lsp-tool.ts"
]
},
"dependencies": {
"@sinclair/typebox": "^0.34.33",
"vscode-languageserver-protocol": "^3.17.5"
},
"peerDependencies": {
"@mariozechner/pi-ai": "^0.50.0",
"@mariozechner/pi-coding-agent": "^0.50.0",
"@mariozechner/pi-tui": "^0.50.0"
},
"devDependencies": {
"@mariozechner/pi-ai": "^0.50.0",
"@mariozechner/pi-coding-agent": "^0.50.0",
"@mariozechner/pi-tui": "^0.50.0",
"@types/node": "^24.10.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,235 @@
/**
* Unit tests for index.ts formatting functions
*/
// ============================================================================
// Test utilities
// ============================================================================
const tests: Array<{ name: string; fn: () => void | Promise<void> }> = [];
function test(name: string, fn: () => void | Promise<void>) {
tests.push({ name, fn });
}
function assertEqual<T>(actual: T, expected: T, message?: string) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) throw new Error(message || `Expected ${e}, got ${a}`);
}
// ============================================================================
// Import the module to test internal functions
// We need to test via the execute function since formatters are private
// Or we can extract and test the logic directly
// ============================================================================
import { uriToPath, findSymbolPosition, formatDiagnostic, filterDiagnosticsBySeverity } from "../lsp-core.js";
// ============================================================================
// uriToPath tests
// ============================================================================
test("uriToPath: converts file:// URI to path", () => {
const result = uriToPath("file:///Users/test/file.ts");
assertEqual(result, "/Users/test/file.ts");
});
test("uriToPath: handles encoded characters", () => {
const result = uriToPath("file:///Users/test/my%20file.ts");
assertEqual(result, "/Users/test/my file.ts");
});
test("uriToPath: passes through non-file URIs", () => {
const result = uriToPath("/some/path.ts");
assertEqual(result, "/some/path.ts");
});
test("uriToPath: handles invalid URIs gracefully", () => {
const result = uriToPath("not-a-valid-uri");
assertEqual(result, "not-a-valid-uri");
});
// ============================================================================
// findSymbolPosition tests
// ============================================================================
test("findSymbolPosition: finds exact match", () => {
const symbols = [
{ name: "greet", range: { start: { line: 5, character: 10 }, end: { line: 5, character: 15 } }, selectionRange: { start: { line: 5, character: 10 }, end: { line: 5, character: 15 } }, kind: 12, children: [] },
{ name: "hello", range: { start: { line: 10, character: 0 }, end: { line: 10, character: 5 } }, selectionRange: { start: { line: 10, character: 0 }, end: { line: 10, character: 5 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "greet");
assertEqual(pos, { line: 5, character: 10 });
});
test("findSymbolPosition: finds partial match", () => {
const symbols = [
{ name: "getUserName", range: { start: { line: 3, character: 0 }, end: { line: 3, character: 11 } }, selectionRange: { start: { line: 3, character: 0 }, end: { line: 3, character: 11 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "user");
assertEqual(pos, { line: 3, character: 0 });
});
test("findSymbolPosition: prefers exact over partial", () => {
const symbols = [
{ name: "userName", range: { start: { line: 1, character: 0 }, end: { line: 1, character: 8 } }, selectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 8 } }, kind: 12, children: [] },
{ name: "user", range: { start: { line: 5, character: 0 }, end: { line: 5, character: 4 } }, selectionRange: { start: { line: 5, character: 0 }, end: { line: 5, character: 4 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "user");
assertEqual(pos, { line: 5, character: 0 });
});
test("findSymbolPosition: searches nested children", () => {
const symbols = [
{
name: "MyClass",
range: { start: { line: 0, character: 0 }, end: { line: 10, character: 0 } },
selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 7 } },
kind: 5,
children: [
{ name: "myMethod", range: { start: { line: 2, character: 2 }, end: { line: 4, character: 2 } }, selectionRange: { start: { line: 2, character: 2 }, end: { line: 2, character: 10 } }, kind: 6, children: [] },
]
},
];
const pos = findSymbolPosition(symbols as any, "myMethod");
assertEqual(pos, { line: 2, character: 2 });
});
test("findSymbolPosition: returns null for no match", () => {
const symbols = [
{ name: "foo", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "bar");
assertEqual(pos, null);
});
test("findSymbolPosition: case insensitive", () => {
const symbols = [
{ name: "MyFunction", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "myfunction");
assertEqual(pos, { line: 0, character: 0 });
});
// ============================================================================
// formatDiagnostic tests
// ============================================================================
test("formatDiagnostic: formats error", () => {
const diag = {
range: { start: { line: 5, character: 10 }, end: { line: 5, character: 15 } },
message: "Type 'number' is not assignable to type 'string'",
severity: 1,
};
const result = formatDiagnostic(diag as any);
assertEqual(result, "ERROR [6:11] Type 'number' is not assignable to type 'string'");
});
test("formatDiagnostic: formats warning", () => {
const diag = {
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
message: "Unused variable",
severity: 2,
};
const result = formatDiagnostic(diag as any);
assertEqual(result, "WARN [1:1] Unused variable");
});
test("formatDiagnostic: formats info", () => {
const diag = {
range: { start: { line: 2, character: 4 }, end: { line: 2, character: 10 } },
message: "Consider using const",
severity: 3,
};
const result = formatDiagnostic(diag as any);
assertEqual(result, "INFO [3:5] Consider using const");
});
test("formatDiagnostic: formats hint", () => {
const diag = {
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
message: "Prefer arrow function",
severity: 4,
};
const result = formatDiagnostic(diag as any);
assertEqual(result, "HINT [1:1] Prefer arrow function");
});
// ============================================================================
// filterDiagnosticsBySeverity tests
// ============================================================================
test("filterDiagnosticsBySeverity: all returns everything", () => {
const diags = [
{ severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 3, message: "info", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 4, message: "hint", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
];
const result = filterDiagnosticsBySeverity(diags as any, "all");
assertEqual(result.length, 4);
});
test("filterDiagnosticsBySeverity: error returns only errors", () => {
const diags = [
{ severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
];
const result = filterDiagnosticsBySeverity(diags as any, "error");
assertEqual(result.length, 1);
assertEqual(result[0].message, "error");
});
test("filterDiagnosticsBySeverity: warning returns errors and warnings", () => {
const diags = [
{ severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 3, message: "info", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
];
const result = filterDiagnosticsBySeverity(diags as any, "warning");
assertEqual(result.length, 2);
});
test("filterDiagnosticsBySeverity: info returns errors, warnings, and info", () => {
const diags = [
{ severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 3, message: "info", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 4, message: "hint", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
];
const result = filterDiagnosticsBySeverity(diags as any, "info");
assertEqual(result.length, 3);
});
// ============================================================================
// Run tests
// ============================================================================
async function runTests(): Promise<void> {
console.log("Running index.ts unit tests...\n");
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
await fn();
console.log(` ${name}... ✓`);
passed++;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(` ${name}... ✗`);
console.log(` Error: ${msg}\n`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) {
process.exit(1);
}
}
runTests();

View File

@@ -0,0 +1,602 @@
/**
* Integration tests for LSP - spawns real language servers and detects errors
*
* Run with: npm run test:integration
*
* Skips tests if language server is not installed.
*/
// Suppress stream errors from vscode-jsonrpc when LSP process exits
process.on('uncaughtException', (err) => {
if (err.message?.includes('write after end')) return;
console.error('Uncaught:', err);
process.exit(1);
});
import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
import { existsSync, statSync } from "fs";
import { tmpdir } from "os";
import { join, delimiter } from "path";
import { LSPManager } from "../lsp-core.js";
// ============================================================================
// Test utilities
// ============================================================================
const tests: Array<{ name: string; fn: () => Promise<void> }> = [];
let skipped = 0;
function test(name: string, fn: () => Promise<void>) {
tests.push({ name, fn });
}
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
class SkipTest extends Error {
constructor(reason: string) {
super(reason);
this.name = "SkipTest";
}
}
function skip(reason: string): never {
throw new SkipTest(reason);
}
// Search paths matching lsp-core.ts
const SEARCH_PATHS = [
...(process.env.PATH?.split(delimiter) || []),
"/usr/local/bin",
"/opt/homebrew/bin",
`${process.env.HOME || ""}/.pub-cache/bin`,
`${process.env.HOME || ""}/fvm/default/bin`,
`${process.env.HOME || ""}/go/bin`,
`${process.env.HOME || ""}/.cargo/bin`,
];
function commandExists(cmd: string): boolean {
for (const dir of SEARCH_PATHS) {
const full = join(dir, cmd);
try {
if (existsSync(full) && statSync(full).isFile()) return true;
} catch {}
}
return false;
}
// ============================================================================
// TypeScript
// ============================================================================
test("typescript: detects type errors", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
// Code with type error
const file = join(dir, "index.ts");
await writeFile(file, `const x: string = 123;`);
const { diagnostics } = await manager.touchFileAndWait(file, 10000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
assert(
diagnostics.some(d => d.message.toLowerCase().includes("type") || d.severity === 1),
`Expected type error, got: ${diagnostics.map(d => d.message).join(", ")}`
);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("typescript: valid code has no errors", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
const file = join(dir, "index.ts");
await writeFile(file, `const x: string = "hello";`);
const { diagnostics } = await manager.touchFileAndWait(file, 10000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Dart
// ============================================================================
test("dart: detects type errors", async () => {
if (!commandExists("dart")) {
skip("dart not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-dart-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "pubspec.yaml"), "name: test_app\nenvironment:\n sdk: ^3.0.0");
await mkdir(join(dir, "lib"));
const file = join(dir, "lib/main.dart");
// Type error: assigning int to String
await writeFile(file, `
void main() {
String x = 123;
print(x);
}
`);
const { diagnostics } = await manager.touchFileAndWait(file, 15000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("dart: valid code has no errors", async () => {
if (!commandExists("dart")) {
skip("dart not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-dart-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "pubspec.yaml"), "name: test_app\nenvironment:\n sdk: ^3.0.0");
await mkdir(join(dir, "lib"));
const file = join(dir, "lib/main.dart");
await writeFile(file, `
void main() {
String x = "hello";
print(x);
}
`);
const { diagnostics } = await manager.touchFileAndWait(file, 15000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Rust
// ============================================================================
test("rust: detects type errors", async () => {
if (!commandExists("rust-analyzer")) {
skip("rust-analyzer not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-rust-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "Cargo.toml"), `[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"`);
await mkdir(join(dir, "src"));
const file = join(dir, "src/main.rs");
await writeFile(file, `fn main() {\n let x: i32 = "hello";\n}`);
// rust-analyzer needs a LOT of time to initialize (compiles the project)
const { diagnostics } = await manager.touchFileAndWait(file, 60000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("rust: valid code has no errors", async () => {
if (!commandExists("rust-analyzer")) {
skip("rust-analyzer not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-rust-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "Cargo.toml"), `[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"`);
await mkdir(join(dir, "src"));
const file = join(dir, "src/main.rs");
await writeFile(file, `fn main() {\n let x = "hello";\n println!("{}", x);\n}`);
const { diagnostics } = await manager.touchFileAndWait(file, 60000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Go
// ============================================================================
test("go: detects type errors", async () => {
if (!commandExists("gopls")) {
skip("gopls not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-go-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "go.mod"), "module test\n\ngo 1.21");
const file = join(dir, "main.go");
// Type error: cannot use int as string
await writeFile(file, `package main
func main() {
var x string = 123
println(x)
}
`);
const { diagnostics } = await manager.touchFileAndWait(file, 15000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("go: valid code has no errors", async () => {
if (!commandExists("gopls")) {
skip("gopls not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-go-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "go.mod"), "module test\n\ngo 1.21");
const file = join(dir, "main.go");
await writeFile(file, `package main
func main() {
var x string = "hello"
println(x)
}
`);
const { diagnostics } = await manager.touchFileAndWait(file, 15000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Kotlin
// ============================================================================
test("kotlin: detects syntax errors", async () => {
if (!commandExists("kotlin-language-server")) {
skip("kotlin-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-kt-"));
const manager = new LSPManager(dir);
try {
// Minimal Gradle markers so the LSP picks a root
await writeFile(join(dir, "settings.gradle.kts"), "rootProject.name = \"test\"\n");
await writeFile(join(dir, "build.gradle.kts"), "// empty\n");
await mkdir(join(dir, "src/main/kotlin"), { recursive: true });
const file = join(dir, "src/main/kotlin/Main.kt");
// Syntax error
await writeFile(file, "fun main() { val x = }\n");
const { diagnostics, receivedResponse } = await manager.touchFileAndWait(file, 30000);
assert(receivedResponse, "Expected Kotlin LSP to respond");
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("kotlin: valid code has no errors", async () => {
if (!commandExists("kotlin-language-server")) {
skip("kotlin-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-kt-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "settings.gradle.kts"), "rootProject.name = \"test\"\n");
await writeFile(join(dir, "build.gradle.kts"), "// empty\n");
await mkdir(join(dir, "src/main/kotlin"), { recursive: true });
const file = join(dir, "src/main/kotlin/Main.kt");
await writeFile(file, "fun main() { val x = 1; println(x) }\n");
const { diagnostics, receivedResponse } = await manager.touchFileAndWait(file, 30000);
assert(receivedResponse, "Expected Kotlin LSP to respond");
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Python
// ============================================================================
test("python: detects type errors", async () => {
if (!commandExists("pyright-langserver")) {
skip("pyright-langserver not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-py-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "pyproject.toml"), `[project]\nname = "test"`);
const file = join(dir, "main.py");
// Type error with type annotation
await writeFile(file, `
def greet(name: str) -> str:
return "Hello, " + name
x: str = 123 # Type error
result = greet(456) # Type error
`);
const { diagnostics } = await manager.touchFileAndWait(file, 10000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("python: valid code has no errors", async () => {
if (!commandExists("pyright-langserver")) {
skip("pyright-langserver not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-py-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "pyproject.toml"), `[project]\nname = "test"`);
const file = join(dir, "main.py");
await writeFile(file, `
def greet(name: str) -> str:
return "Hello, " + name
x: str = "world"
result = greet(x)
`);
const { diagnostics } = await manager.touchFileAndWait(file, 10000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Rename (TypeScript)
// ============================================================================
test("typescript: rename symbol", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-rename-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
const file = join(dir, "index.ts");
await writeFile(file, `function greet(name: string) {
return "Hello, " + name;
}
const result = greet("world");
`);
// Touch file first to ensure it's loaded
await manager.touchFileAndWait(file, 10000);
// Rename 'greet' at line 1, col 10
const edit = await manager.rename(file, 1, 10, "sayHello");
if (!edit) throw new Error("Expected rename to return WorkspaceEdit");
assert(
edit.changes !== undefined || edit.documentChanges !== undefined,
"Expected changes or documentChanges in WorkspaceEdit"
);
// Should have edits for both the function definition and the call
const allEdits: any[] = [];
if (edit.changes) {
for (const edits of Object.values(edit.changes)) {
allEdits.push(...(edits as any[]));
}
}
if (edit.documentChanges) {
for (const change of edit.documentChanges as any[]) {
if (change.edits) allEdits.push(...change.edits);
}
}
assert(allEdits.length >= 2, `Expected at least 2 edits (definition + usage), got ${allEdits.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Code Actions (TypeScript)
// ============================================================================
test("typescript: get code actions for error", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-actions-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
const file = join(dir, "index.ts");
// Missing import - should offer "Add import" code action
await writeFile(file, `const x: Promise<string> = Promise.resolve("hello");
console.log(x);
`);
// Touch to get diagnostics first
await manager.touchFileAndWait(file, 10000);
// Get code actions at line 1
const actions = await manager.getCodeActions(file, 1, 1, 1, 50);
// May or may not have actions depending on the code, but shouldn't throw
assert(Array.isArray(actions), "Expected array of code actions");
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("typescript: code actions for missing function", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-actions2-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
const file = join(dir, "index.ts");
// Call undefined function - should offer quick fix
await writeFile(file, `const result = undefinedFunction();
`);
await manager.touchFileAndWait(file, 10000);
// Get code actions where the error is
const actions = await manager.getCodeActions(file, 1, 16, 1, 33);
// TypeScript should offer to create the function
assert(Array.isArray(actions), "Expected array of code actions");
// Note: we don't assert on action count since it depends on TS version
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Run tests
// ============================================================================
async function runTests(): Promise<void> {
console.log("Running LSP integration tests...\n");
console.log("Note: Tests are skipped if language server is not installed.\n");
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
await fn();
console.log(` ${name}... ✓`);
passed++;
} catch (error) {
if (error instanceof SkipTest) {
console.log(` ${name}... ⊘ (${error.message})`);
skipped++;
} else {
const msg = error instanceof Error ? error.message : String(error);
console.log(` ${name}... ✗`);
console.log(` Error: ${msg}\n`);
failed++;
}
}
}
console.log(`\n${passed} passed, ${failed} failed, ${skipped} skipped`);
if (failed > 0) {
process.exit(1);
}
}
runTests();

View File

@@ -0,0 +1,898 @@
/**
* Tests for LSP hook - configuration and utility functions
*
* Run with: npm test
*
* These tests cover:
* - Project root detection for various languages
* - Language ID mappings
* - URI construction
* - Server configuration correctness
*/
import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { pathToFileURL } from "url";
import { LSP_SERVERS, LANGUAGE_IDS } from "../lsp-core.js";
// ============================================================================
// Test utilities
// ============================================================================
interface TestResult {
name: string;
passed: boolean;
error?: string;
}
const tests: Array<{ name: string; fn: () => Promise<void> }> = [];
function test(name: string, fn: () => Promise<void>) {
tests.push({ name, fn });
}
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
function assertEquals<T>(actual: T, expected: T, message: string) {
assert(
actual === expected,
`${message}\nExpected: ${JSON.stringify(expected)}\nActual: ${JSON.stringify(actual)}`
);
}
function assertIncludes(arr: string[], item: string, message: string) {
assert(arr.includes(item), `${message}\nArray: [${arr.join(", ")}]\nMissing: ${item}`);
}
/** Create a temp directory with optional file structure */
async function withTempDir(
structure: Record<string, string | null>, // null = directory, string = file content
fn: (dir: string) => Promise<void>
): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), "lsp-test-"));
try {
for (const [path, content] of Object.entries(structure)) {
const fullPath = join(dir, path);
if (content === null) {
await mkdir(fullPath, { recursive: true });
} else {
await mkdir(join(dir, path.split("/").slice(0, -1).join("/")), { recursive: true }).catch(() => {});
await writeFile(fullPath, content);
}
}
await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
// ============================================================================
// Language ID tests
// ============================================================================
test("LANGUAGE_IDS: TypeScript extensions", async () => {
assertEquals(LANGUAGE_IDS[".ts"], "typescript", ".ts should map to typescript");
assertEquals(LANGUAGE_IDS[".tsx"], "typescriptreact", ".tsx should map to typescriptreact");
assertEquals(LANGUAGE_IDS[".mts"], "typescript", ".mts should map to typescript");
assertEquals(LANGUAGE_IDS[".cts"], "typescript", ".cts should map to typescript");
});
test("LANGUAGE_IDS: JavaScript extensions", async () => {
assertEquals(LANGUAGE_IDS[".js"], "javascript", ".js should map to javascript");
assertEquals(LANGUAGE_IDS[".jsx"], "javascriptreact", ".jsx should map to javascriptreact");
assertEquals(LANGUAGE_IDS[".mjs"], "javascript", ".mjs should map to javascript");
assertEquals(LANGUAGE_IDS[".cjs"], "javascript", ".cjs should map to javascript");
});
test("LANGUAGE_IDS: Dart extension", async () => {
assertEquals(LANGUAGE_IDS[".dart"], "dart", ".dart should map to dart");
});
test("LANGUAGE_IDS: Go extension", async () => {
assertEquals(LANGUAGE_IDS[".go"], "go", ".go should map to go");
});
test("LANGUAGE_IDS: Rust extension", async () => {
assertEquals(LANGUAGE_IDS[".rs"], "rust", ".rs should map to rust");
});
test("LANGUAGE_IDS: Kotlin extensions", async () => {
assertEquals(LANGUAGE_IDS[".kt"], "kotlin", ".kt should map to kotlin");
assertEquals(LANGUAGE_IDS[".kts"], "kotlin", ".kts should map to kotlin");
});
test("LANGUAGE_IDS: Swift extension", async () => {
assertEquals(LANGUAGE_IDS[".swift"], "swift", ".swift should map to swift");
});
test("LANGUAGE_IDS: Python extensions", async () => {
assertEquals(LANGUAGE_IDS[".py"], "python", ".py should map to python");
assertEquals(LANGUAGE_IDS[".pyi"], "python", ".pyi should map to python");
});
test("LANGUAGE_IDS: Vue/Svelte/Astro extensions", async () => {
assertEquals(LANGUAGE_IDS[".vue"], "vue", ".vue should map to vue");
assertEquals(LANGUAGE_IDS[".svelte"], "svelte", ".svelte should map to svelte");
assertEquals(LANGUAGE_IDS[".astro"], "astro", ".astro should map to astro");
});
// ============================================================================
// Server configuration tests
// ============================================================================
test("LSP_SERVERS: has TypeScript server", async () => {
const server = LSP_SERVERS.find(s => s.id === "typescript");
assert(server !== undefined, "Should have typescript server");
assertIncludes(server!.extensions, ".ts", "Should handle .ts");
assertIncludes(server!.extensions, ".tsx", "Should handle .tsx");
assertIncludes(server!.extensions, ".js", "Should handle .js");
assertIncludes(server!.extensions, ".jsx", "Should handle .jsx");
});
test("LSP_SERVERS: has Dart server", async () => {
const server = LSP_SERVERS.find(s => s.id === "dart");
assert(server !== undefined, "Should have dart server");
assertIncludes(server!.extensions, ".dart", "Should handle .dart");
});
test("LSP_SERVERS: has Rust Analyzer server", async () => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer");
assert(server !== undefined, "Should have rust-analyzer server");
assertIncludes(server!.extensions, ".rs", "Should handle .rs");
});
test("LSP_SERVERS: has Gopls server", async () => {
const server = LSP_SERVERS.find(s => s.id === "gopls");
assert(server !== undefined, "Should have gopls server");
assertIncludes(server!.extensions, ".go", "Should handle .go");
});
test("LSP_SERVERS: has Kotlin server", async () => {
const server = LSP_SERVERS.find(s => s.id === "kotlin");
assert(server !== undefined, "Should have kotlin server");
assertIncludes(server!.extensions, ".kt", "Should handle .kt");
assertIncludes(server!.extensions, ".kts", "Should handle .kts");
});
test("LSP_SERVERS: has Swift server", async () => {
const server = LSP_SERVERS.find(s => s.id === "swift");
assert(server !== undefined, "Should have swift server");
assertIncludes(server!.extensions, ".swift", "Should handle .swift");
});
test("LSP_SERVERS: has Pyright server", async () => {
const server = LSP_SERVERS.find(s => s.id === "pyright");
assert(server !== undefined, "Should have pyright server");
assertIncludes(server!.extensions, ".py", "Should handle .py");
assertIncludes(server!.extensions, ".pyi", "Should handle .pyi");
});
// ============================================================================
// TypeScript root detection tests
// ============================================================================
test("typescript: finds root with package.json", async () => {
await withTempDir({
"package.json": "{}",
"src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "src/index.ts"), dir);
assertEquals(root, dir, "Should find root at package.json location");
});
});
test("typescript: finds root with tsconfig.json", async () => {
await withTempDir({
"tsconfig.json": "{}",
"src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "src/index.ts"), dir);
assertEquals(root, dir, "Should find root at tsconfig.json location");
});
});
test("typescript: finds root with jsconfig.json", async () => {
await withTempDir({
"jsconfig.json": "{}",
"src/app.js": "const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "src/app.js"), dir);
assertEquals(root, dir, "Should find root at jsconfig.json location");
});
});
test("typescript: returns undefined for deno projects", async () => {
await withTempDir({
"deno.json": "{}",
"main.ts": "console.log('deno');",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "main.ts"), dir);
assertEquals(root, undefined, "Should return undefined for deno projects");
});
});
test("typescript: nested package finds nearest root", async () => {
await withTempDir({
"package.json": "{}",
"packages/web/package.json": "{}",
"packages/web/src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "packages/web/src/index.ts"), dir);
assertEquals(root, join(dir, "packages/web"), "Should find nearest package.json");
});
});
// ============================================================================
// Dart root detection tests
// ============================================================================
test("dart: finds root with pubspec.yaml", async () => {
await withTempDir({
"pubspec.yaml": "name: my_app",
"lib/main.dart": "void main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "lib/main.dart"), dir);
assertEquals(root, dir, "Should find root at pubspec.yaml location");
});
});
test("dart: finds root with analysis_options.yaml", async () => {
await withTempDir({
"analysis_options.yaml": "linter: rules:",
"lib/main.dart": "void main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "lib/main.dart"), dir);
assertEquals(root, dir, "Should find root at analysis_options.yaml location");
});
});
test("dart: nested package finds nearest root", async () => {
await withTempDir({
"pubspec.yaml": "name: monorepo",
"packages/core/pubspec.yaml": "name: core",
"packages/core/lib/core.dart": "void init() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "packages/core/lib/core.dart"), dir);
assertEquals(root, join(dir, "packages/core"), "Should find nearest pubspec.yaml");
});
});
// ============================================================================
// Rust root detection tests
// ============================================================================
test("rust: finds root with Cargo.toml", async () => {
await withTempDir({
"Cargo.toml": "[package]\nname = \"my_crate\"",
"src/lib.rs": "pub fn hello() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
const root = server.findRoot(join(dir, "src/lib.rs"), dir);
assertEquals(root, dir, "Should find root at Cargo.toml location");
});
});
test("rust: nested workspace member finds nearest Cargo.toml", async () => {
await withTempDir({
"Cargo.toml": "[workspace]\nmembers = [\"crates/*\"]",
"crates/core/Cargo.toml": "[package]\nname = \"core\"",
"crates/core/src/lib.rs": "pub fn init() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
const root = server.findRoot(join(dir, "crates/core/src/lib.rs"), dir);
assertEquals(root, join(dir, "crates/core"), "Should find nearest Cargo.toml");
});
});
// ============================================================================
// Go root detection tests (including gopls bug fix verification)
// ============================================================================
test("gopls: finds root with go.mod", async () => {
await withTempDir({
"go.mod": "module example.com/myapp",
"main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "main.go"), dir);
assertEquals(root, dir, "Should find root at go.mod location");
});
});
test("gopls: finds root with go.work (workspace)", async () => {
await withTempDir({
"go.work": "go 1.21\nuse ./app",
"app/go.mod": "module example.com/app",
"app/main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "app/main.go"), dir);
assertEquals(root, dir, "Should find root at go.work location (workspace root)");
});
});
test("gopls: prefers go.work over go.mod", async () => {
await withTempDir({
"go.work": "go 1.21\nuse ./app",
"go.mod": "module example.com/root",
"app/go.mod": "module example.com/app",
"app/main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "app/main.go"), dir);
// go.work is found first, so it should return the go.work location
assertEquals(root, dir, "Should prefer go.work over go.mod");
});
});
test("gopls: returns undefined when no go.mod or go.work (bug fix verification)", async () => {
await withTempDir({
"main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "main.go"), dir);
// This test verifies the bug fix: previously this would return undefined
// because `undefined !== cwd` was true, skipping the go.mod check
assertEquals(root, undefined, "Should return undefined when no go.mod or go.work");
});
});
test("gopls: finds go.mod when go.work not present (bug fix verification)", async () => {
await withTempDir({
"go.mod": "module example.com/myapp",
"cmd/server/main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "cmd/server/main.go"), dir);
// This is the key test for the bug fix
// Previously: findRoot(go.work) returns undefined, then `undefined !== cwd` is true,
// so it would return undefined without checking go.mod
// After fix: if go.work not found, falls through to check go.mod
assertEquals(root, dir, "Should find go.mod when go.work is not present");
});
});
// ============================================================================
// Kotlin root detection tests
// ============================================================================
test("kotlin: finds root with settings.gradle.kts", async () => {
await withTempDir({
"settings.gradle.kts": "rootProject.name = \"myapp\"",
"app/src/main/kotlin/Main.kt": "fun main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "kotlin")!;
const root = server.findRoot(join(dir, "app/src/main/kotlin/Main.kt"), dir);
assertEquals(root, dir, "Should find root at settings.gradle.kts location");
});
});
test("kotlin: prefers settings.gradle(.kts) over nested build.gradle", async () => {
await withTempDir({
"settings.gradle": "rootProject.name = 'root'",
"app/build.gradle": "plugins {}",
"app/src/main/kotlin/Main.kt": "fun main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "kotlin")!;
const root = server.findRoot(join(dir, "app/src/main/kotlin/Main.kt"), dir);
assertEquals(root, dir, "Should prefer settings.gradle at workspace root");
});
});
test("kotlin: finds root with pom.xml", async () => {
await withTempDir({
"pom.xml": "<project></project>",
"src/main/kotlin/Main.kt": "fun main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "kotlin")!;
const root = server.findRoot(join(dir, "src/main/kotlin/Main.kt"), dir);
assertEquals(root, dir, "Should find root at pom.xml location");
});
});
// ============================================================================
// Swift root detection tests
// ============================================================================
test("swift: finds root with Package.swift", async () => {
await withTempDir({
"Package.swift": "// swift-tools-version: 5.9",
"Sources/App/main.swift": "print(\"hi\")",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "swift")!;
const root = server.findRoot(join(dir, "Sources/App/main.swift"), dir);
assertEquals(root, dir, "Should find root at Package.swift location");
});
});
test("swift: finds root with Xcode project", async () => {
await withTempDir({
"MyApp.xcodeproj/project.pbxproj": "// pbxproj",
"MyApp/main.swift": "print(\"hi\")",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "swift")!;
const root = server.findRoot(join(dir, "MyApp/main.swift"), dir);
assertEquals(root, dir, "Should find root at Xcode project location");
});
});
test("swift: finds root with Xcode workspace", async () => {
await withTempDir({
"MyApp.xcworkspace/contents.xcworkspacedata": "<Workspace/>",
"MyApp/main.swift": "print(\"hi\")",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "swift")!;
const root = server.findRoot(join(dir, "MyApp/main.swift"), dir);
assertEquals(root, dir, "Should find root at Xcode workspace location");
});
});
// ============================================================================
// Python root detection tests
// ============================================================================
test("pyright: finds root with pyproject.toml", async () => {
await withTempDir({
"pyproject.toml": "[project]\nname = \"myapp\"",
"src/main.py": "print('hello')",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "src/main.py"), dir);
assertEquals(root, dir, "Should find root at pyproject.toml location");
});
});
test("pyright: finds root with setup.py", async () => {
await withTempDir({
"setup.py": "from setuptools import setup",
"myapp/main.py": "print('hello')",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "myapp/main.py"), dir);
assertEquals(root, dir, "Should find root at setup.py location");
});
});
test("pyright: finds root with requirements.txt", async () => {
await withTempDir({
"requirements.txt": "flask>=2.0",
"app.py": "from flask import Flask",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "app.py"), dir);
assertEquals(root, dir, "Should find root at requirements.txt location");
});
});
// ============================================================================
// URI construction tests (pathToFileURL)
// ============================================================================
test("pathToFileURL: handles simple paths", async () => {
const uri = pathToFileURL("/home/user/project/file.ts").href;
assertEquals(uri, "file:///home/user/project/file.ts", "Should create proper file URI");
});
test("pathToFileURL: encodes special characters", async () => {
const uri = pathToFileURL("/home/user/my project/file.ts").href;
assert(uri.includes("my%20project"), "Should URL-encode spaces");
});
test("pathToFileURL: handles unicode", async () => {
const uri = pathToFileURL("/home/user/项目/file.ts").href;
// pathToFileURL properly encodes unicode
assert(uri.startsWith("file:///"), "Should start with file:///");
assert(uri.includes("file.ts"), "Should contain filename");
});
// ============================================================================
// Vue/Svelte root detection tests
// ============================================================================
test("vue: finds root with package.json", async () => {
await withTempDir({
"package.json": "{}",
"src/App.vue": "<template></template>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "vue")!;
const root = server.findRoot(join(dir, "src/App.vue"), dir);
assertEquals(root, dir, "Should find root at package.json location");
});
});
test("vue: finds root with vite.config.ts", async () => {
await withTempDir({
"vite.config.ts": "export default {}",
"src/App.vue": "<template></template>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "vue")!;
const root = server.findRoot(join(dir, "src/App.vue"), dir);
assertEquals(root, dir, "Should find root at vite.config.ts location");
});
});
test("svelte: finds root with svelte.config.js", async () => {
await withTempDir({
"svelte.config.js": "export default {}",
"src/App.svelte": "<script></script>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "svelte")!;
const root = server.findRoot(join(dir, "src/App.svelte"), dir);
assertEquals(root, dir, "Should find root at svelte.config.js location");
});
});
// ============================================================================
// Additional Rust tests (parity with TypeScript)
// ============================================================================
test("rust: finds root in src subdirectory", async () => {
await withTempDir({
"Cargo.toml": "[package]\nname = \"myapp\"",
"src/main.rs": "fn main() {}",
"src/lib.rs": "pub mod utils;",
"src/utils/mod.rs": "pub fn helper() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
const root = server.findRoot(join(dir, "src/utils/mod.rs"), dir);
assertEquals(root, dir, "Should find root from deeply nested src file");
});
});
test("rust: workspace with multiple crates", async () => {
await withTempDir({
"Cargo.toml": "[workspace]\nmembers = [\"crates/*\"]",
"crates/api/Cargo.toml": "[package]\nname = \"api\"",
"crates/api/src/lib.rs": "pub fn serve() {}",
"crates/core/Cargo.toml": "[package]\nname = \"core\"",
"crates/core/src/lib.rs": "pub fn init() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
// Each crate should find its own Cargo.toml
const apiRoot = server.findRoot(join(dir, "crates/api/src/lib.rs"), dir);
const coreRoot = server.findRoot(join(dir, "crates/core/src/lib.rs"), dir);
assertEquals(apiRoot, join(dir, "crates/api"), "API crate should find its Cargo.toml");
assertEquals(coreRoot, join(dir, "crates/core"), "Core crate should find its Cargo.toml");
});
});
test("rust: returns undefined when no Cargo.toml", async () => {
await withTempDir({
"main.rs": "fn main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
const root = server.findRoot(join(dir, "main.rs"), dir);
assertEquals(root, undefined, "Should return undefined when no Cargo.toml");
});
});
// ============================================================================
// Additional Dart tests (parity with TypeScript)
// ============================================================================
test("dart: Flutter project with pubspec.yaml", async () => {
await withTempDir({
"pubspec.yaml": "name: my_flutter_app\ndependencies:\n flutter:\n sdk: flutter",
"lib/main.dart": "import 'package:flutter/material.dart';",
"lib/screens/home.dart": "class HomeScreen {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "lib/screens/home.dart"), dir);
assertEquals(root, dir, "Should find root for Flutter project");
});
});
test("dart: returns undefined when no marker files", async () => {
await withTempDir({
"main.dart": "void main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "main.dart"), dir);
assertEquals(root, undefined, "Should return undefined when no pubspec.yaml or analysis_options.yaml");
});
});
test("dart: monorepo with multiple packages", async () => {
await withTempDir({
"pubspec.yaml": "name: monorepo",
"packages/auth/pubspec.yaml": "name: auth",
"packages/auth/lib/auth.dart": "class Auth {}",
"packages/ui/pubspec.yaml": "name: ui",
"packages/ui/lib/widgets.dart": "class Button {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const authRoot = server.findRoot(join(dir, "packages/auth/lib/auth.dart"), dir);
const uiRoot = server.findRoot(join(dir, "packages/ui/lib/widgets.dart"), dir);
assertEquals(authRoot, join(dir, "packages/auth"), "Auth package should find its pubspec");
assertEquals(uiRoot, join(dir, "packages/ui"), "UI package should find its pubspec");
});
});
// ============================================================================
// Additional Python tests (parity with TypeScript)
// ============================================================================
test("pyright: finds root with pyrightconfig.json", async () => {
await withTempDir({
"pyrightconfig.json": "{}",
"src/app.py": "print('hello')",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "src/app.py"), dir);
assertEquals(root, dir, "Should find root at pyrightconfig.json location");
});
});
test("pyright: returns undefined when no marker files", async () => {
await withTempDir({
"script.py": "print('hello')",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "script.py"), dir);
assertEquals(root, undefined, "Should return undefined when no Python project markers");
});
});
test("pyright: monorepo with multiple packages", async () => {
await withTempDir({
"pyproject.toml": "[project]\nname = \"monorepo\"",
"packages/api/pyproject.toml": "[project]\nname = \"api\"",
"packages/api/src/main.py": "from flask import Flask",
"packages/worker/pyproject.toml": "[project]\nname = \"worker\"",
"packages/worker/src/tasks.py": "def process(): pass",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const apiRoot = server.findRoot(join(dir, "packages/api/src/main.py"), dir);
const workerRoot = server.findRoot(join(dir, "packages/worker/src/tasks.py"), dir);
assertEquals(apiRoot, join(dir, "packages/api"), "API package should find its pyproject.toml");
assertEquals(workerRoot, join(dir, "packages/worker"), "Worker package should find its pyproject.toml");
});
});
// ============================================================================
// Additional Go tests
// ============================================================================
test("gopls: monorepo with multiple modules", async () => {
await withTempDir({
"go.work": "go 1.21\nuse (\n ./api\n ./worker\n)",
"api/go.mod": "module example.com/api",
"api/main.go": "package main",
"worker/go.mod": "module example.com/worker",
"worker/main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
// With go.work present, all files should use workspace root
const apiRoot = server.findRoot(join(dir, "api/main.go"), dir);
const workerRoot = server.findRoot(join(dir, "worker/main.go"), dir);
assertEquals(apiRoot, dir, "API module should use go.work root");
assertEquals(workerRoot, dir, "Worker module should use go.work root");
});
});
test("gopls: nested cmd directory", async () => {
await withTempDir({
"go.mod": "module example.com/myapp",
"cmd/server/main.go": "package main",
"cmd/cli/main.go": "package main",
"internal/db/db.go": "package db",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const serverRoot = server.findRoot(join(dir, "cmd/server/main.go"), dir);
const cliRoot = server.findRoot(join(dir, "cmd/cli/main.go"), dir);
const dbRoot = server.findRoot(join(dir, "internal/db/db.go"), dir);
assertEquals(serverRoot, dir, "cmd/server should find go.mod at root");
assertEquals(cliRoot, dir, "cmd/cli should find go.mod at root");
assertEquals(dbRoot, dir, "internal/db should find go.mod at root");
});
});
// ============================================================================
// Additional TypeScript tests
// ============================================================================
test("typescript: pnpm workspace", async () => {
await withTempDir({
"package.json": "{}",
"pnpm-workspace.yaml": "packages:\n - packages/*",
"packages/web/package.json": "{}",
"packages/web/src/App.tsx": "export const App = () => null;",
"packages/api/package.json": "{}",
"packages/api/src/index.ts": "export const handler = () => {};",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const webRoot = server.findRoot(join(dir, "packages/web/src/App.tsx"), dir);
const apiRoot = server.findRoot(join(dir, "packages/api/src/index.ts"), dir);
assertEquals(webRoot, join(dir, "packages/web"), "Web package should find its package.json");
assertEquals(apiRoot, join(dir, "packages/api"), "API package should find its package.json");
});
});
test("typescript: returns undefined when no config files", async () => {
await withTempDir({
"script.ts": "const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "script.ts"), dir);
assertEquals(root, undefined, "Should return undefined when no package.json or tsconfig.json");
});
});
test("typescript: prefers nearest tsconfig over package.json", async () => {
await withTempDir({
"package.json": "{}",
"apps/web/tsconfig.json": "{}",
"apps/web/src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "apps/web/src/index.ts"), dir);
// Should find tsconfig.json first (it's nearer than root package.json)
assertEquals(root, join(dir, "apps/web"), "Should find nearest config file");
});
});
// ============================================================================
// Additional Vue/Svelte tests
// ============================================================================
test("vue: Nuxt project", async () => {
await withTempDir({
"package.json": "{}",
"nuxt.config.ts": "export default {}",
"pages/index.vue": "<template></template>",
"components/Button.vue": "<template></template>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "vue")!;
const pagesRoot = server.findRoot(join(dir, "pages/index.vue"), dir);
const componentsRoot = server.findRoot(join(dir, "components/Button.vue"), dir);
assertEquals(pagesRoot, dir, "Pages should find root");
assertEquals(componentsRoot, dir, "Components should find root");
});
});
test("vue: returns undefined when no config", async () => {
await withTempDir({
"App.vue": "<template></template>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "vue")!;
const root = server.findRoot(join(dir, "App.vue"), dir);
assertEquals(root, undefined, "Should return undefined when no package.json or vite.config");
});
});
test("svelte: SvelteKit project", async () => {
await withTempDir({
"package.json": "{}",
"svelte.config.js": "export default {}",
"src/routes/+page.svelte": "<script></script>",
"src/lib/components/Button.svelte": "<script></script>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "svelte")!;
const routeRoot = server.findRoot(join(dir, "src/routes/+page.svelte"), dir);
const libRoot = server.findRoot(join(dir, "src/lib/components/Button.svelte"), dir);
assertEquals(routeRoot, dir, "Route should find root");
assertEquals(libRoot, dir, "Lib component should find root");
});
});
test("svelte: returns undefined when no config", async () => {
await withTempDir({
"App.svelte": "<script></script>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "svelte")!;
const root = server.findRoot(join(dir, "App.svelte"), dir);
assertEquals(root, undefined, "Should return undefined when no package.json or svelte.config.js");
});
});
// ============================================================================
// Stop boundary tests (findNearestFile respects cwd boundary)
// ============================================================================
test("stop boundary: does not search above cwd", async () => {
await withTempDir({
"package.json": "{}", // This is at root
"projects/myapp/src/index.ts": "export const x = 1;",
// Note: no package.json in projects/myapp
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
// When cwd is set to projects/myapp, it should NOT find the root package.json
const projectDir = join(dir, "projects/myapp");
const root = server.findRoot(join(projectDir, "src/index.ts"), projectDir);
assertEquals(root, undefined, "Should not find package.json above cwd boundary");
});
});
test("stop boundary: finds marker at cwd level", async () => {
await withTempDir({
"projects/myapp/package.json": "{}",
"projects/myapp/src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const projectDir = join(dir, "projects/myapp");
const root = server.findRoot(join(projectDir, "src/index.ts"), projectDir);
assertEquals(root, projectDir, "Should find package.json at cwd level");
});
});
// ============================================================================
// Edge cases
// ============================================================================
test("edge: deeply nested file finds correct root", async () => {
await withTempDir({
"package.json": "{}",
"src/components/ui/buttons/primary/Button.tsx": "export const Button = () => null;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "src/components/ui/buttons/primary/Button.tsx"), dir);
assertEquals(root, dir, "Should find root even for deeply nested files");
});
});
test("edge: file at root level finds root", async () => {
await withTempDir({
"package.json": "{}",
"index.ts": "console.log('root');",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "index.ts"), dir);
assertEquals(root, dir, "Should find root for file at root level");
});
});
test("edge: no marker files returns undefined", async () => {
await withTempDir({
"random.ts": "const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "random.ts"), dir);
assertEquals(root, undefined, "Should return undefined when no marker files");
});
});
// ============================================================================
// Run tests
// ============================================================================
async function runTests(): Promise<void> {
console.log("Running LSP tests...\n");
const results: TestResult[] = [];
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
await fn();
results.push({ name, passed: true });
console.log(` ${name}... ✓`);
passed++;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
results.push({ name, passed: false, error: errorMsg });
console.log(` ${name}... ✗`);
console.log(` Error: ${errorMsg}\n`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) {
process.exit(1);
}
}
runTests();

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["ES2022"]
},
"include": ["*.ts"]
}

View File

@@ -0,0 +1,65 @@
import { wrapTextWithAnsi } from "@mariozechner/pi-tui";
const INLINE_NOTE_SEPARATOR = " — note: ";
const INLINE_EDIT_CURSOR = "▍";
export const INLINE_NOTE_WRAP_PADDING = 2;
function sanitizeNoteForInlineDisplay(rawNote: string): string {
return rawNote.replace(/[\r\n\t]/g, " ").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
}
function truncateTextKeepingTail(text: string, maxLength: number): string {
if (maxLength <= 0) return "";
if (text.length <= maxLength) return text;
if (maxLength === 1) return "…";
return `${text.slice(-(maxLength - 1))}`;
}
function truncateTextKeepingHead(text: string, maxLength: number): string {
if (maxLength <= 0) return "";
if (text.length <= maxLength) return text;
if (maxLength === 1) return "…";
return `${text.slice(0, maxLength - 1)}`;
}
export function buildOptionLabelWithInlineNote(
baseOptionLabel: string,
rawNote: string,
isEditingNote: boolean,
maxInlineLabelLength?: number,
): string {
const sanitizedNote = sanitizeNoteForInlineDisplay(rawNote);
if (!isEditingNote && sanitizedNote.trim().length === 0) {
return baseOptionLabel;
}
const labelPrefix = `${baseOptionLabel}${INLINE_NOTE_SEPARATOR}`;
const inlineNote = isEditingNote ? `${sanitizedNote}${INLINE_EDIT_CURSOR}` : sanitizedNote.trim();
const inlineLabel = `${labelPrefix}${inlineNote}`;
if (maxInlineLabelLength == null) {
return inlineLabel;
}
return isEditingNote
? truncateTextKeepingTail(inlineLabel, maxInlineLabelLength)
: truncateTextKeepingHead(inlineLabel, maxInlineLabelLength);
}
export function buildWrappedOptionLabelWithInlineNote(
baseOptionLabel: string,
rawNote: string,
isEditingNote: boolean,
maxInlineLabelLength: number,
wrapPadding = INLINE_NOTE_WRAP_PADDING,
): string[] {
const inlineLabel = buildOptionLabelWithInlineNote(baseOptionLabel, rawNote, isEditingNote);
const sanitizedWrapPadding = Number.isFinite(wrapPadding) ? Math.max(0, Math.floor(wrapPadding)) : 0;
const sanitizedMaxInlineLabelLength = Number.isFinite(maxInlineLabelLength)
? Math.max(1, Math.floor(maxInlineLabelLength))
: 1;
const wrapWidth = Math.max(1, sanitizedMaxInlineLabelLength - sanitizedWrapPadding);
const wrappedLines = wrapTextWithAnsi(inlineLabel, wrapWidth);
return wrappedLines.length > 0 ? wrappedLines : [""];
}

View File

@@ -0,0 +1,223 @@
import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
import {
OTHER_OPTION,
appendRecommendedTagToOptionLabels,
buildSingleSelectionResult,
type AskOption,
type AskSelection,
} from "./ask-logic";
import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note";
interface SingleQuestionInput {
question: string;
options: AskOption[];
recommended?: number;
}
interface InlineSelectionResult {
cancelled: boolean;
selectedOption?: string;
note?: string;
}
function resolveInitialCursorIndexFromRecommendedOption(
recommendedOptionIndex: number | undefined,
optionCount: number,
): number {
if (recommendedOptionIndex == null) return 0;
if (recommendedOptionIndex < 0 || recommendedOptionIndex >= optionCount) return 0;
return recommendedOptionIndex;
}
export async function askSingleQuestionWithInlineNote(
ui: ExtensionUIContext,
questionInput: SingleQuestionInput,
): Promise<AskSelection> {
const baseOptionLabels = questionInput.options.map((option) => option.label);
const optionLabelsWithRecommendedTag = appendRecommendedTagToOptionLabels(
baseOptionLabels,
questionInput.recommended,
);
const selectableOptionLabels = [...optionLabelsWithRecommendedTag, OTHER_OPTION];
const initialCursorIndex = resolveInitialCursorIndexFromRecommendedOption(
questionInput.recommended,
optionLabelsWithRecommendedTag.length,
);
const result = await ui.custom<InlineSelectionResult>((tui, theme, _keybindings, done) => {
let cursorOptionIndex = initialCursorIndex;
let isNoteEditorOpen = false;
let cachedRenderedLines: string[] | undefined;
const noteByOptionIndex = new Map<number, string>();
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
selectList: {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
},
};
const noteEditor = new Editor(tui, editorTheme);
const requestUiRerender = () => {
cachedRenderedLines = undefined;
tui.requestRender();
};
const getRawNoteForOption = (optionIndex: number): string => noteByOptionIndex.get(optionIndex) ?? "";
const getTrimmedNoteForOption = (optionIndex: number): string => getRawNoteForOption(optionIndex).trim();
const loadCurrentNoteIntoEditor = () => {
noteEditor.setText(getRawNoteForOption(cursorOptionIndex));
};
const saveCurrentNoteFromEditor = (value: string) => {
noteByOptionIndex.set(cursorOptionIndex, value);
};
const submitCurrentSelection = (selectedOptionLabel: string, note: string) => {
done({
cancelled: false,
selectedOption: selectedOptionLabel,
note,
});
};
noteEditor.onChange = (value) => {
saveCurrentNoteFromEditor(value);
requestUiRerender();
};
noteEditor.onSubmit = (value) => {
saveCurrentNoteFromEditor(value);
const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex];
const trimmedNote = value.trim();
if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) {
requestUiRerender();
return;
}
submitCurrentSelection(selectedOptionLabel, trimmedNote);
};
const render = (width: number): string[] => {
if (cachedRenderedLines) return cachedRenderedLines;
const renderedLines: string[] = [];
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
addLine(theme.fg("accent", "─".repeat(width)));
for (const questionLine of wrapTextWithAnsi(questionInput.question, Math.max(1, width - 1))) {
addLine(` ${theme.fg("text", questionLine)}`);
}
renderedLines.push("");
for (let optionIndex = 0; optionIndex < selectableOptionLabels.length; optionIndex++) {
const optionLabel = selectableOptionLabels[optionIndex];
const isCursorOption = optionIndex === cursorOptionIndex;
const isEditingThisOption = isNoteEditorOpen && isCursorOption;
const cursorPrefixText = isCursorOption ? "→ " : " ";
const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText;
const bullet = isCursorOption ? "●" : "○";
const markerText = `${bullet} `;
const optionColor = isCursorOption ? "accent" : "text";
const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText);
const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote(
optionLabel,
getRawNoteForOption(optionIndex),
isEditingThisOption,
Math.max(1, width - prefixWidth),
INLINE_NOTE_WRAP_PADDING,
);
const continuationPrefix = " ".repeat(prefixWidth);
addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
for (const wrappedLine of wrappedInlineLabelLines.slice(1)) {
addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
}
}
renderedLines.push("");
if (isNoteEditorOpen) {
addLine(theme.fg("dim", " Typing note inline • Enter submit • Tab/Esc stop editing"));
} else if (getTrimmedNoteForOption(cursorOptionIndex).length > 0) {
addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab edit note • Esc cancel"));
} else {
addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab add note • Esc cancel"));
}
addLine(theme.fg("accent", "─".repeat(width)));
cachedRenderedLines = renderedLines;
return renderedLines;
};
const handleInput = (data: string) => {
if (isNoteEditorOpen) {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
isNoteEditorOpen = false;
requestUiRerender();
return;
}
noteEditor.handleInput(data);
requestUiRerender();
return;
}
if (matchesKey(data, Key.up)) {
cursorOptionIndex = Math.max(0, cursorOptionIndex - 1);
requestUiRerender();
return;
}
if (matchesKey(data, Key.down)) {
cursorOptionIndex = Math.min(selectableOptionLabels.length - 1, cursorOptionIndex + 1);
requestUiRerender();
return;
}
if (matchesKey(data, Key.tab)) {
isNoteEditorOpen = true;
loadCurrentNoteIntoEditor();
requestUiRerender();
return;
}
if (matchesKey(data, Key.enter)) {
const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex];
const trimmedNote = getTrimmedNoteForOption(cursorOptionIndex);
if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) {
isNoteEditorOpen = true;
loadCurrentNoteIntoEditor();
requestUiRerender();
return;
}
submitCurrentSelection(selectedOptionLabel, trimmedNote);
return;
}
if (matchesKey(data, Key.escape)) {
done({ cancelled: true });
}
};
return {
render,
invalidate: () => {
cachedRenderedLines = undefined;
},
handleInput,
};
});
if (result.cancelled || !result.selectedOption) {
return { selectedOptions: [] };
}
return buildSingleSelectionResult(result.selectedOption, result.note);
}

View File

@@ -0,0 +1,98 @@
export const OTHER_OPTION = "Other (type your own)";
const RECOMMENDED_OPTION_TAG = " (Recommended)";
export interface AskOption {
label: string;
}
export interface AskQuestion {
id: string;
question: string;
options: AskOption[];
multi?: boolean;
recommended?: number;
}
export interface AskSelection {
selectedOptions: string[];
customInput?: string;
}
export function appendRecommendedTagToOptionLabels(
optionLabels: string[],
recommendedOptionIndex?: number,
): string[] {
if (
recommendedOptionIndex == null ||
recommendedOptionIndex < 0 ||
recommendedOptionIndex >= optionLabels.length
) {
return optionLabels;
}
return optionLabels.map((optionLabel, optionIndex) => {
if (optionIndex !== recommendedOptionIndex) return optionLabel;
if (optionLabel.endsWith(RECOMMENDED_OPTION_TAG)) return optionLabel;
return `${optionLabel}${RECOMMENDED_OPTION_TAG}`;
});
}
function removeRecommendedTagFromOptionLabel(optionLabel: string): string {
if (!optionLabel.endsWith(RECOMMENDED_OPTION_TAG)) {
return optionLabel;
}
return optionLabel.slice(0, -RECOMMENDED_OPTION_TAG.length);
}
export function buildSingleSelectionResult(selectedOptionLabel: string, note?: string): AskSelection {
const normalizedSelectedOption = removeRecommendedTagFromOptionLabel(selectedOptionLabel);
const normalizedNote = note?.trim();
if (normalizedSelectedOption === OTHER_OPTION) {
if (normalizedNote) {
return { selectedOptions: [], customInput: normalizedNote };
}
return { selectedOptions: [] };
}
if (normalizedNote) {
return { selectedOptions: [`${normalizedSelectedOption} - ${normalizedNote}`] };
}
return { selectedOptions: [normalizedSelectedOption] };
}
export function buildMultiSelectionResult(
optionLabels: string[],
selectedOptionIndexes: number[],
optionNotes: string[],
otherOptionIndex: number,
): AskSelection {
const selectedOptionSet = new Set(selectedOptionIndexes);
const selectedOptions: string[] = [];
let customInput: string | undefined;
for (let optionIndex = 0; optionIndex < optionLabels.length; optionIndex++) {
if (!selectedOptionSet.has(optionIndex)) continue;
const optionLabel = removeRecommendedTagFromOptionLabel(optionLabels[optionIndex]);
const optionNote = optionNotes[optionIndex]?.trim();
if (optionIndex === otherOptionIndex) {
if (optionNote) customInput = optionNote;
continue;
}
if (optionNote) {
selectedOptions.push(`${optionLabel} - ${optionNote}`);
} else {
selectedOptions.push(optionLabel);
}
}
if (customInput) {
return { selectedOptions, customInput };
}
return { selectedOptions };
}

View File

@@ -0,0 +1,514 @@
import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
import {
OTHER_OPTION,
appendRecommendedTagToOptionLabels,
buildMultiSelectionResult,
buildSingleSelectionResult,
type AskQuestion,
type AskSelection,
} from "./ask-logic";
import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note";
interface PreparedQuestion {
id: string;
question: string;
options: string[];
tabLabel: string;
multi: boolean;
otherOptionIndex: number;
}
interface TabsUIState {
cancelled: boolean;
selectedOptionIndexesByQuestion: number[][];
noteByQuestionByOption: string[][];
}
export function formatSelectionForSubmitReview(selection: AskSelection, isMulti: boolean): string {
const hasSelectedOptions = selection.selectedOptions.length > 0;
const hasCustomInput = Boolean(selection.customInput);
if (hasSelectedOptions && hasCustomInput) {
const selectedPart = isMulti
? `[${selection.selectedOptions.join(", ")}]`
: selection.selectedOptions[0];
return `${selectedPart} + Other: ${selection.customInput}`;
}
if (hasCustomInput) {
return `Other: ${selection.customInput}`;
}
if (hasSelectedOptions) {
return isMulti ? `[${selection.selectedOptions.join(", ")}]` : selection.selectedOptions[0];
}
return "(not answered)";
}
function clampIndex(index: number | undefined, maxExclusive: number): number {
if (index == null || Number.isNaN(index) || maxExclusive <= 0) return 0;
if (index < 0) return 0;
if (index >= maxExclusive) return maxExclusive - 1;
return index;
}
function normalizeTabLabel(id: string, fallback: string): string {
const normalized = id.trim().replace(/[_-]+/g, " ");
return normalized.length > 0 ? normalized : fallback;
}
function buildSelectionForQuestion(
question: PreparedQuestion,
selectedOptionIndexes: number[],
noteByOptionIndex: string[],
): AskSelection {
if (selectedOptionIndexes.length === 0) {
return { selectedOptions: [] };
}
if (question.multi) {
return buildMultiSelectionResult(question.options, selectedOptionIndexes, noteByOptionIndex, question.otherOptionIndex);
}
const selectedOptionIndex = selectedOptionIndexes[0];
const selectedOptionLabel = question.options[selectedOptionIndex] ?? OTHER_OPTION;
const note = noteByOptionIndex[selectedOptionIndex] ?? "";
return buildSingleSelectionResult(selectedOptionLabel, note);
}
function isQuestionSelectionValid(
question: PreparedQuestion,
selectedOptionIndexes: number[],
noteByOptionIndex: string[],
): boolean {
if (selectedOptionIndexes.length === 0) return false;
if (!selectedOptionIndexes.includes(question.otherOptionIndex)) return true;
const otherNote = noteByOptionIndex[question.otherOptionIndex]?.trim() ?? "";
return otherNote.length > 0;
}
function createTabsUiStateSnapshot(
cancelled: boolean,
selectedOptionIndexesByQuestion: number[][],
noteByQuestionByOption: string[][],
): TabsUIState {
return {
cancelled,
selectedOptionIndexesByQuestion: selectedOptionIndexesByQuestion.map((indexes) => [...indexes]),
noteByQuestionByOption: noteByQuestionByOption.map((notes) => [...notes]),
};
}
function addIndexToSelection(selectedOptionIndexes: number[], optionIndex: number): number[] {
if (selectedOptionIndexes.includes(optionIndex)) return selectedOptionIndexes;
return [...selectedOptionIndexes, optionIndex].sort((a, b) => a - b);
}
function removeIndexFromSelection(selectedOptionIndexes: number[], optionIndex: number): number[] {
return selectedOptionIndexes.filter((index) => index !== optionIndex);
}
export async function askQuestionsWithTabs(
ui: ExtensionUIContext,
questions: AskQuestion[],
): Promise<{ cancelled: boolean; selections: AskSelection[] }> {
const preparedQuestions: PreparedQuestion[] = questions.map((question, questionIndex) => {
const baseOptionLabels = question.options.map((option) => option.label);
const optionLabels = [...appendRecommendedTagToOptionLabels(baseOptionLabels, question.recommended), OTHER_OPTION];
return {
id: question.id,
question: question.question,
options: optionLabels,
tabLabel: normalizeTabLabel(question.id, `Q${questionIndex + 1}`),
multi: question.multi === true,
otherOptionIndex: optionLabels.length - 1,
};
});
const initialCursorOptionIndexByQuestion = preparedQuestions.map((preparedQuestion, questionIndex) =>
clampIndex(questions[questionIndex].recommended, preparedQuestion.options.length),
);
const result = await ui.custom<TabsUIState>((tui, theme, _keybindings, done) => {
let activeTabIndex = 0;
let isNoteEditorOpen = false;
let cachedRenderedLines: string[] | undefined;
const cursorOptionIndexByQuestion = [...initialCursorOptionIndexByQuestion];
const selectedOptionIndexesByQuestion = preparedQuestions.map(() => [] as number[]);
const noteByQuestionByOption = preparedQuestions.map((preparedQuestion) =>
Array(preparedQuestion.options.length).fill("") as string[],
);
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
selectList: {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
},
};
const noteEditor = new Editor(tui, editorTheme);
const submitTabIndex = preparedQuestions.length;
const requestUiRerender = () => {
cachedRenderedLines = undefined;
tui.requestRender();
};
const getActiveQuestionIndex = (): number | null => {
if (activeTabIndex >= preparedQuestions.length) return null;
return activeTabIndex;
};
const getQuestionNote = (questionIndex: number, optionIndex: number): string =>
noteByQuestionByOption[questionIndex]?.[optionIndex] ?? "";
const getTrimmedQuestionNote = (questionIndex: number, optionIndex: number): string =>
getQuestionNote(questionIndex, optionIndex).trim();
const isAllQuestionSelectionsValid = (): boolean =>
preparedQuestions.every((preparedQuestion, questionIndex) =>
isQuestionSelectionValid(
preparedQuestion,
selectedOptionIndexesByQuestion[questionIndex],
noteByQuestionByOption[questionIndex],
),
);
const openNoteEditorForActiveOption = () => {
const questionIndex = getActiveQuestionIndex();
if (questionIndex == null) return;
isNoteEditorOpen = true;
const optionIndex = cursorOptionIndexByQuestion[questionIndex];
noteEditor.setText(getQuestionNote(questionIndex, optionIndex));
requestUiRerender();
};
const advanceToNextTabOrSubmit = () => {
activeTabIndex = Math.min(submitTabIndex, activeTabIndex + 1);
};
noteEditor.onChange = (value) => {
const questionIndex = getActiveQuestionIndex();
if (questionIndex == null) return;
const optionIndex = cursorOptionIndexByQuestion[questionIndex];
noteByQuestionByOption[questionIndex][optionIndex] = value;
requestUiRerender();
};
noteEditor.onSubmit = (value) => {
const questionIndex = getActiveQuestionIndex();
if (questionIndex == null) return;
const preparedQuestion = preparedQuestions[questionIndex];
const optionIndex = cursorOptionIndexByQuestion[questionIndex];
noteByQuestionByOption[questionIndex][optionIndex] = value;
const trimmedNote = value.trim();
if (preparedQuestion.multi) {
if (trimmedNote.length > 0) {
selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection(
selectedOptionIndexesByQuestion[questionIndex],
optionIndex,
);
}
if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) {
requestUiRerender();
return;
}
isNoteEditorOpen = false;
requestUiRerender();
return;
}
selectedOptionIndexesByQuestion[questionIndex] = [optionIndex];
if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) {
requestUiRerender();
return;
}
isNoteEditorOpen = false;
advanceToNextTabOrSubmit();
requestUiRerender();
};
const renderTabs = (): string => {
const tabParts: string[] = ["← "];
for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) {
const preparedQuestion = preparedQuestions[questionIndex];
const isActiveTab = questionIndex === activeTabIndex;
const isQuestionValid = isQuestionSelectionValid(
preparedQuestion,
selectedOptionIndexesByQuestion[questionIndex],
noteByQuestionByOption[questionIndex],
);
const statusIcon = isQuestionValid ? "■" : "□";
const tabLabel = ` ${statusIcon} ${preparedQuestion.tabLabel} `;
const styledTabLabel = isActiveTab
? theme.bg("selectedBg", theme.fg("text", tabLabel))
: theme.fg(isQuestionValid ? "success" : "muted", tabLabel);
tabParts.push(`${styledTabLabel} `);
}
const isSubmitTabActive = activeTabIndex === submitTabIndex;
const canSubmit = isAllQuestionSelectionsValid();
const submitLabel = " ✓ Submit ";
const styledSubmitLabel = isSubmitTabActive
? theme.bg("selectedBg", theme.fg("text", submitLabel))
: theme.fg(canSubmit ? "success" : "dim", submitLabel);
tabParts.push(`${styledSubmitLabel}`);
return tabParts.join("");
};
const renderSubmitTab = (width: number, renderedLines: string[]): void => {
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
addLine(theme.fg("accent", theme.bold(" Review answers")));
renderedLines.push("");
for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) {
const preparedQuestion = preparedQuestions[questionIndex];
const selection = buildSelectionForQuestion(
preparedQuestion,
selectedOptionIndexesByQuestion[questionIndex],
noteByQuestionByOption[questionIndex],
);
const value = formatSelectionForSubmitReview(selection, preparedQuestion.multi);
const isValid = isQuestionSelectionValid(
preparedQuestion,
selectedOptionIndexesByQuestion[questionIndex],
noteByQuestionByOption[questionIndex],
);
const statusIcon = isValid ? theme.fg("success", "●") : theme.fg("warning", "○");
addLine(` ${statusIcon} ${theme.fg("muted", `${preparedQuestion.tabLabel}:`)} ${theme.fg("text", value)}`);
}
renderedLines.push("");
if (isAllQuestionSelectionsValid()) {
addLine(theme.fg("success", " Press Enter to submit"));
} else {
const missingQuestions = preparedQuestions
.filter((preparedQuestion, questionIndex) =>
!isQuestionSelectionValid(
preparedQuestion,
selectedOptionIndexesByQuestion[questionIndex],
noteByQuestionByOption[questionIndex],
),
)
.map((preparedQuestion) => preparedQuestion.tabLabel)
.join(", ");
addLine(theme.fg("warning", ` Complete required answers: ${missingQuestions}`));
}
addLine(theme.fg("dim", " ←/→ switch tabs • Esc cancel"));
};
const renderQuestionTab = (width: number, renderedLines: string[], questionIndex: number): void => {
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
const preparedQuestion = preparedQuestions[questionIndex];
const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex];
const selectedOptionIndexes = selectedOptionIndexesByQuestion[questionIndex];
for (const questionLine of wrapTextWithAnsi(preparedQuestion.question, Math.max(1, width - 1))) {
addLine(` ${theme.fg("text", questionLine)}`);
}
renderedLines.push("");
for (let optionIndex = 0; optionIndex < preparedQuestion.options.length; optionIndex++) {
const optionLabel = preparedQuestion.options[optionIndex];
const isCursorOption = optionIndex === cursorOptionIndex;
const isOptionSelected = selectedOptionIndexes.includes(optionIndex);
const isEditingThisOption = isNoteEditorOpen && isCursorOption;
const cursorPrefixText = isCursorOption ? "→ " : " ";
const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText;
const markerText = preparedQuestion.multi
? `${isOptionSelected ? "[x]" : "[ ]"} `
: `${isOptionSelected ? "●" : "○"} `;
const optionColor = isCursorOption ? "accent" : isOptionSelected ? "success" : "text";
const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText);
const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote(
optionLabel,
getQuestionNote(questionIndex, optionIndex),
isEditingThisOption,
Math.max(1, width - prefixWidth),
INLINE_NOTE_WRAP_PADDING,
);
const continuationPrefix = " ".repeat(prefixWidth);
addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
for (const wrappedLine of wrappedInlineLabelLines.slice(1)) {
addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
}
}
renderedLines.push("");
if (isNoteEditorOpen) {
addLine(theme.fg("dim", " Typing note inline • Enter save note • Tab/Esc stop editing"));
} else {
if (preparedQuestion.multi) {
addLine(
theme.fg(
"dim",
" ↑↓ move • Enter toggle/select • Tab add note • ←/→ switch tabs • Esc cancel",
),
);
} else {
addLine(
theme.fg("dim", " ↑↓ move • Enter select • Tab add note • ←/→ switch tabs • Esc cancel"),
);
}
}
};
const render = (width: number): string[] => {
if (cachedRenderedLines) return cachedRenderedLines;
const renderedLines: string[] = [];
const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
addLine(theme.fg("accent", "─".repeat(width)));
addLine(` ${renderTabs()}`);
renderedLines.push("");
if (activeTabIndex === submitTabIndex) {
renderSubmitTab(width, renderedLines);
} else {
renderQuestionTab(width, renderedLines, activeTabIndex);
}
addLine(theme.fg("accent", "─".repeat(width)));
cachedRenderedLines = renderedLines;
return renderedLines;
};
const handleInput = (data: string) => {
if (isNoteEditorOpen) {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
isNoteEditorOpen = false;
requestUiRerender();
return;
}
noteEditor.handleInput(data);
requestUiRerender();
return;
}
if (matchesKey(data, Key.left)) {
activeTabIndex = (activeTabIndex - 1 + preparedQuestions.length + 1) % (preparedQuestions.length + 1);
requestUiRerender();
return;
}
if (matchesKey(data, Key.right)) {
activeTabIndex = (activeTabIndex + 1) % (preparedQuestions.length + 1);
requestUiRerender();
return;
}
if (activeTabIndex === submitTabIndex) {
if (matchesKey(data, Key.enter) && isAllQuestionSelectionsValid()) {
done(createTabsUiStateSnapshot(false, selectedOptionIndexesByQuestion, noteByQuestionByOption));
return;
}
if (matchesKey(data, Key.escape)) {
done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
}
return;
}
const questionIndex = activeTabIndex;
const preparedQuestion = preparedQuestions[questionIndex];
if (matchesKey(data, Key.up)) {
cursorOptionIndexByQuestion[questionIndex] = Math.max(0, cursorOptionIndexByQuestion[questionIndex] - 1);
requestUiRerender();
return;
}
if (matchesKey(data, Key.down)) {
cursorOptionIndexByQuestion[questionIndex] = Math.min(
preparedQuestion.options.length - 1,
cursorOptionIndexByQuestion[questionIndex] + 1,
);
requestUiRerender();
return;
}
if (matchesKey(data, Key.tab)) {
openNoteEditorForActiveOption();
return;
}
if (matchesKey(data, Key.enter)) {
const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex];
if (preparedQuestion.multi) {
const currentlySelected = selectedOptionIndexesByQuestion[questionIndex];
if (currentlySelected.includes(cursorOptionIndex)) {
selectedOptionIndexesByQuestion[questionIndex] = removeIndexFromSelection(currentlySelected, cursorOptionIndex);
} else {
selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection(currentlySelected, cursorOptionIndex);
}
if (
cursorOptionIndex === preparedQuestion.otherOptionIndex &&
selectedOptionIndexesByQuestion[questionIndex].includes(cursorOptionIndex) &&
getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
) {
openNoteEditorForActiveOption();
return;
}
requestUiRerender();
return;
}
selectedOptionIndexesByQuestion[questionIndex] = [cursorOptionIndex];
if (
cursorOptionIndex === preparedQuestion.otherOptionIndex &&
getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
) {
openNoteEditorForActiveOption();
return;
}
advanceToNextTabOrSubmit();
requestUiRerender();
return;
}
if (matchesKey(data, Key.escape)) {
done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
}
};
return {
render,
invalidate: () => {
cachedRenderedLines = undefined;
},
handleInput,
};
});
if (result.cancelled) {
return {
cancelled: true,
selections: preparedQuestions.map(() => ({ selectedOptions: [] } satisfies AskSelection)),
};
}
const selections = preparedQuestions.map((preparedQuestion, questionIndex) =>
buildSelectionForQuestion(
preparedQuestion,
result.selectedOptionIndexesByQuestion[questionIndex] ?? [],
result.noteByQuestionByOption[questionIndex] ?? Array(preparedQuestion.options.length).fill(""),
),
);
return { cancelled: result.cancelled, selections };
}

View File

@@ -0,0 +1,237 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@sinclair/typebox";
import { OTHER_OPTION, type AskQuestion } from "./ask-logic";
import { askSingleQuestionWithInlineNote } from "./ask-inline-ui";
import { askQuestionsWithTabs } from "./ask-tabs-ui";
const OptionItemSchema = Type.Object({
label: Type.String({ description: "Display label" }),
});
const QuestionItemSchema = Type.Object({
id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }),
question: Type.String({ description: "Question text" }),
options: Type.Array(OptionItemSchema, {
description: "Available options. Do not include 'Other'.",
minItems: 1,
}),
multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })),
recommended: Type.Optional(
Type.Number({ description: "0-indexed recommended option. '(Recommended)' is shown automatically." }),
),
});
const AskParamsSchema = Type.Object({
questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }),
});
type AskParams = Static<typeof AskParamsSchema>;
interface QuestionResult {
id: string;
question: string;
options: string[];
multi: boolean;
selectedOptions: string[];
customInput?: string;
}
interface AskToolDetails {
id?: string;
question?: string;
options?: string[];
multi?: boolean;
selectedOptions?: string[];
customInput?: string;
results?: QuestionResult[];
}
function sanitizeForSessionText(value: string): string {
return value
.replace(/[\r\n\t]/g, " ")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.replace(/\s{2,}/g, " ")
.trim();
}
function sanitizeOptionForSessionText(option: string): string {
const sanitizedOption = sanitizeForSessionText(option);
return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)";
}
function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
const selectedOptions = result.selectedOptions
.map((selectedOption) => sanitizeForSessionText(selectedOption))
.filter((selectedOption) => selectedOption.length > 0);
const rawCustomInput = result.customInput;
const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
return {
id: sanitizeForSessionText(result.id) || "(unknown)",
question: sanitizeForSessionText(result.question) || "(empty question)",
options: result.options.map(sanitizeOptionForSessionText),
multi: result.multi,
selectedOptions,
customInput: customInput && customInput.length > 0 ? customInput : undefined,
};
}
function formatSelectionForSummary(result: QuestionResult): string {
const hasSelectedOptions = result.selectedOptions.length > 0;
const hasCustomInput = Boolean(result.customInput);
if (!hasSelectedOptions && !hasCustomInput) {
return "(cancelled)";
}
if (hasSelectedOptions && hasCustomInput) {
const selectedPart = result.multi
? `[${result.selectedOptions.join(", ")}]`
: result.selectedOptions[0];
return `${selectedPart} + Other: "${result.customInput}"`;
}
if (hasCustomInput) {
return `"${result.customInput}"`;
}
if (result.multi) {
return `[${result.selectedOptions.join(", ")}]`;
}
return result.selectedOptions[0];
}
function formatQuestionResult(result: QuestionResult): string {
return `${result.id}: ${formatSelectionForSummary(result)}`;
}
function formatQuestionContext(result: QuestionResult, questionIndex: number): string {
const lines: string[] = [
`Question ${questionIndex + 1} (${result.id})`,
`Prompt: ${result.question}`,
"Options:",
...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`),
"Response:",
];
const hasSelectedOptions = result.selectedOptions.length > 0;
const hasCustomInput = Boolean(result.customInput);
if (!hasSelectedOptions && !hasCustomInput) {
lines.push(" Selected: (cancelled)");
return lines.join("\n");
}
if (hasSelectedOptions) {
const selectedText = result.multi
? `[${result.selectedOptions.join(", ")}]`
: result.selectedOptions[0];
lines.push(` Selected: ${selectedText}`);
}
if (hasCustomInput) {
if (!hasSelectedOptions) {
lines.push(` Selected: ${OTHER_OPTION}`);
}
lines.push(` Custom input: ${result.customInput}`);
}
return lines.join("\n");
}
function buildAskSessionContent(results: QuestionResult[]): string {
const safeResults = results.map(toSessionSafeQuestionResult);
const summaryLines = safeResults.map(formatQuestionResult).join("\n");
const contextBlocks = safeResults.map((result, index) => formatQuestionContext(result, index)).join("\n\n");
return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
}
const ASK_TOOL_DESCRIPTION = `
Ask the user for clarification when a choice materially affects the outcome.
- Use when multiple valid approaches have different trade-offs.
- Prefer 2-5 concise options.
- Use multi=true when multiple answers are valid.
- Use recommended=<index> (0-indexed) to mark the default option.
- You can ask multiple related questions in one call using questions[].
- Do NOT include an 'Other' option; UI adds it automatically.
`.trim();
export default function askExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "ask",
label: "Ask",
description: ASK_TOOL_DESCRIPTION,
parameters: AskParamsSchema,
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
return {
content: [{ type: "text", text: "Error: ask tool requires interactive mode" }],
details: {},
};
}
if (params.questions.length === 0) {
return {
content: [{ type: "text", text: "Error: questions must not be empty" }],
details: {},
};
}
if (params.questions.length === 1) {
const [q] = params.questions;
const selection = q.multi
? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
: await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
const optionLabels = q.options.map((option) => option.label);
const result: QuestionResult = {
id: q.id,
question: q.question,
options: optionLabels,
multi: q.multi ?? false,
selectedOptions: selection.selectedOptions,
customInput: selection.customInput,
};
const details: AskToolDetails = {
id: q.id,
question: q.question,
options: optionLabels,
multi: q.multi ?? false,
selectedOptions: selection.selectedOptions,
customInput: selection.customInput,
results: [result],
};
return {
content: [{ type: "text", text: buildAskSessionContent([result]) }],
details,
};
}
const results: QuestionResult[] = [];
const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
for (let i = 0; i < params.questions.length; i++) {
const q = params.questions[i];
const selection = tabResult.selections[i] ?? { selectedOptions: [] };
results.push({
id: q.id,
question: q.question,
options: q.options.map((option) => option.label),
multi: q.multi ?? false,
selectedOptions: selection.selectedOptions,
customInput: selection.customInput,
});
}
return {
content: [{ type: "text", text: buildAskSessionContent(results) }],
details: { results } satisfies AskToolDetails,
};
},
});
}

View File

@@ -1,9 +1,12 @@
/**
* Usage Extension - Minimal API usage indicator for pi
*
* Shows Codex (OpenAI), Anthropic (Claude), Z.AI, and optionally
* Google Gemini CLI / Antigravity usage as color-coded percentages
* in the footer status bar.
* Polls Codex, Anthropic, Z.AI, Gemini CLI / Antigravity usage and exposes it
* via two channels:
* • pi.events "usage:update" — for other extensions (e.g. footer-display)
* • ctx.ui.setStatus("usage-bars", …) — formatted S/W braille bars
*
* Rendering / footer layout is handled by the separate footer-display extension.
*/
import { DynamicBorder, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -20,6 +23,7 @@ import {
clampPercent,
colorForPercent,
detectProvider,
ensureFreshAuthForProviders,
fetchAllUsages,
fetchClaudeUsage,
fetchCodexUsage,
@@ -30,15 +34,45 @@ import {
readUsageCache,
resolveUsageEndpoints,
writeUsageCache,
type OAuthProviderId,
type ProviderKey,
type UsageByProvider,
type UsageData,
} from "./core";
const CACHE_TTL_MS = 15 * 60 * 1000; // reuse cached data for 15 min
const RATE_LIMITED_BACKOFF_MS = 60 * 60 * 1000; // 1 hour back-off after 429
const CACHE_TTL_MS = 15 * 60 * 1000;
const ACTIVE_CACHE_TTL_MS = 3 * 60 * 1000;
const STREAMING_POLL_INTERVAL_MS = 2 * 60 * 1000;
const RATE_LIMITED_BACKOFF_MS = 60 * 60 * 1000;
const STATUS_KEY = "usage-bars";
// ---------------------------------------------------------------------------
// Braille gradient bar (⣀ ⣄ ⣤ ⣦ ⣶ ⣷ ⣿)
// ---------------------------------------------------------------------------
const BRAILLE_GRADIENT = "\u28C0\u28C4\u28E4\u28E6\u28F6\u28F7\u28FF";
const BRAILLE_EMPTY = "\u28C0";
const BAR_WIDTH = 5;
function renderBrailleBar(theme: any, value: number, width = BAR_WIDTH): string {
const v = clampPercent(value);
const levels = BRAILLE_GRADIENT.length - 1;
const totalSteps = width * levels;
const filledSteps = Math.round((v / 100) * totalSteps);
const full = Math.floor(filledSteps / levels);
const partial = filledSteps % levels;
const empty = width - full - (partial ? 1 : 0);
const color = colorForPercent(v);
const filled = BRAILLE_GRADIENT[BRAILLE_GRADIENT.length - 1]!.repeat(Math.max(0, full));
const partialChar = partial ? BRAILLE_GRADIENT[partial]! : "";
const emptyChars = BRAILLE_EMPTY.repeat(Math.max(0, empty));
return theme.fg(color, filled + partialChar) + theme.fg("dim", emptyChars);
}
function renderBrailleBarWide(theme: any, value: number): string {
return renderBrailleBar(theme, value, 12);
}
const PROVIDER_LABELS: Record<ProviderKey, string> = {
codex: "Codex",
claude: "Claude",
@@ -47,6 +81,9 @@ const PROVIDER_LABELS: Record<ProviderKey, string> = {
antigravity: "Antigravity",
};
// ---------------------------------------------------------------------------
// /usage command popup
// ---------------------------------------------------------------------------
interface SubscriptionItem {
name: string;
provider: ProviderKey;
@@ -69,14 +106,8 @@ class UsageSelectorComponent extends Container implements Focusable {
private fetchAllFn: () => Promise<UsageByProvider>;
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value;
}
get focused(): boolean { return this._focused; }
set focused(value: boolean) { this._focused = value; this.searchInput.focused = value; }
constructor(
tui: any,
@@ -94,19 +125,15 @@ class UsageSelectorComponent extends Container implements Focusable {
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
this.addChild(new Spacer(1));
this.hintText = new Text(theme.fg("dim", "Fetching usage from all providers…"), 0, 0);
this.addChild(this.hintText);
this.addChild(new Spacer(1));
this.searchInput = new Input();
this.addChild(this.searchInput);
this.addChild(new Spacer(1));
this.listContainer = new Container();
this.addChild(this.listContainer);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
this.fetchAllFn()
@@ -137,7 +164,6 @@ class UsageSelectorComponent extends Container implements Focusable {
{ key: "gemini", name: "Gemini" },
{ key: "antigravity", name: "Antigravity" },
];
this.allItems = [];
for (const p of providers) {
if (results[p.key] !== null) {
@@ -149,7 +175,6 @@ class UsageSelectorComponent extends Container implements Focusable {
});
}
}
this.filteredItems = this.allItems;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
}
@@ -166,23 +191,12 @@ class UsageSelectorComponent extends Container implements Focusable {
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
}
private renderBar(pct: number, width = 16): string {
const value = clampPercent(pct);
const filled = Math.round((value / 100) * width);
const color = colorForPercent(value);
const full = "█".repeat(Math.max(0, filled));
const empty = "░".repeat(Math.max(0, width - filled));
return this.theme.fg(color, full) + this.theme.fg("dim", empty);
}
private renderItem(item: SubscriptionItem, isSelected: boolean) {
const t = this.theme;
const pointer = isSelected ? t.fg("accent", "→ ") : " ";
const activeBadge = item.isActive ? t.fg("success", " ✓") : "";
const name = isSelected ? t.fg("accent", t.bold(item.name)) : item.name;
this.listContainer.addChild(new Text(`${pointer}${name}${activeBadge}`, 0, 0));
const indent = " ";
if (!item.data) {
@@ -192,69 +206,45 @@ class UsageSelectorComponent extends Container implements Focusable {
} else {
const session = clampPercent(item.data.session);
const weekly = clampPercent(item.data.weekly);
const sessionReset = item.data.sessionResetsIn
? t.fg("dim", ` resets in ${item.data.sessionResetsIn}`)
: "";
? t.fg("dim", ` resets in ${item.data.sessionResetsIn}`) : "";
const weeklyReset = item.data.weeklyResetsIn
? t.fg("dim", ` resets in ${item.data.weeklyResetsIn}`)
: "";
? t.fg("dim", ` resets in ${item.data.weeklyResetsIn}`) : "";
this.listContainer.addChild(
new Text(
indent +
t.fg("muted", "Session ") +
this.renderBar(session) +
" " +
t.fg(colorForPercent(session), `${session}%`.padStart(4)) +
sessionReset,
0,
0,
),
);
this.listContainer.addChild(
new Text(
indent +
t.fg("muted", "Weekly ") +
this.renderBar(weekly) +
" " +
t.fg(colorForPercent(weekly), `${weekly}%`.padStart(4)) +
weeklyReset,
0,
0,
),
);
this.listContainer.addChild(new Text(
indent + t.fg("muted", "Session ") +
renderBrailleBarWide(t, session) + " " +
t.fg(colorForPercent(session), `${session}%`.padStart(4)) + sessionReset,
0, 0,
));
this.listContainer.addChild(new Text(
indent + t.fg("muted", "Weekly ") +
renderBrailleBarWide(t, weekly) + " " +
t.fg(colorForPercent(weekly), `${weekly}%`.padStart(4)) + weeklyReset,
0, 0,
));
if (typeof item.data.extraSpend === "number" && typeof item.data.extraLimit === "number") {
this.listContainer.addChild(
new Text(
indent +
t.fg("muted", "Extra ") +
this.listContainer.addChild(new Text(
indent + t.fg("muted", "Extra ") +
t.fg("dim", `$${item.data.extraSpend.toFixed(2)} / $${item.data.extraLimit}`),
0,
0,
),
);
0, 0,
));
}
}
this.listContainer.addChild(new Spacer(1));
}
private updateList() {
this.listContainer.clear();
if (this.loading) {
this.listContainer.addChild(new Text(this.theme.fg("muted", " Loading…"), 0, 0));
return;
}
if (this.filteredItems.length === 0) {
this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching providers"), 0, 0));
return;
}
for (let i = 0; i < this.filteredItems.length; i++) {
this.renderItem(this.filteredItems[i]!, i === this.selectedIndex);
}
@@ -262,78 +252,58 @@ class UsageSelectorComponent extends Container implements Focusable {
handleInput(keyData: string): void {
const kb = getEditorKeybindings();
if (kb.matches(keyData, "selectUp")) {
if (this.filteredItems.length === 0) return;
this.selectedIndex =
this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.updateList();
return;
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.updateList(); return;
}
if (kb.matches(keyData, "selectDown")) {
if (this.filteredItems.length === 0) return;
this.selectedIndex =
this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList();
return;
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList(); return;
}
if (kb.matches(keyData, "selectCancel") || kb.matches(keyData, "selectConfirm")) {
this.onCancelCallback();
return;
this.onCancelCallback(); return;
}
this.searchInput.handleInput(keyData);
this.filterItems(this.searchInput.getValue());
this.updateList();
}
}
// ---------------------------------------------------------------------------
// Extension state
// ---------------------------------------------------------------------------
interface UsageState extends UsageByProvider {
lastPoll: number;
activeProvider: ProviderKey | null;
}
interface PollOptions {
cacheTtl?: number;
forceFresh?: boolean;
}
export default function (pi: ExtensionAPI) {
const endpoints = resolveUsageEndpoints();
const state: UsageState = {
codex: null,
claude: null,
zai: null,
gemini: null,
antigravity: null,
lastPoll: 0,
activeProvider: null,
codex: null, claude: null, zai: null, gemini: null, antigravity: null,
lastPoll: 0, activeProvider: null,
};
let pollInFlight: Promise<void> | null = null;
let pollQueued = false;
let streamingTimer: ReturnType<typeof setInterval> | null = null;
let ctx: any = null;
function renderPercent(theme: any, value: number): string {
const v = clampPercent(value);
return theme.fg(colorForPercent(v), `${v}%`);
}
function renderBar(theme: any, value: number): string {
const v = clampPercent(value);
const width = 8;
const filled = Math.round((v / 100) * width);
const full = "█".repeat(Math.max(0, Math.min(width, filled)));
const empty = "░".repeat(Math.max(0, width - filled));
return theme.fg(colorForPercent(v), full) + theme.fg("dim", empty);
}
function pickDataForProvider(provider: ProviderKey | null): UsageData | null {
if (!provider) return null;
return state[provider];
}
// ---------------------------------------------------------------------------
// Status update
// ---------------------------------------------------------------------------
function updateStatus() {
const active = state.activeProvider;
const data = pickDataForProvider(active);
const data = active ? state[active] : null;
// Always emit event for other extensions (e.g. footer-display)
if (data && !data.error) {
pi.events.emit("usage:update", {
session: data.session,
@@ -345,6 +315,8 @@ export default function (pi: ExtensionAPI) {
if (!ctx?.hasUI) return;
const theme = ctx.ui.theme;
if (!active) {
ctx.ui.setStatus(STATUS_KEY, undefined);
return;
@@ -356,121 +328,100 @@ export default function (pi: ExtensionAPI) {
return;
}
const theme = ctx.ui.theme;
const label = PROVIDER_LABELS[active];
if (!data) {
ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", `${label} usage: loading…`));
ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", "loading\u2026"));
return;
}
if (data.error) {
const cache = readUsageCache();
const blockedUntil = active ? (cache?.rateLimitedUntil?.[active] ?? 0) : 0;
const backoffNote = blockedUntil > Date.now()
? ` retry in ${Math.ceil((blockedUntil - Date.now()) / 60000)}m`
: "";
ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `${label} usage unavailable (${data.error}${backoffNote})`));
const blockedUntil = cache?.rateLimitedUntil?.[active] ?? 0;
const note = blockedUntil > Date.now()
? ` \u2014 retry in ${Math.ceil((blockedUntil - Date.now()) / 60000)}m` : "";
ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `${PROVIDER_LABELS[active]} unavailable${note}`));
return;
}
const session = clampPercent(data.session);
const weekly = clampPercent(data.weekly);
const sessionReset = data.sessionResetsIn ? theme.fg("dim", `${data.sessionResetsIn}`) : "";
const weeklyReset = data.weeklyResetsIn ? theme.fg("dim", `${data.weeklyResetsIn}`) : "";
let s = theme.fg("muted", "S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`);
if (data.sessionResetsIn) s += " " + theme.fg("dim", data.sessionResetsIn);
const status =
theme.fg("dim", `${label} `) +
theme.fg("muted", "S ") +
renderBar(theme, session) +
" " +
renderPercent(theme, session) +
sessionReset +
theme.fg("muted", " W ") +
renderBar(theme, weekly) +
" " +
renderPercent(theme, weekly) +
weeklyReset;
let w = theme.fg("muted", "W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`);
if (data.weeklyResetsIn) w += " " + theme.fg("dim", `\u27F3 ${data.weeklyResetsIn}`);
ctx.ui.setStatus(STATUS_KEY, status);
ctx.ui.setStatus(STATUS_KEY, s + theme.fg("dim", " | ") + w);
}
function updateProviderFrom(modelLike: any): boolean {
const previous = state.activeProvider;
state.activeProvider = detectProvider(modelLike);
if (previous !== state.activeProvider) {
updateStatus();
return true;
}
if (previous !== state.activeProvider) { updateStatus(); return true; }
return false;
}
async function runPoll() {
// ---------------------------------------------------------------------------
// Polling
// ---------------------------------------------------------------------------
async function runPoll(options: PollOptions = {}) {
const auth = readAuth();
const active = state.activeProvider;
if (!canShowForProvider(active, auth, endpoints) || !auth || !active) {
state.lastPoll = Date.now();
updateStatus();
return;
state.lastPoll = Date.now(); updateStatus(); return;
}
// --- Shared disk cache check ---
// All pi sessions read and write the same cache file so that only one
// process hits the API per CACHE_TTL_MS window, no matter how many
// sessions are open at once.
const cache = readUsageCache();
const now = Date.now();
const cacheTtl = options.cacheTtl ?? CACHE_TTL_MS;
// Respect cross-session rate-limit back-off written by any session.
const blockedUntil = cache?.rateLimitedUntil?.[active] ?? 0;
if (now < blockedUntil) {
// Use whatever data is in the cache so the bar still shows something.
if (cache?.data?.[active]) state[active] = cache.data[active]!;
state.lastPoll = now;
updateStatus();
return;
state.lastPoll = now; updateStatus(); return;
}
// If another session already polled recently, use their result.
if (cache && now - cache.timestamp < CACHE_TTL_MS && cache.data?.[active]) {
if (!options.forceFresh && cache && now - cache.timestamp < cacheTtl && cache.data?.[active]) {
state[active] = cache.data[active]!;
state.lastPoll = now;
updateStatus();
return;
state.lastPoll = now; updateStatus(); return;
}
// --- Actually hit the API ---
// Skip independent token refresh — pi manages OAuth tokens and refreshes
// them in memory. A parallel refresh here would cause token rotation
// conflicts (Anthropic invalidates the old refresh token on use).
let result: UsageData;
const oauthId = providerToOAuthProviderId(active);
let effectiveAuth = auth;
if (oauthId && active !== "zai") {
const creds = auth[oauthId as keyof typeof auth] as
| { access?: string; refresh?: string; expires?: number } | undefined;
const expires = typeof creds?.expires === "number" ? creds.expires : 0;
const tokenExpiredOrMissing = !creds?.access || (expires > 0 && Date.now() + 60_000 >= expires);
if (tokenExpiredOrMissing && creds?.refresh) {
try {
const refreshed = await ensureFreshAuthForProviders([oauthId as OAuthProviderId], { auth, persist: true });
if (refreshed.auth) effectiveAuth = refreshed.auth;
} catch {}
}
}
let result: UsageData;
if (active === "codex") {
const access = auth["openai-codex"]?.access;
result = access
? await fetchCodexUsage(access)
const access = effectiveAuth["openai-codex"]?.access;
result = access ? await fetchCodexUsage(access)
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
} else if (active === "claude") {
const access = auth.anthropic?.access;
result = access
? await fetchClaudeUsage(access)
const access = effectiveAuth.anthropic?.access;
result = access ? await fetchClaudeUsage(access)
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
} else if (active === "zai") {
const token = auth.zai?.access || auth.zai?.key;
result = token
? await fetchZaiUsage(token, { endpoints })
const token = effectiveAuth.zai?.access || effectiveAuth.zai?.key;
result = token ? await fetchZaiUsage(token, { endpoints })
: { session: 0, weekly: 0, error: "missing token (try /login again)" };
} else if (active === "gemini") {
const creds = auth["google-gemini-cli"];
const creds = effectiveAuth["google-gemini-cli"];
result = creds?.access
? await fetchGoogleUsage(creds.access, endpoints.gemini, creds.projectId, "gemini", { endpoints })
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
} else {
const creds = auth["google-antigravity"];
const creds = effectiveAuth["google-antigravity"];
result = creds?.access
? await fetchGoogleUsage(creds.access, endpoints.antigravity, creds.projectId, "antigravity", { endpoints })
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
@@ -478,45 +429,49 @@ export default function (pi: ExtensionAPI) {
state[active] = result;
// Write result + rate-limit state to shared cache so other sessions
// (and our own next timer tick) don't need to re-hit the API.
if (result.error) {
if (result.error === "HTTP 429") {
const nextCache: import("./core").UsageCache = {
timestamp: cache?.timestamp ?? now,
data: { ...(cache?.data ?? {}) },
rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}), [active]: now + RATE_LIMITED_BACKOFF_MS },
};
writeUsageCache(nextCache);
}
} else {
const nextCache: import("./core").UsageCache = {
timestamp: now,
data: { ...(cache?.data ?? {}), [active]: result },
rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}) },
};
if (result.error === "HTTP 429") {
nextCache.rateLimitedUntil![active] = now + RATE_LIMITED_BACKOFF_MS;
} else {
delete nextCache.rateLimitedUntil![active];
}
writeUsageCache(nextCache);
}
state.lastPoll = now;
updateStatus();
}
async function poll() {
if (pollInFlight) {
pollQueued = true;
await pollInFlight;
return;
}
async function poll(options: PollOptions = {}) {
if (pollInFlight) { pollQueued = true; await pollInFlight; return; }
do {
pollQueued = false;
pollInFlight = runPoll()
.catch(() => {
// Never crash extension event handlers on transient polling errors.
})
.finally(() => {
pollInFlight = null;
});
pollInFlight = runPoll(options).catch(() => {}).finally(() => { pollInFlight = null; });
await pollInFlight;
} while (pollQueued);
}
function startStreamingTimer() {
if (streamingTimer !== null) return;
streamingTimer = setInterval(() => { void poll({ cacheTtl: ACTIVE_CACHE_TTL_MS }); }, STREAMING_POLL_INTERVAL_MS);
}
function stopStreamingTimer() {
if (streamingTimer !== null) { clearInterval(streamingTimer); streamingTimer = null; }
}
// ── Lifecycle ────────────────────────────────────────────────────────────
pi.on("session_start", async (_event, _ctx) => {
ctx = _ctx;
updateProviderFrom(_ctx.model);
@@ -524,18 +479,8 @@ export default function (pi: ExtensionAPI) {
});
pi.on("session_shutdown", async (_event, _ctx) => {
if (_ctx?.hasUI) {
_ctx.ui.setStatus(STATUS_KEY, undefined);
}
});
// Refresh usage on every turn (like claude-pulse's UserPromptSubmit hook).
// The disk cache means the API is only hit at most once per CACHE_TTL_MS
// regardless of how many turns or sessions are active.
pi.on("turn_start", async (_event, _ctx) => {
ctx = _ctx;
updateProviderFrom(_ctx.model);
await poll();
stopStreamingTimer();
if (_ctx?.hasUI) _ctx.ui.setStatus(STATUS_KEY, undefined);
});
pi.on("model_select", async (event, _ctx) => {
@@ -544,33 +489,51 @@ export default function (pi: ExtensionAPI) {
if (changed) await poll();
});
pi.on("turn_start", (_event, _ctx) => { ctx = _ctx; updateProviderFrom(_ctx.model); });
pi.on("before_agent_start", async (_event, _ctx) => {
ctx = _ctx;
await poll({ cacheTtl: ACTIVE_CACHE_TTL_MS });
});
pi.on("agent_start", (_event, _ctx) => { ctx = _ctx; startStreamingTimer(); });
pi.on("agent_end", async (_event, _ctx) => {
ctx = _ctx;
stopStreamingTimer();
await poll({ cacheTtl: ACTIVE_CACHE_TTL_MS });
});
pi.events.on("claude-account:switched", () => {
const cache = readUsageCache();
if (cache?.data?.claude) {
const nextCache: import("./core").UsageCache = { ...cache, data: { ...cache.data } };
delete nextCache.data.claude;
writeUsageCache(nextCache);
}
void poll({ forceFresh: true });
});
// ── /usage command ───────────────────────────────────────────────────────
pi.registerCommand("usage", {
description: "Show API usage for all subscriptions",
handler: async (_args, _ctx) => {
ctx = _ctx;
updateProviderFrom(_ctx.model);
try {
if (_ctx?.hasUI) {
await _ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
const selector = new UsageSelectorComponent(
tui,
theme,
state.activeProvider,
return new UsageSelectorComponent(
tui, theme, state.activeProvider,
() => fetchAllUsages({ endpoints }),
() => done(),
);
return selector;
});
}
} finally {
await poll();
await poll({ cacheTtl: ACTIVE_CACHE_TTL_MS });
}
},
});
// Re-poll immediately when the Claude account is switched via /switch-claude
pi.events.on("claude-account:switched", () => {
void poll();
});
}

View File

@@ -0,0 +1,444 @@
/**
* WezTerm Theme Sync Extension
*
* Syncs pi theme with WezTerm terminal colors on startup.
*
* How it works:
* 1. Finds the WezTerm config directory (via $WEZTERM_CONFIG_DIR or defaults)
* 2. Runs the config through luajit to extract effective colors
* 3. Maps ANSI palette slots to pi theme colors
* 4. Writes a pi theme file and activates it
*
* Supports:
* - Inline `config.colors = { ... }` definitions
* - Lua theme modules loaded via require()
* - Any config structure as long as `config.colors` is set
*
* ANSI slots (consistent across themes):
* 0: black 8: bright black (gray/muted)
* 1: red 9: bright red
* 2: green 10: bright green
* 3: yellow 11: bright yellow
* 4: blue 12: bright blue
* 5: magenta 13: bright magenta
* 6: cyan 14: bright cyan
* 7: white 15: bright white
*
* Requirements:
* - WezTerm installed and running (sets $WEZTERM_CONFIG_DIR)
* - luajit or lua available in PATH
*/
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
interface WeztermColors {
background: string;
foreground: string;
palette: Record<number, string>;
}
/**
* Find the WezTerm config directory.
* Checks $WEZTERM_CONFIG_DIR, then standard locations.
*/
function findConfigDir(): string | null {
if (process.env.WEZTERM_CONFIG_DIR && existsSync(process.env.WEZTERM_CONFIG_DIR)) {
return process.env.WEZTERM_CONFIG_DIR;
}
const candidates = [
join(homedir(), ".config", "wezterm"),
join(homedir(), ".wezterm"),
];
for (const dir of candidates) {
if (existsSync(dir)) return dir;
}
return null;
}
/**
* Find which Lua interpreter is available.
*/
function findLua(): string | null {
for (const cmd of ["luajit", "lua5.4", "lua5.3", "lua"]) {
try {
execSync(`which ${cmd}`, { stdio: "pipe" });
return cmd;
} catch {
// Try next
}
}
return null;
}
/**
* Extract colors from WezTerm config by evaluating it with a mocked wezterm module.
* Writes a temporary Lua helper script, runs it with luajit, then cleans up.
*/
function getWeztermColors(configDir: string, lua: string): WeztermColors | null {
const configFile = join(configDir, "wezterm.lua");
if (!existsSync(configFile)) return null;
const tmpScript = join(configDir, ".pi-extract-colors.lua");
const extractScript = `
-- Mock wezterm module with commonly used functions
local mock_wezterm = {
font = function(name) return name end,
font_with_fallback = function(names) return names end,
hostname = function() return "mock" end,
home_dir = ${JSON.stringify(homedir())},
config_dir = ${JSON.stringify(configDir)},
target_triple = "x86_64-unknown-linux-gnu",
version = "mock",
log_info = function() end,
log_warn = function() end,
log_error = function() end,
on = function() end,
action = setmetatable({}, {
__index = function(_, k)
return function(...) return { action = k, args = {...} } end
end
}),
action_callback = function(fn) return fn end,
color = {
parse = function(c) return c end,
get_builtin_schemes = function() return {} end,
},
gui = {
get_appearance = function() return "Dark" end,
},
GLOBAL = {},
nerdfonts = setmetatable({}, { __index = function() return "" end }),
}
mock_wezterm.plugin = { require = function() return {} end }
package.loaded["wezterm"] = mock_wezterm
-- Add config dir to Lua search path
package.path = ${JSON.stringify(configDir)} .. "/?.lua;" ..
${JSON.stringify(configDir)} .. "/?/init.lua;" ..
package.path
-- Try to load the config
local ok, config = pcall(dofile, ${JSON.stringify(configFile)})
if not ok then
io.stderr:write("Failed to load config: " .. tostring(config) .. "\\n")
os.exit(1)
end
if type(config) ~= "table" then
io.stderr:write("Config did not return a table\\n")
os.exit(1)
end
local colors = config.colors
if not colors then
if config.color_scheme then
io.stderr:write("color_scheme=" .. tostring(config.color_scheme) .. "\\n")
end
io.stderr:write("No inline colors found in config\\n")
os.exit(1)
end
if type(colors) == "table" then
if colors.background then print("background=" .. colors.background) end
if colors.foreground then print("foreground=" .. colors.foreground) end
if colors.ansi then
for i, c in ipairs(colors.ansi) do
print("ansi" .. (i-1) .. "=" .. c)
end
end
if colors.brights then
for i, c in ipairs(colors.brights) do
print("bright" .. (i-1) .. "=" .. c)
end
end
end
`;
try {
writeFileSync(tmpScript, extractScript);
const output = execSync(`${lua} ${JSON.stringify(tmpScript)}`, {
encoding: "utf-8",
timeout: 5000,
cwd: configDir,
stdio: ["pipe", "pipe", "pipe"],
});
return parseWeztermOutput(output);
} catch (err: any) {
if (err.stderr) {
console.error(`[wezterm-theme-sync] ${err.stderr.trim()}`);
}
return null;
} finally {
try { unlinkSync(tmpScript); } catch { /* ignore */ }
}
}
function parseWeztermOutput(output: string): WeztermColors {
const colors: WeztermColors = {
background: "#1e1e1e",
foreground: "#d4d4d4",
palette: {},
};
for (const line of output.split("\n")) {
const match = line.match(/^(\w+)=(.+)$/);
if (!match) continue;
const [, key, value] = match;
const color = normalizeColor(value.trim());
if (key === "background") {
colors.background = color;
} else if (key === "foreground") {
colors.foreground = color;
} else {
const ansiMatch = key.match(/^ansi(\d+)$/);
const brightMatch = key.match(/^bright(\d+)$/);
if (ansiMatch) {
const idx = parseInt(ansiMatch[1], 10);
if (idx >= 0 && idx <= 7) colors.palette[idx] = color;
} else if (brightMatch) {
const idx = parseInt(brightMatch[1], 10);
if (idx >= 0 && idx <= 7) colors.palette[idx + 8] = color;
}
}
}
return colors;
}
function normalizeColor(color: string): string {
const trimmed = color.trim();
if (trimmed.startsWith("#")) {
if (trimmed.length === 4) {
return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`;
}
return trimmed.toLowerCase();
}
if (/^[0-9a-fA-F]{6}$/.test(trimmed)) {
return `#${trimmed}`.toLowerCase();
}
return `#${trimmed}`.toLowerCase();
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const h = hex.replace("#", "");
return {
r: parseInt(h.substring(0, 2), 16),
g: parseInt(h.substring(2, 4), 16),
b: parseInt(h.substring(4, 6), 16),
};
}
function rgbToHex(r: number, g: number, b: number): string {
const clamp = (n: number) => Math.round(Math.min(255, Math.max(0, n)));
return `#${clamp(r).toString(16).padStart(2, "0")}${clamp(g).toString(16).padStart(2, "0")}${clamp(b).toString(16).padStart(2, "0")}`;
}
function getLuminance(hex: string): number {
const { r, g, b } = hexToRgb(hex);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}
function adjustBrightness(hex: string, amount: number): string {
const { r, g, b } = hexToRgb(hex);
return rgbToHex(r + amount, g + amount, b + amount);
}
function mixColors(color1: string, color2: string, weight: number): string {
const c1 = hexToRgb(color1);
const c2 = hexToRgb(color2);
return rgbToHex(
c1.r * weight + c2.r * (1 - weight),
c1.g * weight + c2.g * (1 - weight),
c1.b * weight + c2.b * (1 - weight),
);
}
function generatePiTheme(colors: WeztermColors, themeName: string): object {
const bg = colors.background;
const fg = colors.foreground;
const isDark = getLuminance(bg) < 0.5;
// ANSI color slots - trust the standard for semantic colors
const error = colors.palette[1] || "#cc6666";
const success = colors.palette[2] || "#98c379";
const warning = colors.palette[3] || "#e5c07b";
const link = colors.palette[4] || "#61afef";
const accent = colors.palette[5] || "#c678dd";
const accentAlt = colors.palette[6] || "#56b6c2";
// Derive neutrals from bg/fg for consistent readability
const muted = mixColors(fg, bg, 0.65);
const dim = mixColors(fg, bg, 0.45);
const borderMuted = mixColors(fg, bg, 0.25);
// Derive backgrounds
const bgShift = isDark ? 12 : -12;
const selectedBg = adjustBrightness(bg, bgShift);
const userMsgBg = adjustBrightness(bg, Math.round(bgShift * 0.7));
const toolPendingBg = adjustBrightness(bg, Math.round(bgShift * 0.4));
const toolSuccessBg = mixColors(bg, success, 0.88);
const toolErrorBg = mixColors(bg, error, 0.88);
const customMsgBg = mixColors(bg, accent, 0.92);
return {
$schema:
"https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
name: themeName,
vars: {
bg,
fg,
accent,
accentAlt,
link,
error,
success,
warning,
muted,
dim,
borderMuted,
selectedBg,
userMsgBg,
toolPendingBg,
toolSuccessBg,
toolErrorBg,
customMsgBg,
},
colors: {
accent: "accent",
border: "link",
borderAccent: "accent",
borderMuted: "borderMuted",
success: "success",
error: "error",
warning: "warning",
muted: "muted",
dim: "dim",
text: "",
thinkingText: "muted",
selectedBg: "selectedBg",
userMessageBg: "userMsgBg",
userMessageText: "",
customMessageBg: "customMsgBg",
customMessageText: "",
customMessageLabel: "accent",
toolPendingBg: "toolPendingBg",
toolSuccessBg: "toolSuccessBg",
toolErrorBg: "toolErrorBg",
toolTitle: "",
toolOutput: "muted",
mdHeading: "warning",
mdLink: "link",
mdLinkUrl: "dim",
mdCode: "accent",
mdCodeBlock: "success",
mdCodeBlockBorder: "muted",
mdQuote: "muted",
mdQuoteBorder: "muted",
mdHr: "muted",
mdListBullet: "accent",
toolDiffAdded: "success",
toolDiffRemoved: "error",
toolDiffContext: "muted",
syntaxComment: "muted",
syntaxKeyword: "accent",
syntaxFunction: "link",
syntaxVariable: "accentAlt",
syntaxString: "success",
syntaxNumber: "accent",
syntaxType: "accentAlt",
syntaxOperator: "fg",
syntaxPunctuation: "muted",
thinkingOff: "borderMuted",
thinkingMinimal: "muted",
thinkingLow: "link",
thinkingMedium: "accentAlt",
thinkingHigh: "accent",
thinkingXhigh: "accent",
bashMode: "success",
},
export: {
pageBg: isDark ? adjustBrightness(bg, -8) : adjustBrightness(bg, 8),
cardBg: bg,
infoBg: mixColors(bg, warning, 0.88),
},
};
}
function computeThemeHash(colors: WeztermColors): string {
const parts: string[] = [];
parts.push(`bg=${colors.background}`);
parts.push(`fg=${colors.foreground}`);
for (let i = 0; i <= 15; i++) {
parts.push(`p${i}=${colors.palette[i] ?? ""}`);
}
return createHash("sha1").update(parts.join("\n")).digest("hex").slice(0, 8);
}
function cleanupOldThemes(themesDir: string, keepFile: string): void {
try {
for (const file of readdirSync(themesDir)) {
if (file === keepFile) continue;
if (file.startsWith("wezterm-sync-") && file.endsWith(".json")) {
unlinkSync(join(themesDir, file));
}
}
} catch {
// Best-effort cleanup
}
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
const configDir = findConfigDir();
if (!configDir) {
return;
}
const lua = findLua();
if (!lua) {
return;
}
const colors = getWeztermColors(configDir, lua);
if (!colors) {
return;
}
const themesDir = join(homedir(), ".pi", "agent", "themes");
if (!existsSync(themesDir)) {
mkdirSync(themesDir, { recursive: true });
}
const hash = computeThemeHash(colors);
const themeName = `wezterm-sync-${hash}`;
const themeFile = `${themeName}.json`;
const themePath = join(themesDir, themeFile);
// Skip if already on the correct synced theme (avoids repaint)
if (ctx.ui.theme.name === themeName) {
return;
}
const themeJson = generatePiTheme(colors, themeName);
writeFileSync(themePath, JSON.stringify(themeJson, null, 2));
// Remove old generated themes
cleanupOldThemes(themesDir, themeFile);
// Set by name so pi loads from the file we just wrote
const result = ctx.ui.setTheme(themeName);
if (!result.success) {
ctx.ui.notify(`WezTerm theme sync failed: ${result.error}`, "error");
}
});
}

View File

@@ -1,140 +0,0 @@
{
"version": 1,
"servers": {
"qmd": {
"configHash": "fd16eaf87d17a4ce5efee10dc65237dbbe1403353bbbfc4a7de196abe21ab5f9",
"tools": [
{
"name": "query",
"description": "Search the knowledge base using a query document — one or more typed sub-queries combined for best recall.\n\n## Query Types\n\n**lex** — BM25 keyword search. Fast, exact, no LLM needed.\nFull lex syntax:\n- `term` — prefix match (\"perf\" matches \"performance\")\n- `\"exact phrase\"` — phrase must appear verbatim\n- `-term` or `-\"phrase\"` — exclude documents containing this\n\nGood lex examples:\n- `\"connection pool\" timeout -redis`\n- `\"machine learning\" -sports -athlete`\n- `handleError async typescript`\n\n**vec** — Semantic vector search. Write a natural language question. Finds documents by meaning, not exact words.\n- `how does the rate limiter handle burst traffic?`\n- `what is the tradeoff between consistency and availability?`\n\n**hyde** — Hypothetical document. Write 50-100 words that look like the answer. Often the most powerful for nuanced topics.\n- `The rate limiter uses a token bucket algorithm. When a client exceeds 100 req/min, subsequent requests return 429 until the window resets.`\n\n## Strategy\n\nCombine types for best results. First sub-query gets 2× weight — put your strongest signal first.\n\n| Goal | Approach |\n|------|----------|\n| Know exact term/name | `lex` only |\n| Concept search | `vec` only |\n| Best recall | `lex` + `vec` |\n| Complex/nuanced | `lex` + `vec` + `hyde` |\n| Unknown vocabulary | Use a standalone natural-language query (no typed lines) so the server can auto-expand it |\n\n## Examples\n\nSimple lookup:\n```json\n[{ \"type\": \"lex\", \"query\": \"CAP theorem\" }]\n```\n\nBest recall on a technical topic:\n```json\n[\n { \"type\": \"lex\", \"query\": \"\\\"connection pool\\\" timeout -redis\" },\n { \"type\": \"vec\", \"query\": \"why do database connections time out under load\" },\n { \"type\": \"hyde\", \"query\": \"Connection pool exhaustion occurs when all connections are in use and new requests must wait. This typically happens under high concurrency when queries run longer than expected.\" }\n]\n```\n\nIntent-aware lex (C++ performance, not sports):\n```json\n[\n { \"type\": \"lex\", \"query\": \"\\\"C++ performance\\\" optimization -sports -athlete\" },\n { \"type\": \"vec\", \"query\": \"how to optimize C++ program performance\" }\n]\n```",
"inputSchema": {
"type": "object",
"properties": {
"searches": {
"minItems": 1,
"maxItems": 10,
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"lex",
"vec",
"hyde"
],
"description": "lex = BM25 keywords (supports \"phrase\" and -negation); vec = semantic question; hyde = hypothetical answer passage"
},
"query": {
"type": "string",
"description": "The query text. For lex: use keywords, \"quoted phrases\", and -negation. For vec: natural language question. For hyde: 50-100 word answer passage."
}
},
"required": [
"type",
"query"
]
},
"description": "Typed sub-queries to execute (lex/vec/hyde). First gets 2x weight."
},
"limit": {
"default": 10,
"description": "Max results (default: 10)",
"type": "number"
},
"minScore": {
"default": 0,
"description": "Min relevance 0-1 (default: 0)",
"type": "number"
},
"collections": {
"description": "Filter to collections (OR match)",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"searches"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get",
"description": "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.",
"inputSchema": {
"type": "object",
"properties": {
"file": {
"type": "string",
"description": "File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"
},
"fromLine": {
"description": "Start from this line number (1-indexed)",
"type": "number"
},
"maxLines": {
"description": "Maximum number of lines to return",
"type": "number"
},
"lineNumbers": {
"default": false,
"description": "Add line numbers to output (format: 'N: content')",
"type": "boolean"
}
},
"required": [
"file"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "multi_get",
"description": "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
"inputSchema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern or comma-separated list of file paths"
},
"maxLines": {
"description": "Maximum lines per file",
"type": "number"
},
"maxBytes": {
"default": 10240,
"description": "Skip files larger than this (default: 10240 = 10KB)",
"type": "number"
},
"lineNumbers": {
"default": false,
"description": "Add line numbers to output (format: 'N: content')",
"type": "boolean"
}
},
"required": [
"pattern"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "status",
"description": "Show the status of the QMD index: collections, document counts, and health information.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
],
"resources": [],
"cachedAt": 1772656106222
}
}
}

View File

@@ -11,7 +11,9 @@
"command": "opty",
"args": [
"mcp"
]
],
"directTools": true,
"lifecycle": "lazy"
}
}
}

View File

@@ -1,8 +0,0 @@
{"agent":"scout","task":"I need to understand the editor and rendering architecture for implementing entity picking. Please find and summarize:\n\n1. How editor mode is toggled (look for key 'I' handling, editor mode state)\n2. ","ts":1772639557,"status":"ok","duration":90399}
{"agent":"scout","task":"I need to understand the editor and rendering architecture for implementing entity picking. Please find and summarize:\n\n1. How editor mode is toggled (look for key 'I' handling, editor mode flag)\n2. T","ts":1772654920,"status":"ok","duration":1217167}
{"agent":"scout","task":"I need to understand the codebase to plan entity picking in the editor. Find and summarize:\n\n1. How the editor mode works (toggled with 'I' key) - find the editor module/system, what it currently does","ts":1772655378,"status":"ok","duration":282743}
{"agent":"scout","task":"I need to understand the codebase to plan entity picking in the editor. Find and summarize:\n\n1. How editor mode works - look for editor-related code, the \"I\" key toggle, inspector UI\n2. How entities a","ts":1772655580,"status":"ok","duration":174603}
{"agent":"scout","task":"Explore the codebase structure. Find: 1) the main game loop and input handling, 2) the ECS system and component definitions, 3) any existing UI/rendering systems, 4) entity types (player, trees, etc),","ts":1772656483,"status":"ok","duration":451373}
{"agent":"scout","task":"I need to understand the rendering pipeline, scene loading, and transform usage for implementing entity picking with a visual selection indicator. Find and read:\n\n1. `src/render/mod.rs` - the full ren","ts":1772656502,"status":"ok","duration":82458}
{"agent":"scout","task":"Explore the codebase at /home/jonas/projects/snow_trail_sdl and give me a detailed summary of:\n1. src/loaders/mesh.rs - full content, especially the Mesh struct and all constructor functions\n2. src/pi","ts":1772658265,"status":"ok","duration":330549}
{"agent":"scout","task":"Find and summarize: 1) how the camera follow/update logic works, 2) how editor mode is tracked/detected. Look for camera systems, editor state, and any existing checks for editor mode in camera code.","ts":1772659168,"status":"ok","duration":5992}

View File

@@ -0,0 +1,81 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "wezterm-sync-ba8a76f5",
"vars": {
"bg": "#1c2433",
"fg": "#afbbd2",
"accent": "#b78aff",
"accentAlt": "#ff955c",
"link": "#69c3ff",
"error": "#ff738a",
"success": "#3cec85",
"warning": "#eacd61",
"muted": "#7c869a",
"dim": "#5e687b",
"borderMuted": "#414a5b",
"selectedBg": "#28303f",
"userMsgBg": "#242c3b",
"toolPendingBg": "#212938",
"toolSuccessBg": "#203c3d",
"toolErrorBg": "#372d3d",
"customMsgBg": "#282c43"
},
"colors": {
"accent": "accent",
"border": "link",
"borderAccent": "accent",
"borderMuted": "borderMuted",
"success": "success",
"error": "error",
"warning": "warning",
"muted": "muted",
"dim": "dim",
"text": "",
"thinkingText": "muted",
"selectedBg": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "",
"customMessageBg": "customMsgBg",
"customMessageText": "",
"customMessageLabel": "accent",
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",
"toolTitle": "",
"toolOutput": "muted",
"mdHeading": "warning",
"mdLink": "link",
"mdLinkUrl": "dim",
"mdCode": "accent",
"mdCodeBlock": "success",
"mdCodeBlockBorder": "muted",
"mdQuote": "muted",
"mdQuoteBorder": "muted",
"mdHr": "muted",
"mdListBullet": "accent",
"toolDiffAdded": "success",
"toolDiffRemoved": "error",
"toolDiffContext": "muted",
"syntaxComment": "muted",
"syntaxKeyword": "accent",
"syntaxFunction": "link",
"syntaxVariable": "accentAlt",
"syntaxString": "success",
"syntaxNumber": "accent",
"syntaxType": "accentAlt",
"syntaxOperator": "fg",
"syntaxPunctuation": "muted",
"thinkingOff": "borderMuted",
"thinkingMinimal": "muted",
"thinkingLow": "link",
"thinkingMedium": "accentAlt",
"thinkingHigh": "accent",
"thinkingXhigh": "accent",
"bashMode": "success"
},
"export": {
"pageBg": "#141c2b",
"cardBg": "#1c2433",
"infoBg": "#353839"
}
}

3
pi/.pi/settings.json Normal file
View File

@@ -0,0 +1,3 @@
{
"hide_thinking_block": true
}

View File

@@ -0,0 +1,360 @@
"$schema" = 'https://starship.rs/config-schema.json'
add_newline = true
command_timeout = 2000
format = """
$os\
$username\
$directory\
$git_branch\
$git_commit\
$git_status\
$git_metrics\
$git_state\
$c\
$rust\
$golang\
$nodejs\
$php\
$java\
$kotlin\
$haskell\
$python\
$package\
$docker_context\
$kubernetes\
$shell\
$container\
$jobs\
${custom.memory_usage}\
${custom.battery}\
${custom.keyboard_layout}\
$time\
$cmd_duration\
$status\
$line_break\
$character\
"""
palette = 'bearded-arc'
[palettes.bearded-arc]
color_ok = '#3CEC85'
color_danger = '#FF738A'
color_caution = '#EACD61'
color_os = '#FF738A'
color_username = '#FF738A'
color_directory = '#EACD61'
color_git = '#22ECDB'
color_git_added = '#3CEC85'
color_git_deleted = '#FF738A'
color_env = '#69C3FF'
color_kubernetes = '#bd93ff'
color_docker = '#69C3FF'
color_shell = '#ABB7C1'
color_container = '#FF955C'
color_other = '#ABB7C1'
color_time = '#c3cfd9'
color_duration = '#c3cfd9'
color_vimcmd_ok = '#9bdead'
color_vimcmd_replace = '#bd93ff'
color_vimcmd_visual = '#EACD61'
[os]
disabled = false
style = "fg:color_os"
format = '[$symbol]($style)'
[os.symbols]
Windows = "󰍲"
Ubuntu = "󰕈"
SUSE = ""
Raspbian = "󰐿"
Mint = "󰣭"
Macos = "󰀵"
Manjaro = ""
Linux = "󰌽"
Gentoo = "󰣨"
Fedora = "󰣛"
Alpine = ""
Amazon = ""
Android = ""
Arch = "󰣇"
Artix = "󰣇"
EndeavourOS = ""
CentOS = ""
Debian = "󰣚"
Redhat = "󱄛"
RedHatEnterprise = "󱄛"
Pop = ""
[username]
show_always = true
style_user = "fg:color_username"
style_root = "bold fg:color_danger"
format = '[ $user ]($style)'
[directory]
style = "fg:color_directory"
read_only_style = "fg:color_directory"
repo_root_style = "bold fg:color_directory"
format = "[ $path ]($style)"
read_only = " "
home_symbol = "~"
truncation_symbol = "…/"
truncation_length = 0
truncate_to_repo = true
fish_style_pwd_dir_length = 0
use_logical_path = true
[git_branch]
symbol = ""
style = "fg:color_git"
format = '( [$symbol $branch]($style) )'
only_attached = true
ignore_branches = []
truncation_length = 25
truncation_symbol = "..."
always_show_remote = false
disabled = false
[git_commit]
style = "fg:color_git"
format = "( [($tag)(@$hash)]($style) )"
commit_hash_length = 7
only_detached = true
tag_symbol = "󰓼 "
tag_disabled = false
disabled = false
[git_status]
style = "fg:color_git"
format = '([$ahead_behind]($style) )([$all_status]($style) )'
stashed = "*${count}"
ahead = "⇡${count}"
behind = "⇣${count}"
up_to_date = ""
diverged = "⇡${ahead_count}⇣${behind_count}"
conflicted = "=${count}"
deleted = "×${count}"
renamed = "»${count}"
modified = "!${count}"
staged = "+${count}"
untracked = "?${count}"
ignore_submodules = false
disabled = false
[git_metrics]
format = '([([+$added]($added_style))([-$deleted]($deleted_style))](fg:color_git) )'
added_style = "fg:color_git_added"
deleted_style = "fg:color_git_deleted"
only_nonzero_diffs = true
disabled = false
[git_state]
style = "fg:color_danger"
format = '([$state( $progress_current/$progress_total)]($style bold) )'
rebase = "REBASING"
merge = "MERGING"
revert = "REVERTING"
cherry_pick = "CHERRY-PICKING"
bisect = "BISECTING"
am = "AM"
am_or_rebase = "AM/REBASE"
disabled = false
[nodejs]
symbol = ""
style = "fg:color_env"
format = '( [$symbol( $version)]($style) )'
[c]
symbol = ""
style = "fg:color_env"
format = '( [$symbol( $version)]($style) )'
[rust]
symbol = "󱘗"
style = "fg:color_env"
format = '( [$symbol( $version)]($style) )'
[golang]
symbol = "󰟓"
style = "fg:color_env"
format = '( [$symbol( $version)]($style) )'
[php]
symbol = ""
style = "fg:color_env"
format = '( [$symbol( $version)]($style) )'
[java]
symbol = ""
style = "fg:color_env"
format = '( [$symbol( $version)]($style) )'
[kotlin]
symbol = ""
style = "fg:color_env"
format = '( [$symbol( $version)]($style) )'
[haskell]
symbol = ""
style = "fg:color_env"
format = '( [$symbol( $version)]($style) )'
[python]
symbol = ""
style = "fg:color_env"
format = '( [$symbol( $version)( $virtualenv)]($style) )'
version_format = '${raw}'
[package]
disabled = false
symbol = "󰏗"
style = "fg:color_env"
format = '( [$symbol( $version)]($style) )'
[docker_context]
symbol = ""
style = "fg:color_docker"
format = '( [$symbol( $context)]($style) )'
[kubernetes]
symbol = "󱃾"
style = "fg:color_kubernetes"
format = '( [($symbol( $cluster))]($style) )'
disabled = false
[shell]
disabled = true
[container]
style = "fg:color_container"
format = '( [$symbol $name]($style) )'
[jobs]
symbol = "󰒋"
style = "fg:color_other"
format = '( [$symbol( $number)]($style) )'
symbol_threshold = 1
number_threshold = 1
[custom.memory_usage]
command = "starship module memory_usage"
when = '[ "${STARSHIP_COCKPIT_MEMORY_USAGE_ENABLED:-false}" = "true" ]'
shell = "sh"
format = "( $output )"
disabled = false
[memory_usage]
threshold = 0
symbol = "󰓅"
style = "fg:color_other"
format = '( [$symbol( ${ram})]($style) )'
disabled = false
[custom.battery]
command = """
battery_info=$(starship module battery)
if [ -n "$battery_info" ]; then
percent=$(echo "$battery_info" | grep -o '[0-9]*%' | sed 's/%//')
if [ "$percent" -le "${STARSHIP_COCKPIT_BATTERY_THRESHOLD:-0}" ]; then
echo "$battery_info" | sed 's/%%/%/'
fi
fi
"""
when = '[ "${STARSHIP_COCKPIT_BATTERY_ENABLED:-false}" = "true" ]'
shell = "sh"
format = "( $output )"
disabled = false
[battery]
full_symbol = "󰁹"
charging_symbol = "󰂄"
discharging_symbol = "󰂃"
unknown_symbol = "󰂑"
empty_symbol = "󰂎"
format = '( [$symbol( $percentage)]($style) )'
disabled = false
[[battery.display]]
threshold = 10
style = "bold fg:color_danger"
[[battery.display]]
threshold = 20
style = "fg:color_caution"
[[battery.display]]
threshold = 100
style = "fg:color_other"
[time]
disabled = false
time_format = "%R"
style = "fg:color_time"
format = '( [󰔛 $time]($style) )'
[cmd_duration]
min_time = 2000
format = '( [󱫑 $duration]($style) )'
style = 'fg:color_duration'
show_milliseconds = false
disabled = false
[status]
disabled = false
format = '( [$symbol( $common_meaning)( $signal_name)]($style) )'
map_symbol = true
pipestatus = true
symbol = '󰅙'
success_symbol = ''
not_executable_symbol = '󰂭'
not_found_symbol = '󰍉'
sigint_symbol = '󰐊'
signal_symbol = '󱐋'
style = 'bold fg:color_danger'
recognize_signal_code = true
[line_break]
disabled = false
[character]
disabled = false
success_symbol = '[](bold fg:color_ok)'
error_symbol = '[](bold fg:color_danger)'
vimcmd_symbol = '[](bold fg:color_vimcmd_ok)'
vimcmd_replace_one_symbol = '[](bold fg:color_vimcmd_replace)'
vimcmd_replace_symbol = '[](bold fg:color_vimcmd_replace)'
vimcmd_visual_symbol = '[](bold fg:color_vimcmd_visual)'
[custom.keyboard_layout]
command = """
# Set env variables if you want to use layout aliases (in uppercase)
# export STARSHIP_COCKPIT_KEYBOARD_LAYOUT_ABC=ENG
# export STARSHIP_COCKPIT_KEYBOARD_LAYOUT_UKRAINIAN=UKR
#
# Implementations:
# macOS
if [ "$(uname -s)" = "Darwin" ]; then
input_source=$(defaults read ~/Library/Preferences/com.apple.HIToolbox.plist AppleCurrentKeyboardLayoutInputSourceID)
layout_id=$(echo "$input_source" | cut -d '.' -f4)
layout=$(printenv "STARSHIP_COCKPIT_KEYBOARD_LAYOUT_$(echo "$layout_id" | tr '[:lower:]' '[:upper:]')")
echo "$layout" || echo "$layout_id"
fi
"""
symbol = "󰌌"
style = "fg:color_other"
format = '( [$symbol $output]($style) )'
when = '[ "${STARSHIP_COCKPIT_KEYBOARD_LAYOUT_ENABLED:-false}" = "true" ]'
shell = "sh"
disabled = false

View File

@@ -1,25 +1,109 @@
#!/bin/bash
# Opens a new terminal, using the current terminal's working directory if focused window is a terminal
# Defaults to home directory (~) if no terminal is focused
# Parse file URI to extract path
parse_file_uri() {
local uri="$1"
# Remove file:// prefix
local path="${uri#file://}"
# Handle localhost or hostname prefix: file://hostname/path -> /path
if [[ "$path" =~ ^localhost(/.*) ]] || [[ "$path" =~ ^[a-zA-Z0-9.-]+(/.*) ]]; then
path="${BASH_REMATCH[1]}"
fi
echo "$path"
}
# Try to get cwd from focused wezterm window
# Arguments: window_title from Sway's focused window
get_wezterm_cwd() {
local sway_window_title="$1"
# Check if wezterm is available
if ! command -v wezterm &> /dev/null; then
return 1
fi
# Get list of wezterm windows/panes
local wezterm_data
wezterm_data=$(wezterm cli list --format json 2>/dev/null) || return 1
# Return early if no data
if [ -z "$wezterm_data" ]; then
return 1
fi
local cwd
# Try to match the Sway window title with wezterm's window_title first
# (this handles windows with explicit titles set)
if [ -n "$sway_window_title" ] && [ "$sway_window_title" != "null" ]; then
cwd=$(echo "$wezterm_data" | jq -r --arg title "$sway_window_title" '.[] | select(.window_title == $title) | .cwd' | head -n 1)
fi
# If no match by window_title, try matching by pane title
# When multiple matches exist, pick the highest window_id (most recent)
if [ -z "$cwd" ] || [ "$cwd" = "null" ]; then
cwd=$(echo "$wezterm_data" | jq -r --arg title "$sway_window_title" '[.[] | select(.title == $title)] | sort_by(.window_id) | .[-1] | .cwd' 2>/dev/null)
fi
# If the Sway window title looks like an app (nvim, vim, pi, claude, etc),
# look for the pane with a visible cursor (likely the active app)
if [ -z "$cwd" ] || [ "$cwd" = "null" ]; then
local app_pattern="nvim|vim|pi|claude|less|more|man|htop|top|nano|emacs"
if [[ "$sway_window_title" =~ ^($app_pattern) ]]; then
# Try to find a pane with visible cursor (most likely the active one)
cwd=$(echo "$wezterm_data" | jq -r '.[] | select(.cursor_visibility == "Visible") | .cwd' | head -n 1)
fi
fi
# Final fallback: just get most recent pane with valid cwd
if [ -z "$cwd" ] || [ "$cwd" = "null" ]; then
cwd=$(echo "$wezterm_data" | jq -r '[.[] | select(.cwd != null and .cwd != "")] | sort_by(.window_id) | .[-1] | .cwd' 2>/dev/null)
fi
# If still nothing, fail
if [ -z "$cwd" ] || [ "$cwd" = "null" ]; then
return 1
fi
# Parse the URI if needed
if [[ "$cwd" == file://* ]]; then
cwd=$(parse_file_uri "$cwd")
fi
# Verify path exists
if [ -d "$cwd" ]; then
echo "$cwd"
return 0
fi
return 1
}
# Main logic
cwd=""
# Get focused window info from Sway
focused_info=$(swaymsg -t get_tree | jq -r '.. | select(.focused? == true) | {app_id: .app_id, name: .name}')
app_id=$(echo "$focused_info" | jq -r '.app_id')
window_name=$(echo "$focused_info" | jq -r '.name')
if command -v swaymsg &> /dev/null; then
focused_window=$(swaymsg -t get_tree 2>/dev/null | jq -r '.. | select(.focused? == true and .app_id? != null) | [.app_id, .name] | @tsv' | head -n 1)
if [ "$app_id" = "org.wezfurlong.wezterm" ]; then
# Match the Sway window title with wezterm's window_title to get the correct pane's cwd
cwd=$(wezterm cli list --format json 2>/dev/null | jq -r --arg title "$window_name" '.[] | select(.window_title == $title) | .cwd' | head -n 1)
# Parse tab-separated values
app_id=$(echo "$focused_window" | cut -f1)
window_name=$(echo "$focused_window" | cut -f2)
if [ -n "$cwd" ] && [ "$cwd" != "null" ]; then
# Remove file:// prefix and hostname (format: file://hostname/path)
cwd=$(echo "$cwd" | sed 's|^file://[^/]*/|/|')
# Check if focused window is wezterm (app_id contains "wez")
if [ -n "$app_id" ] && [[ "$app_id" == *"wez"* ]]; then
cwd=$(get_wezterm_cwd "$window_name")
fi
fi
if [ -d "$cwd" ]; then
# Open terminal with cwd if we found one, otherwise default to home
if [ -n "$cwd" ] && [ -d "$cwd" ]; then
wezterm start --cwd "$cwd" &
exit 0
else
wezterm start --cwd "$HOME" &
fi
fi
fi
# Fallback: open terminal in home directory
wezterm &

View File

@@ -14,6 +14,14 @@ setopt HIST_IGNORE_SPACE # Don't save commands starting with space
setopt INC_APPEND_HISTORY # Write to history immediately, not at shell exit
setopt SHARE_HISTORY # Share history between sessions
# Completion
autoload -U compinit && compinit
zmodload zsh/complist
zstyle ':completion:*' menu select # show menu, highlight selection
zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS} # file-type colors (dirs, executables, etc.)
zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' # case-insensitive matching
# Plugins
source ~/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.plugin.zsh
source ~/.zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.plugin.zsh
@@ -55,3 +63,4 @@ export PYENV_ROOT="$HOME/.pyenv"
eval "$(pyenv init - zsh)"
eval "$(starship init zsh)"
eval "$(zoxide init zsh)"