Compare commits
38 Commits
7d34751695
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b11a0d315 | ||
|
|
508eddef62 | ||
|
|
529764d15e | ||
|
|
871caa5adc | ||
|
|
a3c9183485 | ||
|
|
601dfb2332 | ||
|
|
af15044f5c | ||
|
|
9c82b54a52 | ||
|
|
6a92aa066a | ||
|
|
54642fe7e4 | ||
|
|
bdf1f84e71 | ||
|
|
d308af7454 | ||
|
|
c419eb5f92 | ||
|
|
de49d03182 | ||
| f1a0806dbb | |||
| 8069f6f150 | |||
| e149dac313 | |||
|
|
870dc6ac58 | ||
|
|
209dad84a1 | ||
|
|
6bf59a4528 | ||
|
|
afedf4d3c9 | ||
|
|
8e1bef3e17 | ||
|
|
bea8fa19b3 | ||
|
|
cf8623bca1 | ||
|
|
25f6ce81f3 | ||
|
|
df888eca4d | ||
|
|
bb4ea06b60 | ||
|
|
4542b5f48a | ||
|
|
5396c96fbe | ||
|
|
a4d518826c | ||
|
|
9106effa00 | ||
|
|
44acd484dd | ||
|
|
b09f6626ba | ||
|
|
806f4c8f98 | ||
|
|
683c770cbc | ||
|
|
c4da7c9f84 | ||
|
|
b4544c4710 | ||
|
|
f15f8916d4 |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,2 +1,14 @@
|
||||
# Stow metadata
|
||||
.stow-local-ignore
|
||||
pi/.pi/agent/profiles
|
||||
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
11
.gitmodules
vendored
Normal 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
15
dot-add
@@ -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
205
install-deps.sh
Executable 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
149
install-stow.sh
Executable 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."
|
||||
67
install.sh
67
install.sh
@@ -1,67 +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)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependencies (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 dnf packages..."
|
||||
sudo dnf install -y "${DNF_PACKAGES[@]}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stow all packages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "Stowing dotfiles..."
|
||||
for PACKAGE in "$DOTFILES_DIR"/*/; do
|
||||
PACKAGE="$(basename "$PACKAGE")"
|
||||
echo " $PACKAGE"
|
||||
stow --dir="$DOTFILES_DIR" --target="$HOME" --restow "$PACKAGE"
|
||||
done
|
||||
|
||||
echo "Done."
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
20
pi/.pi/agent/agents/explorer.md
Normal file
20
pi/.pi/agent/agents/explorer.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: explorer
|
||||
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
|
||||
defaultProgress: true
|
||||
---
|
||||
|
||||
You are an explorer. Thoroughly investigate a codebase or knowledge base and synthesize your findings into a comprehensive document.
|
||||
|
||||
**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
|
||||
|
||||
Be thorough and comprehensive — include all relevant code snippets and context needed to understand the codebase.
|
||||
|
||||
After writing to the file, confirm completion with: "Context saved to: /home/jonas/.pi/context.md"
|
||||
44
pi/.pi/agent/agents/scout.md
Normal file
44
pi/.pi/agent/agents/scout.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: scout
|
||||
description: Fast codebase recon using local Qwen model — searches, reads, returns compressed findings
|
||||
tools: read, grep, find, ls, bash, write, mcp:qmd, mcp:opty
|
||||
model: llama-cpp/unsloth/Qwen3.5-4B-GGUF:Q5_K_M
|
||||
output: context.md
|
||||
defaultProgress: true
|
||||
---
|
||||
|
||||
You are a scout. Quickly investigate a codebase and return structured findings.
|
||||
|
||||
When running in a chain, you'll receive instructions about where to write your output.
|
||||
When running solo, write to the provided output path and summarize what you found.
|
||||
|
||||
Thoroughness (infer from task, default medium):
|
||||
- Quick: Targeted lookups, key files only
|
||||
- Medium: Follow imports, read critical sections
|
||||
- Thorough: Trace all dependencies, check tests/types
|
||||
|
||||
Strategy:
|
||||
1. Use qmd tools for semantic/hybrid code search (preferred)
|
||||
2. Use opty tools for HDC-indexed context retrieval
|
||||
3. Fall back to grep/find only if qmd/opty don't find what you need
|
||||
4. Read key sections (not entire files)
|
||||
5. Identify types, interfaces, key functions
|
||||
6. Note dependencies between files
|
||||
|
||||
Your output format (context.md):
|
||||
|
||||
# Code Context
|
||||
|
||||
## Files Retrieved
|
||||
List with exact line ranges:
|
||||
1. `path/to/file.ts` (lines 10-50) - Description
|
||||
2. `path/to/other.ts` (lines 100-150) - Description
|
||||
|
||||
## Key Code
|
||||
Critical types, interfaces, or functions with actual code snippets.
|
||||
|
||||
## Architecture
|
||||
Brief explanation of how the pieces connect.
|
||||
|
||||
## Start Here
|
||||
Which file to look at first and why.
|
||||
320
pi/.pi/agent/extensions/claude-account-switch.ts
Normal file
320
pi/.pi/agent/extensions/claude-account-switch.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Claude Account Switch Extension
|
||||
*
|
||||
* 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
|
||||
* 2. /switch-claude save personal
|
||||
* 3. /login → authenticate with work account
|
||||
* 4. /switch-claude save work
|
||||
*
|
||||
* Usage:
|
||||
* /switch-claude — pick account interactively
|
||||
* /switch-claude save <name> — save current pi login as a named profile
|
||||
*/
|
||||
|
||||
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_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`);
|
||||
}
|
||||
|
||||
function hasProfile(account: Account): boolean {
|
||||
return fs.existsSync(profilePath(account));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
if (fs.existsSync(AUTH_JSON)) {
|
||||
fs.copyFileSync(AUTH_JSON, profilePath(account));
|
||||
fs.chmodSync(profilePath(account), 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" {
|
||||
try {
|
||||
const marker = fs.readFileSync(MARKER_FILE, "utf8").trim();
|
||||
if (marker === "personal" || marker === "work") return marker;
|
||||
} catch {}
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extension ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let currentAccount: Account | "unknown" = "unknown";
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
pi.registerCommand("switch-claude", {
|
||||
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 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",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 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 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",
|
||||
);
|
||||
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",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Perform the switch ──────────────────────────────────────────
|
||||
try {
|
||||
// 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)} ✓`,
|
||||
"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(
|
||||
`❌ Switch failed: ${msg}. Rolled back to ${statusLabel(currentAccount)}. ` +
|
||||
`You may need to /login and /switch-claude save ${newAccount}.`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
191
pi/.pi/agent/extensions/footer-display.ts
Normal file
191
pi/.pi/agent/extensions/footer-display.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
99
pi/.pi/agent/extensions/git-checkout-guard.ts
Normal file
99
pi/.pi/agent/extensions/git-checkout-guard.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
15
pi/.pi/agent/extensions/local-explorer.ts
Normal file
15
pi/.pi/agent/extensions/local-explorer.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Local Explorer Extension
|
||||
*
|
||||
* Previously auto-injected a scout hint into every cloud-model session.
|
||||
* Now a no-op — scout delegation is opt-in via the `local-scout` skill.
|
||||
* Invoke with /skill:local-scout or by mentioning "use scout" in a prompt.
|
||||
*
|
||||
* Placed in: ~/.pi/agent/extensions/local-explorer.ts
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (_pi: ExtensionAPI) {
|
||||
// No automatic injection — scout is opt-in via the local-scout skill.
|
||||
}
|
||||
178
pi/.pi/agent/extensions/lsp-pi/README.md
Normal file
178
pi/.pi/agent/extensions/lsp-pi/README.md
Normal 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
|
||||
12
pi/.pi/agent/extensions/lsp-pi/index.ts
Normal file
12
pi/.pi/agent/extensions/lsp-pi/index.ts
Normal 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);
|
||||
}
|
||||
1129
pi/.pi/agent/extensions/lsp-pi/lsp-core.ts
Normal file
1129
pi/.pi/agent/extensions/lsp-pi/lsp-core.ts
Normal file
File diff suppressed because it is too large
Load Diff
382
pi/.pi/agent/extensions/lsp-pi/lsp-tool.ts
Normal file
382
pi/.pi/agent/extensions/lsp-pi/lsp-tool.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
604
pi/.pi/agent/extensions/lsp-pi/lsp.ts
Normal file
604
pi/.pi/agent/extensions/lsp-pi/lsp.ts
Normal 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 }> };
|
||||
});
|
||||
}
|
||||
54
pi/.pi/agent/extensions/lsp-pi/package.json
Normal file
54
pi/.pi/agent/extensions/lsp-pi/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
235
pi/.pi/agent/extensions/lsp-pi/tests/index.test.ts
Normal file
235
pi/.pi/agent/extensions/lsp-pi/tests/index.test.ts
Normal 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();
|
||||
602
pi/.pi/agent/extensions/lsp-pi/tests/lsp-integration.test.ts
Normal file
602
pi/.pi/agent/extensions/lsp-pi/tests/lsp-integration.test.ts
Normal 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();
|
||||
898
pi/.pi/agent/extensions/lsp-pi/tests/lsp.test.ts
Normal file
898
pi/.pi/agent/extensions/lsp-pi/tests/lsp.test.ts
Normal 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();
|
||||
13
pi/.pi/agent/extensions/lsp-pi/tsconfig.json
Normal file
13
pi/.pi/agent/extensions/lsp-pi/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
82
pi/.pi/agent/extensions/new-with-context.ts
Normal file
82
pi/.pi/agent/extensions/new-with-context.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* new-with-context extension
|
||||
*
|
||||
* Provides a /new-with-context command that starts a fresh session but carries
|
||||
* only the last message from the current conversation into the new session.
|
||||
*
|
||||
* Usage:
|
||||
* /new-with-context
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds the last user or assistant message in the current branch
|
||||
* 2. Starts a new session
|
||||
* 3. Injects that last message as the opening context
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("new-with-context", {
|
||||
description: "Start a new session keeping only the last message as context",
|
||||
handler: async (args, ctx) => {
|
||||
await ctx.waitForIdle();
|
||||
|
||||
// Collect all message entries from the current branch
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const messageEntries = branch.filter(
|
||||
(entry): entry is SessionEntry & { type: "message" } => entry.type === "message",
|
||||
);
|
||||
|
||||
if (messageEntries.length === 0) {
|
||||
ctx.ui.notify("No messages in current session to carry over.", "info");
|
||||
await ctx.newSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab the last message entry
|
||||
const lastEntry = messageEntries[messageEntries.length - 1];
|
||||
const lastMessage = lastEntry.message;
|
||||
|
||||
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
||||
|
||||
// Create a new session and inject the last message as opening context
|
||||
const result = await ctx.newSession({
|
||||
parentSession: currentSessionFile ?? undefined,
|
||||
setup: async (sm) => {
|
||||
sm.appendMessage(lastMessage);
|
||||
},
|
||||
});
|
||||
|
||||
if (result.cancelled) {
|
||||
ctx.ui.notify("New session cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Give a brief summary of what was carried over
|
||||
const role = lastMessage.role;
|
||||
let preview = "";
|
||||
if (role === "user" || role === "assistant") {
|
||||
const content = lastMessage.content;
|
||||
if (typeof content === "string") {
|
||||
preview = content.slice(0, 80);
|
||||
} else if (Array.isArray(content)) {
|
||||
const textBlock = content.find((c: any) => c.type === "text");
|
||||
if (textBlock && "text" in textBlock) {
|
||||
preview = (textBlock as { type: "text"; text: string }).text.slice(0, 80);
|
||||
}
|
||||
}
|
||||
} else if (role === "toolResult") {
|
||||
preview = `[tool result: ${(lastMessage as any).toolName}]`;
|
||||
} else {
|
||||
preview = `[${role} message]`;
|
||||
}
|
||||
|
||||
if (preview.length === 80) preview += "…";
|
||||
|
||||
ctx.ui.notify(
|
||||
`New session started. Carried over last message (${role}): "${preview}"`,
|
||||
"success",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
65
pi/.pi/agent/extensions/pi-ask-tool/ask-inline-note.ts
Normal file
65
pi/.pi/agent/extensions/pi-ask-tool/ask-inline-note.ts
Normal 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 : [""];
|
||||
}
|
||||
223
pi/.pi/agent/extensions/pi-ask-tool/ask-inline-ui.ts
Normal file
223
pi/.pi/agent/extensions/pi-ask-tool/ask-inline-ui.ts
Normal 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);
|
||||
}
|
||||
98
pi/.pi/agent/extensions/pi-ask-tool/ask-logic.ts
Normal file
98
pi/.pi/agent/extensions/pi-ask-tool/ask-logic.ts
Normal 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 };
|
||||
}
|
||||
514
pi/.pi/agent/extensions/pi-ask-tool/ask-tabs-ui.ts
Normal file
514
pi/.pi/agent/extensions/pi-ask-tool/ask-tabs-ui.ts
Normal 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 };
|
||||
}
|
||||
237
pi/.pi/agent/extensions/pi-ask-tool/index.ts
Normal file
237
pi/.pi/agent/extensions/pi-ask-tool/index.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
731
pi/.pi/agent/extensions/usage-bars/core.ts
Normal file
731
pi/.pi/agent/extensions/usage-bars/core.ts
Normal file
@@ -0,0 +1,731 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared disk cache — lets multiple concurrent pi sessions coordinate so only
|
||||
// one actually hits the API per cache window, regardless of how many sessions
|
||||
// are open. Modelled after claude-pulse's cache.json approach.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UsageCache {
|
||||
timestamp: number;
|
||||
data: Partial<Record<ProviderKey, UsageData>>;
|
||||
/** ISO timestamp until which a provider is rate-limited (429 backoff). */
|
||||
rateLimitedUntil?: Partial<Record<ProviderKey, number>>;
|
||||
}
|
||||
|
||||
const USAGE_CACHE_FILE = path.join(os.homedir(), ".pi", "agent", "usage-cache.json");
|
||||
|
||||
export function readUsageCache(): UsageCache | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(USAGE_CACHE_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed?.timestamp === "number") return parsed as UsageCache;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeUsageCache(cache: UsageCache): void {
|
||||
try {
|
||||
const dir = path.dirname(USAGE_CACHE_FILE);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${USAGE_CACHE_FILE}.tmp-${process.pid}-${Date.now()}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(cache, null, 2));
|
||||
fs.renameSync(tmp, USAGE_CACHE_FILE);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export type ProviderKey = "codex" | "claude" | "zai" | "gemini" | "antigravity";
|
||||
export type OAuthProviderId = "openai-codex" | "anthropic" | "google-gemini-cli" | "google-antigravity";
|
||||
|
||||
export interface AuthData {
|
||||
"openai-codex"?: { access?: string; refresh?: string; expires?: number };
|
||||
anthropic?: { access?: string; refresh?: string; expires?: number };
|
||||
zai?: { key?: string; access?: string; refresh?: string; expires?: number };
|
||||
"google-gemini-cli"?: { access?: string; refresh?: string; projectId?: string; expires?: number };
|
||||
"google-antigravity"?: { access?: string; refresh?: string; projectId?: string; expires?: number };
|
||||
}
|
||||
|
||||
export interface UsageData {
|
||||
session: number;
|
||||
weekly: number;
|
||||
sessionResetsIn?: string;
|
||||
weeklyResetsIn?: string;
|
||||
extraSpend?: number;
|
||||
extraLimit?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type UsageByProvider = Record<ProviderKey, UsageData | null>;
|
||||
|
||||
export interface UsageEndpoints {
|
||||
zai: string;
|
||||
gemini: string;
|
||||
antigravity: string;
|
||||
googleLoadCodeAssistEndpoints: string[];
|
||||
}
|
||||
|
||||
export interface FetchResponseLike {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<any>;
|
||||
}
|
||||
|
||||
export type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
|
||||
|
||||
export interface RequestConfig {
|
||||
fetchFn?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface FetchConfig extends RequestConfig {
|
||||
endpoints?: UsageEndpoints;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface OAuthApiKeyResult {
|
||||
newCredentials: Record<string, any>;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export type OAuthApiKeyResolver = (
|
||||
providerId: OAuthProviderId,
|
||||
credentials: Record<string, Record<string, any>>,
|
||||
) => Promise<OAuthApiKeyResult | null>;
|
||||
|
||||
export interface EnsureFreshAuthConfig {
|
||||
auth?: AuthData | null;
|
||||
authFile?: string;
|
||||
oauthResolver?: OAuthApiKeyResolver;
|
||||
nowMs?: number;
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export interface FreshAuthResult {
|
||||
auth: AuthData | null;
|
||||
changed: boolean;
|
||||
refreshErrors: Partial<Record<OAuthProviderId, string>>;
|
||||
}
|
||||
|
||||
export interface FetchAllUsagesConfig extends FetchConfig, EnsureFreshAuthConfig {
|
||||
auth?: AuthData | null;
|
||||
authFile?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 12_000;
|
||||
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
export const DEFAULT_AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json");
|
||||
export const DEFAULT_ZAI_USAGE_ENDPOINT = "https://api.z.ai/api/monitor/usage/quota/limit";
|
||||
export const GOOGLE_QUOTA_ENDPOINT = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota";
|
||||
export const GOOGLE_LOAD_CODE_ASSIST_ENDPOINTS = [
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist",
|
||||
];
|
||||
|
||||
export function resolveUsageEndpoints(): UsageEndpoints {
|
||||
return {
|
||||
zai: DEFAULT_ZAI_USAGE_ENDPOINT,
|
||||
gemini: GOOGLE_QUOTA_ENDPOINT,
|
||||
antigravity: GOOGLE_QUOTA_ENDPOINT,
|
||||
googleLoadCodeAssistEndpoints: GOOGLE_LOAD_CODE_ASSIST_ENDPOINTS,
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === "AbortError") return "request timeout";
|
||||
return error.message || String(error);
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, any> | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
return value as Record<string, any>;
|
||||
}
|
||||
|
||||
function normalizeUsagePair(session: number, weekly: number): { session: number; weekly: number } {
|
||||
const clean = (v: number) => {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Number(v.toFixed(2));
|
||||
};
|
||||
return { session: clean(session), weekly: clean(weekly) };
|
||||
}
|
||||
|
||||
async function requestJson(url: string, init: RequestInit, config: RequestConfig = {}): Promise<{ ok: true; data: any } | { ok: false; error: string }> {
|
||||
const fetchFn = config.fetchFn ?? ((fetch as unknown) as FetchLike);
|
||||
const timeoutMs = config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
const timeout = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, { ...init, signal: controller.signal });
|
||||
if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
|
||||
|
||||
try {
|
||||
const data = await response.json();
|
||||
return { ok: true, data };
|
||||
} catch {
|
||||
return { ok: false, error: "invalid JSON response" };
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, error: toErrorMessage(error) };
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "now";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0 && h > 0) return `${d}d ${h}h`;
|
||||
if (d > 0) return `${d}d`;
|
||||
if (h > 0 && m > 0) return `${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h`;
|
||||
if (m > 0) return `${m}m`;
|
||||
return "<1m";
|
||||
}
|
||||
|
||||
export function formatResetsAt(isoDate: string, nowMs = Date.now()): string {
|
||||
const resetTime = new Date(isoDate).getTime();
|
||||
if (!Number.isFinite(resetTime)) return "";
|
||||
const diffSeconds = Math.max(0, (resetTime - nowMs) / 1000);
|
||||
return formatDuration(diffSeconds);
|
||||
}
|
||||
|
||||
export function readAuth(authFile = DEFAULT_AUTH_FILE): AuthData | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||
return asObject(parsed) as AuthData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAuth(auth: AuthData, authFile = DEFAULT_AUTH_FILE): boolean {
|
||||
try {
|
||||
const dir = path.dirname(authFile);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const tmpPath = `${authFile}.tmp-${process.pid}-${Date.now()}`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(auth, null, 2));
|
||||
fs.renameSync(tmpPath, authFile);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedOAuthResolver: OAuthApiKeyResolver | null = null;
|
||||
|
||||
async function getDefaultOAuthResolver(): Promise<OAuthApiKeyResolver> {
|
||||
if (cachedOAuthResolver) return cachedOAuthResolver;
|
||||
|
||||
const mod = await import("@mariozechner/pi-ai");
|
||||
if (typeof (mod as any).getOAuthApiKey !== "function") {
|
||||
throw new Error("oauth resolver unavailable");
|
||||
}
|
||||
|
||||
cachedOAuthResolver = (providerId, credentials) =>
|
||||
(mod as any).getOAuthApiKey(providerId, credentials) as Promise<OAuthApiKeyResult | null>;
|
||||
|
||||
return cachedOAuthResolver;
|
||||
}
|
||||
|
||||
function isCredentialExpired(creds: { expires?: number } | undefined, nowMs: number): boolean {
|
||||
if (!creds) return false;
|
||||
if (typeof creds.expires !== "number") return false;
|
||||
return nowMs + TOKEN_REFRESH_SKEW_MS >= creds.expires;
|
||||
}
|
||||
|
||||
export async function ensureFreshAuthForProviders(
|
||||
providerIds: OAuthProviderId[],
|
||||
config: EnsureFreshAuthConfig = {},
|
||||
): Promise<FreshAuthResult> {
|
||||
const authFile = config.authFile ?? DEFAULT_AUTH_FILE;
|
||||
const auth = config.auth ?? readAuth(authFile);
|
||||
if (!auth) {
|
||||
return { auth: null, changed: false, refreshErrors: {} };
|
||||
}
|
||||
|
||||
const nowMs = config.nowMs ?? Date.now();
|
||||
const uniqueProviders = Array.from(new Set(providerIds));
|
||||
const nextAuth: AuthData = { ...auth };
|
||||
const refreshErrors: Partial<Record<OAuthProviderId, string>> = {};
|
||||
|
||||
let changed = false;
|
||||
|
||||
for (const providerId of uniqueProviders) {
|
||||
const creds = (nextAuth as any)[providerId] as { access?: string; refresh?: string; expires?: number } | undefined;
|
||||
if (!creds?.refresh) continue;
|
||||
|
||||
const needsRefresh = !creds.access || isCredentialExpired(creds, nowMs);
|
||||
if (!needsRefresh) continue;
|
||||
|
||||
try {
|
||||
const resolver = config.oauthResolver ?? (await getDefaultOAuthResolver());
|
||||
const resolved = await resolver(providerId, nextAuth as any);
|
||||
if (!resolved?.newCredentials) {
|
||||
refreshErrors[providerId] = "missing OAuth credentials";
|
||||
continue;
|
||||
}
|
||||
|
||||
(nextAuth as any)[providerId] = {
|
||||
...(nextAuth as any)[providerId],
|
||||
...resolved.newCredentials,
|
||||
};
|
||||
changed = true;
|
||||
} catch (error) {
|
||||
refreshErrors[providerId] = toErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed && config.persist !== false) {
|
||||
writeAuth(nextAuth, authFile);
|
||||
}
|
||||
|
||||
return { auth: nextAuth, changed, refreshErrors };
|
||||
}
|
||||
|
||||
export function readPercentCandidate(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
|
||||
if (value >= 0 && value <= 1) {
|
||||
if (Number.isInteger(value)) return value;
|
||||
return value * 100;
|
||||
}
|
||||
|
||||
if (value >= 0 && value <= 100) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readLimitPercent(limit: any): number | null {
|
||||
const direct = [
|
||||
limit?.percentage,
|
||||
limit?.utilization,
|
||||
limit?.used_percent,
|
||||
limit?.usedPercent,
|
||||
limit?.usagePercent,
|
||||
limit?.usage_percent,
|
||||
]
|
||||
.map(readPercentCandidate)
|
||||
.find((v) => v != null);
|
||||
|
||||
if (direct != null) return direct;
|
||||
|
||||
const current = typeof limit?.currentValue === "number" ? limit.currentValue : null;
|
||||
const remaining = typeof limit?.remaining === "number" ? limit.remaining : null;
|
||||
|
||||
if (current != null && remaining != null && current + remaining > 0) {
|
||||
return (current / (current + remaining)) * 100;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractUsageFromPayload(data: any): { session: number; weekly: number } | null {
|
||||
const limitArrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits];
|
||||
const limits = limitArrays.find((arr) => Array.isArray(arr)) as any[] | undefined;
|
||||
|
||||
if (limits) {
|
||||
const byType = (types: string[]) =>
|
||||
limits.find((l) => {
|
||||
const t = String(l?.type || "").toUpperCase();
|
||||
return types.some((x) => t === x);
|
||||
});
|
||||
|
||||
const sessionLimit = byType(["TIME_LIMIT", "SESSION_LIMIT", "REQUEST_LIMIT", "RPM_LIMIT", "RPD_LIMIT"]);
|
||||
const weeklyLimit = byType(["TOKENS_LIMIT", "TOKEN_LIMIT", "WEEK_LIMIT", "WEEKLY_LIMIT", "TPM_LIMIT", "DAILY_LIMIT"]);
|
||||
|
||||
const s = readLimitPercent(sessionLimit);
|
||||
const w = readLimitPercent(weeklyLimit);
|
||||
if (s != null && w != null) return normalizeUsagePair(s, w);
|
||||
}
|
||||
|
||||
const sessionCandidates = [
|
||||
data?.session,
|
||||
data?.sessionPercent,
|
||||
data?.session_percent,
|
||||
data?.five_hour?.utilization,
|
||||
data?.rate_limit?.primary_window?.used_percent,
|
||||
data?.limits?.session?.utilization,
|
||||
data?.usage?.session,
|
||||
data?.data?.session,
|
||||
data?.data?.sessionPercent,
|
||||
data?.data?.session_percent,
|
||||
data?.data?.usage?.session,
|
||||
data?.quota?.session?.percentage,
|
||||
data?.data?.quota?.session?.percentage,
|
||||
];
|
||||
|
||||
const weeklyCandidates = [
|
||||
data?.weekly,
|
||||
data?.weeklyPercent,
|
||||
data?.weekly_percent,
|
||||
data?.seven_day?.utilization,
|
||||
data?.rate_limit?.secondary_window?.used_percent,
|
||||
data?.limits?.weekly?.utilization,
|
||||
data?.usage?.weekly,
|
||||
data?.data?.weekly,
|
||||
data?.data?.weeklyPercent,
|
||||
data?.data?.weekly_percent,
|
||||
data?.data?.usage?.weekly,
|
||||
data?.quota?.weekly?.percentage,
|
||||
data?.data?.quota?.weekly?.percentage,
|
||||
data?.quota?.daily?.percentage,
|
||||
data?.data?.quota?.daily?.percentage,
|
||||
];
|
||||
|
||||
const session = sessionCandidates.map(readPercentCandidate).find((v) => v != null);
|
||||
const weekly = weeklyCandidates.map(readPercentCandidate).find((v) => v != null);
|
||||
|
||||
if (session == null || weekly == null) return null;
|
||||
return normalizeUsagePair(session, weekly);
|
||||
}
|
||||
|
||||
export function googleMetadata(projectId?: string) {
|
||||
return {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
...(projectId ? { duetProject: projectId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function googleHeaders(token: string, projectId?: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"X-Goog-Api-Client": "gl-node/22.17.0",
|
||||
"Client-Metadata": JSON.stringify(googleMetadata(projectId)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function discoverGoogleProjectId(token: string, config: FetchConfig = {}): Promise<string | undefined> {
|
||||
const env = config.env ?? process.env;
|
||||
const envProjectId = env.GOOGLE_CLOUD_PROJECT || env.GOOGLE_CLOUD_PROJECT_ID;
|
||||
if (envProjectId) return envProjectId;
|
||||
|
||||
const endpoints = config.endpoints ?? resolveUsageEndpoints();
|
||||
|
||||
for (const endpoint of endpoints.googleLoadCodeAssistEndpoints) {
|
||||
const result = await requestJson(
|
||||
endpoint,
|
||||
{
|
||||
method: "POST",
|
||||
headers: googleHeaders(token),
|
||||
body: JSON.stringify({ metadata: googleMetadata() }),
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) continue;
|
||||
|
||||
const data = result.data;
|
||||
if (typeof data?.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (data?.cloudaicompanionProject && typeof data.cloudaicompanionProject === "object") {
|
||||
const id = data.cloudaicompanionProject.id;
|
||||
if (typeof id === "string" && id) return id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function usedPercentFromRemainingFraction(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
const remaining = Math.max(0, Math.min(1, value));
|
||||
return (1 - remaining) * 100;
|
||||
}
|
||||
|
||||
export function pickMostUsedBucket(buckets: any[]): any | null {
|
||||
let best: any | null = null;
|
||||
let bestUsed = -1;
|
||||
for (const bucket of buckets) {
|
||||
const used = usedPercentFromRemainingFraction(bucket?.remainingFraction);
|
||||
if (used == null) continue;
|
||||
if (used > bestUsed) {
|
||||
bestUsed = used;
|
||||
best = bucket;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function parseGoogleQuotaBuckets(data: any, provider: "gemini" | "antigravity"): { session: number; weekly: number } | null {
|
||||
const allBuckets = Array.isArray(data?.buckets) ? data.buckets : [];
|
||||
if (!allBuckets.length) return null;
|
||||
|
||||
const requestBuckets = allBuckets.filter((b: any) => String(b?.tokenType || "").toUpperCase() === "REQUESTS");
|
||||
const buckets = requestBuckets.length ? requestBuckets : allBuckets;
|
||||
|
||||
const modelId = (b: any) => String(b?.modelId || "").toLowerCase();
|
||||
const claudeNonThinking = buckets.filter((b: any) => modelId(b).includes("claude") && !modelId(b).includes("thinking"));
|
||||
const geminiPro = buckets.filter((b: any) => modelId(b).includes("gemini") && modelId(b).includes("pro"));
|
||||
const geminiFlash = buckets.filter((b: any) => modelId(b).includes("gemini") && modelId(b).includes("flash"));
|
||||
|
||||
const primaryBucket =
|
||||
provider === "antigravity"
|
||||
? pickMostUsedBucket(claudeNonThinking) || pickMostUsedBucket(geminiPro) || pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(buckets)
|
||||
: pickMostUsedBucket(geminiPro) || pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(buckets);
|
||||
|
||||
const secondaryBucket = pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(geminiPro) || pickMostUsedBucket(buckets);
|
||||
|
||||
const session = usedPercentFromRemainingFraction(primaryBucket?.remainingFraction);
|
||||
const weekly = usedPercentFromRemainingFraction(secondaryBucket?.remainingFraction);
|
||||
|
||||
if (session == null || weekly == null) return null;
|
||||
return normalizeUsagePair(session, weekly);
|
||||
}
|
||||
|
||||
export async function fetchCodexUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
|
||||
const result = await requestJson(
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
||||
|
||||
const primary = result.data?.rate_limit?.primary_window;
|
||||
const secondary = result.data?.rate_limit?.secondary_window;
|
||||
|
||||
return {
|
||||
session: readPercentCandidate(primary?.used_percent) ?? 0,
|
||||
weekly: readPercentCandidate(secondary?.used_percent) ?? 0,
|
||||
sessionResetsIn: typeof primary?.reset_after_seconds === "number" ? formatDuration(primary.reset_after_seconds) : undefined,
|
||||
weeklyResetsIn: typeof secondary?.reset_after_seconds === "number" ? formatDuration(secondary.reset_after_seconds) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchClaudeUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
|
||||
const result = await requestJson(
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
||||
|
||||
const data = result.data;
|
||||
const usage: UsageData = {
|
||||
session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
|
||||
weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0,
|
||||
sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined,
|
||||
weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : undefined,
|
||||
};
|
||||
|
||||
if (data?.extra_usage?.is_enabled) {
|
||||
usage.extraSpend = typeof data.extra_usage.used_credits === "number" ? data.extra_usage.used_credits : undefined;
|
||||
usage.extraLimit = typeof data.extra_usage.monthly_limit === "number" ? data.extra_usage.monthly_limit : undefined;
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
export async function fetchZaiUsage(token: string, config: FetchConfig = {}): Promise<UsageData> {
|
||||
const endpoint = (config.endpoints ?? resolveUsageEndpoints()).zai;
|
||||
if (!endpoint) return { session: 0, weekly: 0, error: "usage endpoint unavailable" };
|
||||
|
||||
const result = await requestJson(
|
||||
endpoint,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
||||
|
||||
const parsed = extractUsageFromPayload(result.data);
|
||||
if (!parsed) return { session: 0, weekly: 0, error: "unrecognized response shape" };
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function fetchGoogleUsage(
|
||||
token: string,
|
||||
endpoint: string,
|
||||
projectId: string | undefined,
|
||||
provider: "gemini" | "antigravity",
|
||||
config: FetchConfig = {},
|
||||
): Promise<UsageData> {
|
||||
if (!endpoint) return { session: 0, weekly: 0, error: "configure endpoint" };
|
||||
|
||||
const discoveredProjectId = projectId || (await discoverGoogleProjectId(token, config));
|
||||
if (!discoveredProjectId) {
|
||||
return { session: 0, weekly: 0, error: "missing projectId (try /login again)" };
|
||||
}
|
||||
|
||||
const result = await requestJson(
|
||||
endpoint,
|
||||
{
|
||||
method: "POST",
|
||||
headers: googleHeaders(token, discoveredProjectId),
|
||||
body: JSON.stringify({ project: discoveredProjectId }),
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
||||
|
||||
const quota = parseGoogleQuotaBuckets(result.data, provider);
|
||||
if (quota) return quota;
|
||||
|
||||
const parsed = extractUsageFromPayload(result.data);
|
||||
if (!parsed) return { session: 0, weekly: 0, error: "unrecognized response shape" };
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function detectProvider(
|
||||
model: { provider?: string; id?: string; name?: string; api?: string } | string | undefined | null,
|
||||
): ProviderKey | null {
|
||||
if (!model) return null;
|
||||
if (typeof model === "string") return null;
|
||||
|
||||
const provider = (model.provider || "").toLowerCase();
|
||||
|
||||
if (provider === "openai-codex") return "codex";
|
||||
if (provider === "anthropic") return "claude";
|
||||
if (provider === "zai") return "zai";
|
||||
if (provider === "google-gemini-cli") return "gemini";
|
||||
if (provider === "google-antigravity") return "antigravity";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function providerToOAuthProviderId(active: ProviderKey | null): OAuthProviderId | null {
|
||||
if (active === "codex") return "openai-codex";
|
||||
if (active === "claude") return "anthropic";
|
||||
if (active === "gemini") return "google-gemini-cli";
|
||||
if (active === "antigravity") return "google-antigravity";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canShowForProvider(active: ProviderKey | null, auth: AuthData | null, endpoints: UsageEndpoints): boolean {
|
||||
if (!active || !auth) return false;
|
||||
if (active === "codex") return !!(auth["openai-codex"]?.access || auth["openai-codex"]?.refresh);
|
||||
if (active === "claude") return !!(auth.anthropic?.access || auth.anthropic?.refresh);
|
||||
if (active === "zai") return !!(auth.zai?.access || auth.zai?.key) && !!endpoints.zai;
|
||||
if (active === "gemini") {
|
||||
return !!(auth["google-gemini-cli"]?.access || auth["google-gemini-cli"]?.refresh) && !!endpoints.gemini;
|
||||
}
|
||||
if (active === "antigravity") {
|
||||
return !!(auth["google-antigravity"]?.access || auth["google-antigravity"]?.refresh) && !!endpoints.antigravity;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
export function colorForPercent(value: number): "success" | "warning" | "error" {
|
||||
if (value >= 90) return "error";
|
||||
if (value >= 70) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
export async function fetchAllUsages(config: FetchAllUsagesConfig = {}): Promise<UsageByProvider> {
|
||||
const authFile = config.authFile ?? DEFAULT_AUTH_FILE;
|
||||
const auth = config.auth ?? readAuth(authFile);
|
||||
const endpoints = config.endpoints ?? resolveUsageEndpoints();
|
||||
|
||||
const results: UsageByProvider = {
|
||||
codex: null,
|
||||
claude: null,
|
||||
zai: null,
|
||||
gemini: null,
|
||||
antigravity: null,
|
||||
};
|
||||
|
||||
if (!auth) return results;
|
||||
|
||||
const oauthProviders: OAuthProviderId[] = [
|
||||
"openai-codex",
|
||||
"anthropic",
|
||||
"google-gemini-cli",
|
||||
"google-antigravity",
|
||||
];
|
||||
|
||||
const refreshed = await ensureFreshAuthForProviders(oauthProviders, {
|
||||
...config,
|
||||
auth,
|
||||
authFile,
|
||||
});
|
||||
|
||||
const authData = refreshed.auth ?? auth;
|
||||
|
||||
const refreshError = (providerId: OAuthProviderId): string | null => {
|
||||
const error = refreshed.refreshErrors[providerId];
|
||||
return error ? `auth refresh failed (${error})` : null;
|
||||
};
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
const assign = (provider: ProviderKey, task: Promise<UsageData>) => {
|
||||
tasks.push(
|
||||
task
|
||||
.then((usage) => {
|
||||
results[provider] = usage;
|
||||
})
|
||||
.catch((error) => {
|
||||
results[provider] = { session: 0, weekly: 0, error: toErrorMessage(error) };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (authData["openai-codex"]?.access) {
|
||||
const err = refreshError("openai-codex");
|
||||
if (err) results.codex = { session: 0, weekly: 0, error: err };
|
||||
else assign("codex", fetchCodexUsage(authData["openai-codex"].access, config));
|
||||
}
|
||||
|
||||
if (authData.anthropic?.access) {
|
||||
const err = refreshError("anthropic");
|
||||
if (err) results.claude = { session: 0, weekly: 0, error: err };
|
||||
else assign("claude", fetchClaudeUsage(authData.anthropic.access, config));
|
||||
}
|
||||
|
||||
if (authData.zai?.access || authData.zai?.key) {
|
||||
assign("zai", fetchZaiUsage(authData.zai.access || authData.zai.key!, { ...config, endpoints }));
|
||||
}
|
||||
|
||||
if (authData["google-gemini-cli"]?.access) {
|
||||
const err = refreshError("google-gemini-cli");
|
||||
if (err) {
|
||||
results.gemini = { session: 0, weekly: 0, error: err };
|
||||
} else {
|
||||
const creds = authData["google-gemini-cli"];
|
||||
assign(
|
||||
"gemini",
|
||||
fetchGoogleUsage(creds.access!, endpoints.gemini, creds.projectId, "gemini", { ...config, endpoints }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (authData["google-antigravity"]?.access) {
|
||||
const err = refreshError("google-antigravity");
|
||||
if (err) {
|
||||
results.antigravity = { session: 0, weekly: 0, error: err };
|
||||
} else {
|
||||
const creds = authData["google-antigravity"];
|
||||
assign(
|
||||
"antigravity",
|
||||
fetchGoogleUsage(creds.access!, endpoints.antigravity, creds.projectId, "antigravity", { ...config, endpoints }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
return results;
|
||||
}
|
||||
539
pi/.pi/agent/extensions/usage-bars/index.ts
Normal file
539
pi/.pi/agent/extensions/usage-bars/index.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Usage Extension - Minimal API usage indicator for pi
|
||||
*
|
||||
* 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";
|
||||
import {
|
||||
Container,
|
||||
Input,
|
||||
Spacer,
|
||||
Text,
|
||||
getEditorKeybindings,
|
||||
type Focusable,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import {
|
||||
canShowForProvider,
|
||||
clampPercent,
|
||||
colorForPercent,
|
||||
detectProvider,
|
||||
ensureFreshAuthForProviders,
|
||||
fetchAllUsages,
|
||||
fetchClaudeUsage,
|
||||
fetchCodexUsage,
|
||||
fetchGoogleUsage,
|
||||
fetchZaiUsage,
|
||||
providerToOAuthProviderId,
|
||||
readAuth,
|
||||
readUsageCache,
|
||||
resolveUsageEndpoints,
|
||||
writeUsageCache,
|
||||
type OAuthProviderId,
|
||||
type ProviderKey,
|
||||
type UsageByProvider,
|
||||
type UsageData,
|
||||
} from "./core";
|
||||
|
||||
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",
|
||||
zai: "Z.AI",
|
||||
gemini: "Gemini",
|
||||
antigravity: "Antigravity",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /usage command popup
|
||||
// ---------------------------------------------------------------------------
|
||||
interface SubscriptionItem {
|
||||
name: string;
|
||||
provider: ProviderKey;
|
||||
data: UsageData | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
class UsageSelectorComponent extends Container implements Focusable {
|
||||
private searchInput: Input;
|
||||
private listContainer: Container;
|
||||
private hintText: Text;
|
||||
private tui: any;
|
||||
private theme: any;
|
||||
private onCancelCallback: () => void;
|
||||
private allItems: SubscriptionItem[] = [];
|
||||
private filteredItems: SubscriptionItem[] = [];
|
||||
private selectedIndex = 0;
|
||||
private loading = true;
|
||||
private activeProvider: ProviderKey | null;
|
||||
private fetchAllFn: () => Promise<UsageByProvider>;
|
||||
private _focused = false;
|
||||
|
||||
get focused(): boolean { return this._focused; }
|
||||
set focused(value: boolean) { this._focused = value; this.searchInput.focused = value; }
|
||||
|
||||
constructor(
|
||||
tui: any,
|
||||
theme: any,
|
||||
activeProvider: ProviderKey | null,
|
||||
fetchAll: () => Promise<UsageByProvider>,
|
||||
onCancel: () => void,
|
||||
) {
|
||||
super();
|
||||
this.tui = tui;
|
||||
this.theme = theme;
|
||||
this.activeProvider = activeProvider;
|
||||
this.fetchAllFn = fetchAll;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
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()
|
||||
.then((results) => {
|
||||
this.loading = false;
|
||||
this.buildItems(results);
|
||||
this.updateList();
|
||||
this.hintText.setText(
|
||||
theme.fg("muted", "Only showing providers with credentials. ") +
|
||||
theme.fg("dim", "✓ = active provider"),
|
||||
);
|
||||
this.tui.requestRender();
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
this.hintText.setText(theme.fg("error", "Failed to fetch usage data"));
|
||||
this.tui.requestRender();
|
||||
});
|
||||
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private buildItems(results: UsageByProvider) {
|
||||
const providers: Array<{ key: ProviderKey; name: string }> = [
|
||||
{ key: "codex", name: "Codex" },
|
||||
{ key: "claude", name: "Claude" },
|
||||
{ key: "zai", name: "Z.AI" },
|
||||
{ key: "gemini", name: "Gemini" },
|
||||
{ key: "antigravity", name: "Antigravity" },
|
||||
];
|
||||
this.allItems = [];
|
||||
for (const p of providers) {
|
||||
if (results[p.key] !== null) {
|
||||
this.allItems.push({
|
||||
name: p.name,
|
||||
provider: p.key,
|
||||
data: results[p.key],
|
||||
isActive: this.activeProvider === p.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.filteredItems = this.allItems;
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
||||
}
|
||||
|
||||
private filterItems(query: string) {
|
||||
if (!query) {
|
||||
this.filteredItems = this.allItems;
|
||||
} else {
|
||||
const q = query.toLowerCase();
|
||||
this.filteredItems = this.allItems.filter(
|
||||
(item) => item.name.toLowerCase().includes(q) || item.provider.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
||||
}
|
||||
|
||||
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) {
|
||||
this.listContainer.addChild(new Text(indent + t.fg("dim", "No credentials"), 0, 0));
|
||||
} else if (item.data.error) {
|
||||
this.listContainer.addChild(new Text(indent + t.fg("error", item.data.error), 0, 0));
|
||||
} 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}`) : "";
|
||||
const weeklyReset = item.data.weeklyResetsIn
|
||||
? t.fg("dim", ` resets in ${item.data.weeklyResetsIn}`) : "";
|
||||
|
||||
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 ") +
|
||||
t.fg("dim", `$${item.data.extraSpend.toFixed(2)} / $${item.data.extraLimit}`),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (kb.matches(keyData, "selectCancel") || kb.matches(keyData, "selectConfirm")) {
|
||||
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,
|
||||
};
|
||||
|
||||
let pollInFlight: Promise<void> | null = null;
|
||||
let pollQueued = false;
|
||||
let streamingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let ctx: any = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status update
|
||||
// ---------------------------------------------------------------------------
|
||||
function updateStatus() {
|
||||
const active = state.activeProvider;
|
||||
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,
|
||||
weekly: data.weekly,
|
||||
sessionResetsIn: data.sessionResetsIn,
|
||||
weeklyResetsIn: data.weeklyResetsIn,
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx?.hasUI) return;
|
||||
|
||||
const theme = ctx.ui.theme;
|
||||
|
||||
if (!active) {
|
||||
ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = readAuth();
|
||||
if (!canShowForProvider(active, auth, endpoints)) {
|
||||
ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", "loading\u2026"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
const cache = readUsageCache();
|
||||
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);
|
||||
|
||||
let s = theme.fg("muted", "S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`);
|
||||
if (data.sessionResetsIn) s += " " + theme.fg("dim", data.sessionResetsIn);
|
||||
|
||||
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, 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; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
const cache = readUsageCache();
|
||||
const now = Date.now();
|
||||
const cacheTtl = options.cacheTtl ?? CACHE_TTL_MS;
|
||||
|
||||
const blockedUntil = cache?.rateLimitedUntil?.[active] ?? 0;
|
||||
if (now < blockedUntil) {
|
||||
if (cache?.data?.[active]) state[active] = cache.data[active]!;
|
||||
state.lastPoll = now; updateStatus(); return;
|
||||
}
|
||||
|
||||
if (!options.forceFresh && cache && now - cache.timestamp < cacheTtl && cache.data?.[active]) {
|
||||
state[active] = cache.data[active]!;
|
||||
state.lastPoll = now; updateStatus(); return;
|
||||
}
|
||||
|
||||
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 = 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 = 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 = 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 = 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 = 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)" };
|
||||
}
|
||||
|
||||
state[active] = result;
|
||||
|
||||
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 ?? {}) },
|
||||
};
|
||||
delete nextCache.rateLimitedUntil![active];
|
||||
writeUsageCache(nextCache);
|
||||
}
|
||||
|
||||
state.lastPoll = now;
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
async function poll(options: PollOptions = {}) {
|
||||
if (pollInFlight) { pollQueued = true; await pollInFlight; return; }
|
||||
do {
|
||||
pollQueued = false;
|
||||
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);
|
||||
await poll();
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (_event, _ctx) => {
|
||||
stopStreamingTimer();
|
||||
if (_ctx?.hasUI) _ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
});
|
||||
|
||||
pi.on("model_select", async (event, _ctx) => {
|
||||
ctx = _ctx;
|
||||
const changed = updateProviderFrom(event.model ?? _ctx.model);
|
||||
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) => {
|
||||
return new UsageSelectorComponent(
|
||||
tui, theme, state.activeProvider,
|
||||
() => fetchAllUsages({ endpoints }),
|
||||
() => done(),
|
||||
);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await poll({ cacheTtl: ACTIVE_CACHE_TTL_MS });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
444
pi/.pi/agent/extensions/wezterm-theme-sync/index.ts
Normal file
444
pi/.pi/agent/extensions/wezterm-theme-sync/index.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
19
pi/.pi/agent/mcp.json
Normal file
19
pi/.pi/agent/mcp.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"qmd": {
|
||||
"command": "qmd",
|
||||
"args": [
|
||||
"mcp"
|
||||
],
|
||||
"directTools": true
|
||||
},
|
||||
"opty": {
|
||||
"command": "opty",
|
||||
"args": [
|
||||
"mcp"
|
||||
],
|
||||
"directTools": true,
|
||||
"lifecycle": "lazy"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
pi/.pi/agent/models.json
Normal file
41
pi/.pi/agent/models.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"providers": {
|
||||
"llama-cpp": {
|
||||
"baseUrl": "http://localhost:8080/v1",
|
||||
"api": "openai-completions",
|
||||
"apiKey": "sk-no-key",
|
||||
"models": [
|
||||
{
|
||||
"id": "unsloth/Qwen3.5-9B-GGUF:Q5_K_M",
|
||||
"name": "Qwen 3.5 9B Q5_K_M (Local M1 Max - Unsloth)",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 32768,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
||||
"compat": {
|
||||
"supportsDeveloperRole": false,
|
||||
"supportsReasoningEffort": false,
|
||||
"maxTokensField": "max_tokens",
|
||||
"thinkingFormat": "qwen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "unsloth/Qwen3.5-4B-GGUF:Q5_K_M",
|
||||
"name": "Qwen 3.5 4B Q5_K_M (Local M1 Max - Unsloth)",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 32768,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
||||
"compat": {
|
||||
"supportsDeveloperRole": false,
|
||||
"supportsReasoningEffort": false,
|
||||
"maxTokensField": "max_tokens",
|
||||
"thinkingFormat": "qwen"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
29
pi/.pi/agent/skills/local-scout/SKILL.md
Normal file
29
pi/.pi/agent/skills/local-scout/SKILL.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: local-scout
|
||||
description: "Delegates codebase exploration to the local scout subagent (runs on a fast local Qwen model with QMD + opty tools). Load this skill only when the user explicitly asks to use scout, use local scout, or scout a task. Do NOT load automatically for general exploration — only when scout is explicitly requested."
|
||||
---
|
||||
|
||||
# Local Scout
|
||||
|
||||
Delegate codebase exploration to the `scout` subagent, which runs on a fast local model (Qwen) augmented with semantic search via QMD and HDC-indexed context retrieval via opty. It is cheap, fast, and keeps the main context clean.
|
||||
|
||||
## When to use
|
||||
|
||||
- User says "use scout to find …", "scout: …", or "use local scout"
|
||||
- You need to gather broad codebase context before planning
|
||||
- The task is primarily "look around the codebase" rather than making precise edits
|
||||
|
||||
## How to invoke
|
||||
|
||||
```javascript
|
||||
subagent({ agent: "scout", task: "Find and summarize the authentication flow" })
|
||||
```
|
||||
|
||||
The scout writes its findings to `context.md` and returns a summary. Use the summary or read `context.md` for the full structured output.
|
||||
|
||||
## Tips
|
||||
|
||||
- Be specific in the task description — the scout infers thoroughness from it
|
||||
- For deep traces, prefix with "Thorough:" e.g. `"Thorough: trace all usages of X"`
|
||||
- For quick lookups, prefix with "Quick:" e.g. `"Quick: where is the config loaded?"`
|
||||
- Do your own reading only when you need precise line-level content to reference in your response
|
||||
81
pi/.pi/agent/themes/wezterm-sync-ba8a76f5.json
Normal file
81
pi/.pi/agent/themes/wezterm-sync-ba8a76f5.json
Normal 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
3
pi/.pi/settings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"hide_thinking_block": true
|
||||
}
|
||||
360
starship/.config/starship.toml
Normal file
360
starship/.config/starship.toml
Normal 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
|
||||
@@ -1,5 +1,6 @@
|
||||
exec dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=sway
|
||||
exec_always autotiling -l 2
|
||||
exec_always ~/.config/sway/scripts/workspace-colors.py
|
||||
exec_always eww daemon && (eww open bar0 || true) && (eww open bar1 || true)
|
||||
exec_always mako
|
||||
|
||||
|
||||
@@ -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 &
|
||||
|
||||
41
sway/.config/sway/scripts/workspace-colors.py
Executable file
41
sway/.config/sway/scripts/workspace-colors.py
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import i3ipc
|
||||
import os
|
||||
import signal
|
||||
|
||||
PID_FILE = '/tmp/workspace-colors.pid'
|
||||
|
||||
COLORS = {
|
||||
'1': '#69C3FF',
|
||||
'2': '#FF69C3',
|
||||
'3': '#69FFC3',
|
||||
'4': '#C369FF',
|
||||
'5': '#FFC369',
|
||||
}
|
||||
|
||||
# Kill any previous instance
|
||||
try:
|
||||
old_pid = int(open(PID_FILE).read())
|
||||
os.kill(old_pid, signal.SIGTERM)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
with open(PID_FILE, 'w') as f:
|
||||
f.write(str(os.getpid()))
|
||||
|
||||
def apply_color(conn, ws_name):
|
||||
color = COLORS.get(ws_name)
|
||||
if color:
|
||||
conn.command(f'client.focused {color} {color} {color} {color} {color}')
|
||||
|
||||
def on_workspace_focus(conn, event):
|
||||
apply_color(conn, event.current.name)
|
||||
|
||||
conn = i3ipc.Connection()
|
||||
|
||||
focused = next((w for w in conn.get_workspaces() if w.focused), None)
|
||||
if focused:
|
||||
apply_color(conn, focused.name)
|
||||
|
||||
conn.on(i3ipc.Event.WORKSPACE_FOCUS, on_workspace_focus)
|
||||
conn.main()
|
||||
6
wezterm/.config/wezterm/.gitignore
vendored
Normal file
6
wezterm/.config/wezterm/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Machine-local overrides — not tracked in dotfiles
|
||||
# Create local.lua returning a function that mutates config, e.g.:
|
||||
# return function(config)
|
||||
# config.wayland_scroll_factor = 0.15
|
||||
# end
|
||||
local.lua
|
||||
@@ -12,12 +12,15 @@ config.colors = theme.colors()
|
||||
config.enable_tab_bar = false
|
||||
config.window_close_confirmation = 'NeverPrompt'
|
||||
|
||||
config.wayland_scroll_factor = 0.15
|
||||
|
||||
-- Wayland specific fixes
|
||||
config.enable_wayland = true
|
||||
|
||||
-- Enable Kitty keyboard protocol for better key disambiguation (Ctrl+I vs Tab)
|
||||
config.enable_kitty_keyboard = true
|
||||
|
||||
-- Machine-local overrides (not tracked in dotfiles)
|
||||
-- Create ~/.config/wezterm/local.lua returning a function that mutates config
|
||||
local ok, local_config = pcall(require, 'local')
|
||||
if ok then local_config(config) end
|
||||
|
||||
return config
|
||||
|
||||
1
zshrc/.zsh/plugins/zsh-autosuggestions
Submodule
1
zshrc/.zsh/plugins/zsh-autosuggestions
Submodule
Submodule zshrc/.zsh/plugins/zsh-autosuggestions added at 85919cd1ff
1
zshrc/.zsh/plugins/zsh-history-substring-search
Submodule
1
zshrc/.zsh/plugins/zsh-history-substring-search
Submodule
Submodule zshrc/.zsh/plugins/zsh-history-substring-search added at 14c8d2e0ff
1
zshrc/.zsh/plugins/zsh-syntax-highlighting
Submodule
1
zshrc/.zsh/plugins/zsh-syntax-highlighting
Submodule
Submodule zshrc/.zsh/plugins/zsh-syntax-highlighting added at 1d85c69261
66
zshrc/.zshrc
Normal file
66
zshrc/.zshrc
Normal file
@@ -0,0 +1,66 @@
|
||||
export PATH="$HOME/.local/share/nvim/mason/bin:$PATH"
|
||||
export PATH="$PATH:/home/jonas/.local/bin"
|
||||
|
||||
# History configuration
|
||||
HISTFILE=~/.zsh_history
|
||||
HISTSIZE=10000
|
||||
SAVEHIST=10000
|
||||
|
||||
setopt APPEND_HISTORY # Append history instead of overwriting
|
||||
setopt EXTENDED_HISTORY # Save timestamps
|
||||
setopt HIST_FIND_NO_DUPS # Don't show duplicates when searching
|
||||
setopt HIST_IGNORE_DUPS # Don't save duplicate commands
|
||||
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
|
||||
source ~/.zsh/plugins/zsh-history-substring-search/zsh-history-substring-search.plugin.zsh
|
||||
|
||||
# zsh-history-substring-search key bindings
|
||||
bindkey '^[[A' history-substring-search-up
|
||||
bindkey '^[[B' history-substring-search-down
|
||||
|
||||
unsetopt autocd
|
||||
|
||||
alias hooknloop='godot --display-driver wayland --rendering-driver opengl3 ~/shared/godot-projects/hooknloop/project.godot'
|
||||
alias screengrab='hyprshot -m region --clipboard-only'
|
||||
alias claude-work="CLAUDE_WORK=1 CLAUDE_CONFIG_DIR=~/.claude-work claude"
|
||||
alias qwen-local='QWEN_CODE_AUTH_TYPE=openai-compatible QWEN_CODE_API_KEY=llama-cpp QWEN_CODE_BASE_URL=http://127.0.0.1:8080/v1 qwen'
|
||||
|
||||
export EDITOR="nvim"
|
||||
export VISUAL="nvim"
|
||||
export SUDO_EDITOR="nvim"
|
||||
export OPENROUTER_API_KEY=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
|
||||
export USE_BUILTIN_RIPGREP=0
|
||||
export REAL_DEBRID_API_KEY="HQVUOC3ALUHIIQCFQK4UOV2GVGVLQKKHEWFWKN77G6URFBTQMUTQ"
|
||||
export HF_TOKEN=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
|
||||
|
||||
export PATH=~/.npm-global/bin:$PATH
|
||||
|
||||
export LD_LIBRARY_PATH=/home/jonas/.local/lib64:$LD_LIBRARY_PATH
|
||||
|
||||
function y() {
|
||||
local tmp="$(mktemp -t "yazi-cwd.XXXXXX")" cwd
|
||||
yazi "$@" --cwd-file="$tmp"
|
||||
IFS= read -r -d '' cwd < "$tmp"
|
||||
[ -n "$cwd" ] && [ "$cwd" != "$PWD" ] && builtin cd -- "$cwd"
|
||||
rm -f -- "$tmp"
|
||||
}
|
||||
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init - zsh)"
|
||||
|
||||
eval "$(starship init zsh)"
|
||||
eval "$(zoxide init zsh)"
|
||||
Reference in New Issue
Block a user