Compare commits

...

9 Commits

Author SHA1 Message Date
Jonas H
f908d84a9b llama stuff 2026-04-24 14:24:30 +02:00
Jonas H
c6df5667f7 zsh android variables 2026-04-24 14:24:04 +02:00
Jonas H
60095c6f61 wezterm light theme 2026-04-24 14:23:29 +02:00
Jonas H
a641087fae sway dark/light switch 2026-04-24 14:23:16 +02:00
Jonas H
248667468c BIG pi update with claude chat 2026-04-24 14:22:59 +02:00
Jonas H
fbb00a49ba rm claude-agent-sdk 2026-04-24 14:21:05 +02:00
Jonas H
08ac9d02f8 pi agents update 2026-04-24 14:20:54 +02:00
Jonas H
9dd1c319e0 nvim light theme 2026-04-24 14:20:32 +02:00
Jonas H
96bd163442 light theme 2026-04-24 14:20:14 +02:00
44 changed files with 5031 additions and 4460 deletions

View File

@@ -0,0 +1,64 @@
-- Bearded Arc (Light) colorscheme
-- Hand-tuned light variant with warm off-white bg and saturated accents
vim.cmd("hi clear")
if vim.fn.exists("syntax_on") then
vim.cmd("syntax reset")
end
vim.o.background = "light"
vim.g.colors_name = "bearded-arc-light"
local base_30 = {
white = "#2a2a2a",
darker_black = "#ede6dc",
black = "#faf4ed",
black2 = "#f0e8df",
one_bg = "#e8e0d4",
one_bg2 = "#ddd5c9",
one_bg3 = "#d2cabd",
grey = "#b0a89c",
grey_fg = "#8a8278",
grey_fg2 = "#7a7268",
light_grey = "#6a6258",
red = "#d1344f",
baby_pink = "#c44a8a",
pink = "#b04a7a",
line = "#ddd5c9",
green = "#1e9b52",
vibrant_green = "#158040",
nord_blue = "#2a6a9a",
blue = "#1a7db5",
yellow = "#b8890f",
sun = "#d4a017",
purple = "#7b4fc4",
dark_purple = "#6a3eb0",
teal = "#0e8a82",
orange = "#c45a1c",
cyan = "#147a8a",
statusline_bg = "#f0e8df",
lightbg = "#e8e0d4",
pmenu_bg = "#1e9b52",
folder_bg = "#1a7db5",
}
local base_16 = {
base00 = "#faf4ed",
base01 = "#f0e8df",
base02 = "#e8e0d4",
base03 = "#b0a89c",
base04 = "#5a5248",
base05 = "#3a3228",
base06 = "#2a2a2a",
base07 = "#0e8a82",
base08 = "#d1344f",
base09 = "#c45a1c",
base0A = "#b8890f",
base0B = "#1e9b52",
base0C = "#2a6a9a",
base0D = "#1a7db5",
base0E = "#147a8a",
base0F = "#7b4fc4",
}
require("bearded-arc").apply(base_30, base_16)

View File

@@ -1,5 +1,5 @@
-- Bearded Arc colorscheme
-- Accurate port from NvChad base46 theme with proper highlight group mappings
-- Bearded Arc (Dark) colorscheme
-- Thin wrapper around the highlight engine
vim.cmd("hi clear")
if vim.fn.exists("syntax_on") then
@@ -9,7 +9,6 @@ end
vim.o.background = "dark"
vim.g.colors_name = "bearded-arc"
-- Base 30 color palette (UI colors)
local base_30 = {
white = "#ABB7C1",
darker_black = "#19212e",
@@ -43,7 +42,6 @@ local base_30 = {
folder_bg = "#69C3FF",
}
-- Base 16 color palette (syntax colors)
local base_16 = {
base00 = "#1c2433",
base01 = "#262e3d",
@@ -63,277 +61,4 @@ local base_16 = {
base0F = "#B78AFF",
}
-- Helper function to set highlights
local function hi(group, opts)
local cmd = "hi " .. group
if opts.fg then cmd = cmd .. " guifg=" .. opts.fg end
if opts.bg then cmd = cmd .. " guibg=" .. opts.bg end
if opts.sp then cmd = cmd .. " guisp=" .. opts.sp end
if opts.style then cmd = cmd .. " gui=" .. opts.style end
vim.cmd(cmd)
end
-- === UI ELEMENTS (from defaults.lua) ===
-- Line Numbers & Sidebar (CRITICAL FIX)
hi("LineNr", { fg = base_30.grey })
hi("CursorLineNr", { fg = base_30.white })
hi("SignColumn", { fg = base_16.base03 })
hi("FoldColumn", { fg = base_30.grey, bg = base_16.base00 })
hi("Folded", { fg = base_30.light_grey, bg = base_16.base01 })
-- Main Editor
hi("Normal", { fg = base_16.base05, bg = base_16.base00 })
hi("NormalFloat", { bg = base_30.darker_black })
hi("FloatBorder", { fg = base_30.blue })
hi("FloatTitle", { fg = base_30.white, bg = base_30.grey })
-- Cursor and Lines
hi("Cursor", { fg = base_16.base00, bg = base_16.base05 })
hi("CursorLine", { bg = base_30.black2 })
hi("CursorColumn", { bg = base_16.base01 })
hi("ColorColumn", { bg = base_16.base01 })
-- Visual Selection
hi("Visual", { bg = base_30.one_bg2 })
hi("VisualNOS", { bg = base_30.one_bg2 })
-- Search
hi("Search", { fg = base_16.base00, bg = base_16.base0A })
hi("IncSearch", { fg = base_16.base01, bg = base_16.base09 })
hi("Substitute", { fg = base_16.base01, bg = base_16.base0A })
-- Window Separators
hi("WinSeparator", { fg = base_30.line })
hi("WinBar", { bg = base_30.statusline_bg })
hi("WinBarNC", { bg = base_30.statusline_bg })
-- Completion Menu
hi("Pmenu", { bg = base_30.one_bg })
hi("PmenuSbar", { bg = base_30.one_bg })
hi("PmenuSel", { bg = base_30.pmenu_bg, fg = base_30.black })
hi("PmenuThumb", { bg = base_30.grey })
-- Statusline & Tabline
hi("StatusLine", { bg = base_30.statusline_bg })
hi("StatusLineNC", { bg = base_30.statusline_bg })
hi("TabLine", { fg = base_30.light_grey, bg = base_30.black2 })
hi("TabLineSel", { fg = base_30.white, bg = base_30.black })
hi("TabLineFill", { bg = base_30.black2 })
-- Messages & Prompts
hi("ErrorMsg", { fg = base_16.base08, bg = base_16.base00 })
hi("WarningMsg", { fg = base_16.base0A })
hi("MoreMsg", { fg = base_16.base0B })
hi("ModeMsg", { fg = base_16.base0B })
hi("Question", { fg = base_16.base0D })
-- Special Elements
hi("MatchParen", { bg = base_30.grey, fg = base_30.white })
hi("NonText", { fg = base_16.base03 })
hi("SpecialKey", { fg = base_16.base03 })
hi("Conceal", { fg = base_16.base0D, bg = base_16.base00 })
hi("Directory", { fg = base_16.base0D })
hi("Title", { fg = base_16.base0D })
-- === SYNTAX HIGHLIGHTING (from syntax.lua) ===
hi("Comment", { fg = base_30.grey_fg, style = "italic" })
-- Constants
hi("Constant", { fg = base_16.base09 })
hi("String", { fg = base_16.base0B })
hi("Character", { fg = base_16.base08 })
hi("Number", { fg = base_16.base09 })
hi("Boolean", { fg = base_16.base09 })
hi("Float", { fg = base_16.base09 })
-- Identifiers
hi("Identifier", { fg = base_16.base08 })
hi("Function", { fg = base_16.base0D })
-- Statements
hi("Statement", { fg = base_16.base0E })
hi("Conditional", { fg = base_16.base0E })
hi("Repeat", { fg = base_16.base0E })
hi("Label", { fg = base_16.base0A })
hi("Operator", { fg = base_16.base05 })
hi("Keyword", { fg = base_16.base0E })
hi("Exception", { fg = base_16.base08 })
-- PreProcessor
hi("PreProc", { fg = base_16.base0A })
hi("Include", { fg = base_16.base0D })
hi("Define", { fg = base_16.base0E })
hi("Macro", { fg = base_16.base08 })
hi("PreCondit", { fg = base_16.base0A })
-- Types
hi("Type", { fg = base_16.base0A })
hi("StorageClass", { fg = base_16.base0A })
hi("Structure", { fg = base_16.base0E })
hi("Typedef", { fg = base_16.base0A })
-- Special
hi("Special", { fg = base_16.base0C })
hi("SpecialChar", { fg = base_16.base0F })
hi("Tag", { fg = base_16.base0A })
hi("Delimiter", { fg = base_16.base0F })
hi("SpecialComment", { fg = base_16.base0C })
hi("Debug", { fg = base_16.base08 })
-- Errors
hi("Error", { fg = base_16.base00, bg = base_16.base08 })
hi("Todo", { fg = base_16.base0A, bg = base_16.base01 })
hi("Underlined", { fg = base_16.base0B, style = "underline" })
hi("Ignore", { fg = base_16.base0C })
-- === DIFF ===
hi("DiffAdd", { fg = base_16.base0B, bg = base_16.base00 })
hi("DiffChange", { fg = base_16.base0E, bg = base_16.base00 })
hi("DiffDelete", { fg = base_16.base08, bg = base_16.base00 })
hi("DiffText", { fg = base_16.base0D, bg = base_16.base01 })
hi("Added", { fg = base_30.green })
hi("Changed", { fg = base_30.yellow })
hi("Removed", { fg = base_30.red })
-- === GIT SIGNS ===
hi("GitSignsAdd", { fg = base_30.green })
hi("GitSignsChange", { fg = base_30.yellow })
hi("GitSignsDelete", { fg = base_30.red })
-- === LSP DIAGNOSTICS (from lsp.lua) ===
hi("DiagnosticError", { fg = base_30.red })
hi("DiagnosticWarn", { fg = base_30.yellow })
hi("DiagnosticInfo", { fg = base_30.green })
hi("DiagnosticHint", { fg = base_30.purple })
hi("DiagnosticUnderlineError", { sp = base_30.red, style = "underline" })
hi("DiagnosticUnderlineWarn", { sp = base_30.yellow, style = "underline" })
hi("DiagnosticUnderlineInfo", { sp = base_30.green, style = "underline" })
hi("DiagnosticUnderlineHint", { sp = base_30.purple, style = "underline" })
hi("LspReferenceText", { bg = base_30.one_bg3 })
hi("LspReferenceRead", { bg = base_30.one_bg3 })
hi("LspReferenceWrite", { bg = base_30.one_bg3 })
hi("LspInlayHint", { fg = base_30.light_grey, bg = base_30.black2 })
hi("LspSignatureActiveParameter", { fg = base_30.black, bg = base_30.green })
-- === TELESCOPE (from telescope.lua) ===
hi("TelescopeBorder", { fg = base_30.grey, bg = base_30.darker_black })
hi("TelescopePromptBorder", { fg = base_30.grey, bg = base_30.darker_black })
hi("TelescopePromptNormal", { fg = base_16.base05, bg = base_30.darker_black })
hi("TelescopeResultsBorder", { fg = base_30.grey, bg = base_30.darker_black })
hi("TelescopeResultsNormal", { bg = base_30.darker_black })
hi("TelescopePreviewBorder", { fg = base_30.grey, bg = base_30.darker_black })
hi("TelescopePreviewNormal", { bg = base_30.darker_black })
hi("TelescopeSelection", { bg = base_30.one_bg2, fg = base_30.white })
hi("TelescopeSelectionCaret", { fg = base_30.cyan, bg = base_30.one_bg2 })
hi("TelescopeMatching", { fg = base_30.blue, style = "bold" })
-- === TREESITTER (from treesitter.lua) ===
hi("@variable", { fg = base_16.base05 })
hi("@variable.builtin", { fg = base_16.base09 })
hi("@variable.parameter", { fg = base_16.base08 })
hi("@variable.member", { fg = base_16.base08 })
hi("@constant", { fg = base_16.base08 })
hi("@constant.builtin", { fg = base_16.base09 })
hi("@constant.macro", { fg = base_16.base08 })
hi("@string", { fg = base_16.base0B })
hi("@string.regex", { fg = base_16.base0C })
hi("@string.escape", { fg = base_16.base0C })
hi("@character", { fg = base_16.base08 })
hi("@number", { fg = base_16.base09 })
hi("@boolean", { fg = base_16.base09 })
hi("@float", { fg = base_16.base09 })
hi("@function", { fg = base_16.base0D })
hi("@function.builtin", { fg = base_16.base0D })
hi("@function.macro", { fg = base_16.base08 })
hi("@function.method", { fg = base_16.base0D })
hi("@constructor", { fg = base_16.base0C })
hi("@parameter", { fg = base_16.base08 })
hi("@keyword", { fg = base_16.base0E })
hi("@keyword.function", { fg = base_16.base0E })
hi("@keyword.return", { fg = base_16.base0E })
hi("@keyword.operator", { fg = base_16.base0E })
hi("@conditional", { fg = base_16.base0E })
hi("@repeat", { fg = base_16.base0E })
hi("@label", { fg = base_16.base0A })
hi("@operator", { fg = base_16.base05 })
hi("@exception", { fg = base_16.base08 })
hi("@type", { fg = base_16.base0A })
hi("@type.builtin", { fg = base_16.base0A })
hi("@type.qualifier", { fg = base_16.base0E })
hi("@structure", { fg = base_16.base0E })
hi("@include", { fg = base_16.base0D })
hi("@property", { fg = base_16.base08 })
hi("@field", { fg = base_16.base08 })
hi("@punctuation.delimiter", { fg = base_16.base0F })
hi("@punctuation.bracket", { fg = base_16.base05 })
hi("@punctuation.special", { fg = base_16.base08 })
hi("@comment", { fg = base_30.grey_fg, style = "italic" })
hi("@comment.todo", { fg = base_30.grey, bg = base_30.white })
hi("@comment.note", { fg = base_30.white, bg = base_30.blue })
hi("@comment.warning", { fg = base_30.black, bg = base_30.yellow })
hi("@comment.error", { fg = base_30.white, bg = base_30.red })
hi("@tag", { fg = base_16.base08 })
hi("@tag.attribute", { fg = base_16.base0A })
hi("@tag.delimiter", { fg = base_16.base0F })
hi("@markup.strong", { style = "bold" })
hi("@markup.italic", { style = "italic" })
hi("@markup.underline", { style = "underline" })
hi("@markup.strike", { style = "strikethrough" })
hi("@markup.heading", { fg = base_16.base0D, style = "bold" })
hi("@markup.link", { fg = base_16.base08, style = "underline" })
hi("@markup.link.url", { fg = base_16.base0B, style = "underline" })
hi("@markup.list", { fg = base_16.base08 })
-- === NVIM-TREE ===
hi("NvimTreeNormal", { bg = base_30.darker_black })
hi("NvimTreeNormalNC", { bg = base_30.darker_black })
hi("NvimTreeRootFolder", { fg = base_30.red, style = "bold" })
hi("NvimTreeGitDirty", { fg = base_30.yellow })
hi("NvimTreeGitNew", { fg = base_30.green })
hi("NvimTreeGitDeleted", { fg = base_30.red })
hi("NvimTreeSpecialFile", { fg = base_30.yellow, style = "underline" })
hi("NvimTreeIndentMarker", { fg = base_30.grey })
hi("NvimTreeImageFile", { fg = base_30.dark_purple })
hi("NvimTreeSymlink", { fg = base_30.cyan })
hi("NvimTreeFolderName", { fg = base_30.blue })
hi("NvimTreeFolderIcon", { fg = base_30.folder_bg })
hi("NvimTreeOpenedFolderName", { fg = base_30.blue })
-- === WHICH-KEY ===
hi("WhichKey", { fg = base_30.blue })
hi("WhichKeyGroup", { fg = base_30.green })
hi("WhichKeyDesc", { fg = base_16.base05 })
hi("WhichKeySeparator", { fg = base_30.grey })
hi("WhichKeyFloat", { bg = base_30.darker_black })
hi("WhichKeyBorder", { fg = base_30.blue, bg = base_30.darker_black })
-- === HEALTH CHECK ===
hi("healthSuccess", { fg = base_30.green, bg = base_30.black })
require("bearded-arc").apply(base_30, base_16)

View File

@@ -17,8 +17,8 @@ require("lazy").setup({
{ import = "plugins" },
}, lazy_config)
-- load theme
vim.cmd.colorscheme("bearded-arc")
-- load theme (watches ~/.config/theme-state for dark/light toggle)
require("bearded-arc.watcher").setup()
-- Load standalone config (migrated from NvChad)
require "options"

View File

@@ -0,0 +1,266 @@
-- Bearded Arc highlight engine
-- Shared logic for dark and light variants
local M = {}
-- Helper function to set highlights
local function hi(group, opts)
local cmd = "hi " .. group
if opts.fg then cmd = cmd .. " guifg=" .. opts.fg end
if opts.bg then cmd = cmd .. " guibg=" .. opts.bg end
if opts.sp then cmd = cmd .. " guisp=" .. opts.sp end
if opts.style then cmd = cmd .. " gui=" .. opts.style end
vim.cmd(cmd)
end
function M.apply(base_30, base_16)
-- === UI ELEMENTS ===
hi("LineNr", { fg = base_30.grey })
hi("CursorLineNr", { fg = base_30.white })
hi("SignColumn", { fg = base_16.base03 })
hi("FoldColumn", { fg = base_30.grey, bg = base_16.base00 })
hi("Folded", { fg = base_30.light_grey, bg = base_16.base01 })
hi("Normal", { fg = base_16.base05, bg = base_16.base00 })
hi("NormalFloat", { bg = base_30.darker_black })
hi("FloatBorder", { fg = base_30.blue })
hi("FloatTitle", { fg = base_30.white, bg = base_30.grey })
hi("Cursor", { fg = base_16.base00, bg = base_16.base05 })
hi("CursorLine", { bg = base_30.black2 })
hi("CursorColumn", { bg = base_16.base01 })
hi("ColorColumn", { bg = base_16.base01 })
hi("Visual", { bg = base_30.one_bg2 })
hi("VisualNOS", { bg = base_30.one_bg2 })
hi("Search", { fg = base_16.base00, bg = base_16.base0A })
hi("IncSearch", { fg = base_16.base01, bg = base_16.base09 })
hi("Substitute", { fg = base_16.base01, bg = base_16.base0A })
hi("WinSeparator", { fg = base_30.line })
hi("WinBar", { bg = base_30.statusline_bg })
hi("WinBarNC", { bg = base_30.statusline_bg })
hi("Pmenu", { bg = base_30.one_bg })
hi("PmenuSbar", { bg = base_30.one_bg })
hi("PmenuSel", { bg = base_30.pmenu_bg, fg = base_30.black })
hi("PmenuThumb", { bg = base_30.grey })
hi("StatusLine", { bg = base_30.statusline_bg })
hi("StatusLineNC", { bg = base_30.statusline_bg })
hi("TabLine", { fg = base_30.light_grey, bg = base_30.black2 })
hi("TabLineSel", { fg = base_30.white, bg = base_30.black })
hi("TabLineFill", { bg = base_30.black2 })
hi("ErrorMsg", { fg = base_16.base08, bg = base_16.base00 })
hi("WarningMsg", { fg = base_16.base0A })
hi("MoreMsg", { fg = base_16.base0B })
hi("ModeMsg", { fg = base_16.base0B })
hi("Question", { fg = base_16.base0D })
hi("MatchParen", { bg = base_30.grey, fg = base_30.white })
hi("NonText", { fg = base_16.base03 })
hi("SpecialKey", { fg = base_16.base03 })
hi("Conceal", { fg = base_16.base0D, bg = base_16.base00 })
hi("Directory", { fg = base_16.base0D })
hi("Title", { fg = base_16.base0D })
-- === SYNTAX HIGHLIGHTING ===
hi("Comment", { fg = base_30.grey_fg, style = "italic" })
hi("Constant", { fg = base_16.base09 })
hi("String", { fg = base_16.base0B })
hi("Character", { fg = base_16.base08 })
hi("Number", { fg = base_16.base09 })
hi("Boolean", { fg = base_16.base09 })
hi("Float", { fg = base_16.base09 })
hi("Identifier", { fg = base_16.base08 })
hi("Function", { fg = base_16.base0D })
hi("Statement", { fg = base_16.base0E })
hi("Conditional", { fg = base_16.base0E })
hi("Repeat", { fg = base_16.base0E })
hi("Label", { fg = base_16.base0A })
hi("Operator", { fg = base_16.base05 })
hi("Keyword", { fg = base_16.base0E })
hi("Exception", { fg = base_16.base08 })
hi("PreProc", { fg = base_16.base0A })
hi("Include", { fg = base_16.base0D })
hi("Define", { fg = base_16.base0E })
hi("Macro", { fg = base_16.base08 })
hi("PreCondit", { fg = base_16.base0A })
hi("Type", { fg = base_16.base0A })
hi("StorageClass", { fg = base_16.base0A })
hi("Structure", { fg = base_16.base0E })
hi("Typedef", { fg = base_16.base0A })
hi("Special", { fg = base_16.base0C })
hi("SpecialChar", { fg = base_16.base0F })
hi("Tag", { fg = base_16.base0A })
hi("Delimiter", { fg = base_16.base0F })
hi("SpecialComment", { fg = base_16.base0C })
hi("Debug", { fg = base_16.base08 })
hi("Error", { fg = base_16.base00, bg = base_16.base08 })
hi("Todo", { fg = base_16.base0A, bg = base_16.base01 })
hi("Underlined", { fg = base_16.base0B, style = "underline" })
hi("Ignore", { fg = base_16.base0C })
-- === DIFF ===
hi("DiffAdd", { fg = base_16.base0B, bg = base_16.base00 })
hi("DiffChange", { fg = base_16.base0E, bg = base_16.base00 })
hi("DiffDelete", { fg = base_16.base08, bg = base_16.base00 })
hi("DiffText", { fg = base_16.base0D, bg = base_16.base01 })
hi("Added", { fg = base_30.green })
hi("Changed", { fg = base_30.yellow })
hi("Removed", { fg = base_30.red })
-- === GIT SIGNS ===
hi("GitSignsAdd", { fg = base_30.green })
hi("GitSignsChange", { fg = base_30.yellow })
hi("GitSignsDelete", { fg = base_30.red })
-- === LSP DIAGNOSTICS ===
hi("DiagnosticError", { fg = base_30.red })
hi("DiagnosticWarn", { fg = base_30.yellow })
hi("DiagnosticInfo", { fg = base_30.green })
hi("DiagnosticHint", { fg = base_30.purple })
hi("DiagnosticUnderlineError", { sp = base_30.red, style = "underline" })
hi("DiagnosticUnderlineWarn", { sp = base_30.yellow, style = "underline" })
hi("DiagnosticUnderlineInfo", { sp = base_30.green, style = "underline" })
hi("DiagnosticUnderlineHint", { sp = base_30.purple, style = "underline" })
hi("LspReferenceText", { bg = base_30.one_bg3 })
hi("LspReferenceRead", { bg = base_30.one_bg3 })
hi("LspReferenceWrite", { bg = base_30.one_bg3 })
hi("LspInlayHint", { fg = base_30.light_grey, bg = base_30.black2 })
hi("LspSignatureActiveParameter", { fg = base_30.black, bg = base_30.green })
-- === TELESCOPE ===
hi("TelescopeBorder", { fg = base_30.grey, bg = base_30.darker_black })
hi("TelescopePromptBorder", { fg = base_30.grey, bg = base_30.darker_black })
hi("TelescopePromptNormal", { fg = base_16.base05, bg = base_30.darker_black })
hi("TelescopeResultsBorder", { fg = base_30.grey, bg = base_30.darker_black })
hi("TelescopeResultsNormal", { bg = base_30.darker_black })
hi("TelescopePreviewBorder", { fg = base_30.grey, bg = base_30.darker_black })
hi("TelescopePreviewNormal", { bg = base_30.darker_black })
hi("TelescopeSelection", { bg = base_30.one_bg2, fg = base_30.white })
hi("TelescopeSelectionCaret", { fg = base_30.cyan, bg = base_30.one_bg2 })
hi("TelescopeMatching", { fg = base_30.blue, style = "bold" })
-- === TREESITTER ===
hi("@variable", { fg = base_16.base05 })
hi("@variable.builtin", { fg = base_16.base09 })
hi("@variable.parameter", { fg = base_16.base08 })
hi("@variable.member", { fg = base_16.base08 })
hi("@constant", { fg = base_16.base08 })
hi("@constant.builtin", { fg = base_16.base09 })
hi("@constant.macro", { fg = base_16.base08 })
hi("@string", { fg = base_16.base0B })
hi("@string.regex", { fg = base_16.base0C })
hi("@string.escape", { fg = base_16.base0C })
hi("@character", { fg = base_16.base08 })
hi("@number", { fg = base_16.base09 })
hi("@boolean", { fg = base_16.base09 })
hi("@float", { fg = base_16.base09 })
hi("@function", { fg = base_16.base0D })
hi("@function.builtin", { fg = base_16.base0D })
hi("@function.macro", { fg = base_16.base08 })
hi("@function.method", { fg = base_16.base0D })
hi("@constructor", { fg = base_16.base0C })
hi("@parameter", { fg = base_16.base08 })
hi("@keyword", { fg = base_16.base0E })
hi("@keyword.function", { fg = base_16.base0E })
hi("@keyword.return", { fg = base_16.base0E })
hi("@keyword.operator", { fg = base_16.base0E })
hi("@conditional", { fg = base_16.base0E })
hi("@repeat", { fg = base_16.base0E })
hi("@label", { fg = base_16.base0A })
hi("@operator", { fg = base_16.base05 })
hi("@exception", { fg = base_16.base08 })
hi("@type", { fg = base_16.base0A })
hi("@type.builtin", { fg = base_16.base0A })
hi("@type.qualifier", { fg = base_16.base0E })
hi("@structure", { fg = base_16.base0E })
hi("@include", { fg = base_16.base0D })
hi("@property", { fg = base_16.base08 })
hi("@field", { fg = base_16.base08 })
hi("@punctuation.delimiter", { fg = base_16.base0F })
hi("@punctuation.bracket", { fg = base_16.base05 })
hi("@punctuation.special", { fg = base_16.base08 })
hi("@comment", { fg = base_30.grey_fg, style = "italic" })
hi("@comment.todo", { fg = base_30.grey, bg = base_30.white })
hi("@comment.note", { fg = base_30.white, bg = base_30.blue })
hi("@comment.warning", { fg = base_30.black, bg = base_30.yellow })
hi("@comment.error", { fg = base_30.white, bg = base_30.red })
hi("@tag", { fg = base_16.base08 })
hi("@tag.attribute", { fg = base_16.base0A })
hi("@tag.delimiter", { fg = base_16.base0F })
hi("@markup.strong", { style = "bold" })
hi("@markup.italic", { style = "italic" })
hi("@markup.underline", { style = "underline" })
hi("@markup.strike", { style = "strikethrough" })
hi("@markup.heading", { fg = base_16.base0D, style = "bold" })
hi("@markup.link", { fg = base_16.base08, style = "underline" })
hi("@markup.link.url", { fg = base_16.base0B, style = "underline" })
hi("@markup.list", { fg = base_16.base08 })
-- === NVIM-TREE ===
hi("NvimTreeNormal", { bg = base_30.darker_black })
hi("NvimTreeNormalNC", { bg = base_30.darker_black })
hi("NvimTreeRootFolder", { fg = base_30.red, style = "bold" })
hi("NvimTreeGitDirty", { fg = base_30.yellow })
hi("NvimTreeGitNew", { fg = base_30.green })
hi("NvimTreeGitDeleted", { fg = base_30.red })
hi("NvimTreeSpecialFile", { fg = base_30.yellow, style = "underline" })
hi("NvimTreeIndentMarker", { fg = base_30.grey })
hi("NvimTreeImageFile", { fg = base_30.dark_purple })
hi("NvimTreeSymlink", { fg = base_30.cyan })
hi("NvimTreeFolderName", { fg = base_30.blue })
hi("NvimTreeFolderIcon", { fg = base_30.folder_bg })
hi("NvimTreeOpenedFolderName", { fg = base_30.blue })
-- === WHICH-KEY ===
hi("WhichKey", { fg = base_30.blue })
hi("WhichKeyGroup", { fg = base_30.green })
hi("WhichKeyDesc", { fg = base_16.base05 })
hi("WhichKeySeparator", { fg = base_30.grey })
hi("WhichKeyFloat", { bg = base_30.darker_black })
hi("WhichKeyBorder", { fg = base_30.blue, bg = base_30.darker_black })
-- === HEALTH CHECK ===
hi("healthSuccess", { fg = base_30.green, bg = base_30.black })
end
return M

View File

@@ -0,0 +1,50 @@
-- Theme state file watcher
-- Watches ~/.config/theme-state and switches between bearded-arc / bearded-arc-light
local M = {}
local state_file = vim.fn.expand("~/.config/theme-state")
local current_theme = nil
local function apply_theme(name)
if name == current_theme then return end
current_theme = name
if name == "light" then
vim.cmd("colorscheme bearded-arc-light")
else
vim.cmd("colorscheme bearded-arc")
end
-- Re-setup lualine with the new palette
if package.loaded["configs.lualine"] then
require("configs.lualine").setup()
end
end
local function read_state()
local f = io.open(state_file, "r")
if f then
local state = f:read("*a"):gsub("%s+", "")
f:close()
return state == "light" and "light" or "dark"
end
return "dark"
end
function M.setup()
-- Apply initial theme on startup
apply_theme(read_state())
-- Watch for changes
local uv = vim.uv or vim.loop
local watcher = uv.new_fs_event()
if watcher then
watcher:start(state_file, {}, function(err, filename, events)
if err then return end
vim.schedule(function()
apply_theme(read_state())
end)
end)
end
end
return M

View File

@@ -1,59 +1,77 @@
-- Lualine configuration with bearded-arc theme colors
local colors = {
bg = "#232b3a", -- statusline_bg
fg = "#c3cfd9", -- base05
white = "#ABB7C1", -- white
black = "#1c2433", -- black
grey = "#444c5b", -- grey
light_grey = "#626a79", -- light_grey
-- Supports dark and light variants
-- Mode colors
nord_blue = "#6da4cd", -- normal mode
cyan = "#22ECDB", -- visual mode
dark_purple = "#B78AFF", -- insert mode
green = "#3CEC85", -- success/terminal
red = "#FF738A", -- errors/replace
yellow = "#EACD61", -- warnings
orange = "#FF955C", -- command
blue = "#69C3FF", -- info
-- Section backgrounds
lightbg = "#303847", -- file section bg
local palettes = {
dark = {
bg = "#232b3a",
fg = "#c3cfd9",
white = "#ABB7C1",
black = "#1c2433",
grey = "#444c5b",
light_grey = "#626a79",
nord_blue = "#6da4cd",
cyan = "#22ECDB",
dark_purple = "#B78AFF",
green = "#3CEC85",
red = "#FF738A",
yellow = "#EACD61",
orange = "#FF955C",
blue = "#69C3FF",
lightbg = "#303847",
},
light = {
bg = "#f0e8df",
fg = "#3a3228",
white = "#2a2a2a",
black = "#faf4ed",
grey = "#b0a89c",
light_grey = "#6a6258",
nord_blue = "#2a6a9a",
cyan = "#147a8a",
dark_purple = "#7b4fc4",
green = "#1e9b52",
red = "#d1344f",
yellow = "#b8890f",
orange = "#c45a1c",
blue = "#1a7db5",
lightbg = "#e8e0d4",
},
}
-- Custom theme based on bearded-arc
local bearded_arc_theme = {
normal = {
a = { bg = colors.nord_blue, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
c = { bg = colors.bg, fg = colors.fg },
},
insert = {
a = { bg = colors.dark_purple, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
visual = {
a = { bg = colors.cyan, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
replace = {
a = { bg = colors.red, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
command = {
a = { bg = colors.orange, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
terminal = {
a = { bg = colors.green, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
inactive = {
a = { bg = colors.bg, fg = colors.grey },
b = { bg = colors.bg, fg = colors.grey },
c = { bg = colors.bg, fg = colors.grey },
},
}
local function build_theme(colors)
return {
normal = {
a = { bg = colors.nord_blue, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
c = { bg = colors.bg, fg = colors.fg },
},
insert = {
a = { bg = colors.dark_purple, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
visual = {
a = { bg = colors.cyan, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
replace = {
a = { bg = colors.red, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
command = {
a = { bg = colors.orange, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
terminal = {
a = { bg = colors.green, fg = colors.black, gui = "bold" },
b = { bg = colors.lightbg, fg = colors.white },
},
inactive = {
a = { bg = colors.bg, fg = colors.grey },
b = { bg = colors.bg, fg = colors.grey },
c = { bg = colors.bg, fg = colors.grey },
},
}
end
-- Custom component for LSP status
local function lsp_status()
@@ -115,106 +133,117 @@ local function git_status()
return table.concat(parts, " ")
end
require("lualine").setup({
options = {
theme = bearded_arc_theme,
component_separators = { left = "", right = "" },
section_separators = { left = "", right = "" },
disabled_filetypes = {
statusline = { "alpha", "dashboard", "NvimTree", "neo-tree" },
winbar = {},
},
ignore_focus = {},
always_divide_middle = true,
globalstatus = true,
refresh = {
statusline = 1000,
tabline = 1000,
winbar = 1000,
},
},
sections = {
lualine_a = {
{
"mode",
fmt = function(str)
return str:sub(1, 1) -- Show only first letter (N, I, V, etc.)
end,
local M = {}
function M.setup()
local colors = palettes[vim.o.background] or palettes.dark
local theme = build_theme(colors)
require("lualine").setup({
options = {
theme = theme,
component_separators = { left = "", right = "" },
section_separators = { left = "", right = "" },
disabled_filetypes = {
statusline = { "alpha", "dashboard", "NvimTree", "neo-tree" },
winbar = {},
},
ignore_focus = {},
always_divide_middle = true,
globalstatus = true,
refresh = {
statusline = 1000,
tabline = 1000,
winbar = 1000,
},
},
lualine_b = {
{
"branch",
icon = "",
color = { fg = colors.light_grey },
},
{
git_status,
color = { bg = colors.lightbg },
},
},
lualine_c = {
{
"filename",
path = 1, -- Relative path
symbols = {
modified = " ",
readonly = " ",
unnamed = "[No Name]",
newfile = " ",
sections = {
lualine_a = {
{
"mode",
fmt = function(str)
return str:sub(1, 1)
end,
},
color = { bg = colors.lightbg, fg = colors.white },
},
{
function()
return require("nvim-navic").get_location()
end,
cond = function()
return package.loaded["nvim-navic"] and require("nvim-navic").is_available()
end,
color = { bg = colors.bg, fg = colors.light_grey },
lualine_b = {
{
"branch",
icon = "",
color = { fg = colors.light_grey },
},
{
git_status,
color = { bg = colors.lightbg },
},
},
{
lsp_diagnostics,
color = { bg = colors.bg },
lualine_c = {
{
"filename",
path = 1,
symbols = {
modified = " ",
readonly = " ",
unnamed = "[No Name]",
newfile = " ",
},
color = { bg = colors.lightbg, fg = colors.white },
},
{
function()
return require("nvim-navic").get_location()
end,
cond = function()
return package.loaded["nvim-navic"] and require("nvim-navic").is_available()
end,
color = { bg = colors.bg, fg = colors.light_grey },
},
{
lsp_diagnostics,
color = { bg = colors.bg },
},
},
lualine_x = {
{
lsp_status,
color = { fg = colors.nord_blue, bg = colors.bg },
},
{
"filetype",
colored = true,
icon_only = false,
icon = { align = "right" },
},
},
lualine_y = {
{
"progress",
color = { bg = colors.lightbg, fg = colors.white },
},
},
lualine_z = {
{
"location",
icon = "",
color = { bg = colors.green, fg = colors.black },
},
},
},
lualine_x = {
{
lsp_status,
color = { fg = colors.nord_blue, bg = colors.bg },
},
{
"filetype",
colored = true,
icon_only = false,
icon = { align = "right" },
},
inactive_sections = {
lualine_a = {},
lualine_b = {},
lualine_c = { "filename" },
lualine_x = { "location" },
lualine_y = {},
lualine_z = {},
},
lualine_y = {
{
"progress",
color = { bg = colors.lightbg, fg = colors.white },
},
},
lualine_z = {
{
"location",
icon = "",
color = { bg = colors.green, fg = colors.black },
},
},
},
inactive_sections = {
lualine_a = {},
lualine_b = {},
lualine_c = { "filename" },
lualine_x = { "location" },
lualine_y = {},
lualine_z = {},
},
tabline = {},
winbar = {},
inactive_winbar = {},
extensions = { "lazy", "mason" },
})
tabline = {},
winbar = {},
inactive_winbar = {},
extensions = { "lazy", "mason" },
})
end
M.setup()
return M

View File

@@ -1,73 +0,0 @@
---
name: deep-scout
description: Thorough architectural exploration. Traces dependencies, maps subsystems, understands the why. Writes structured context for the next agent in the chain.
tools: read, write, grep, find, ls, bash
model: opencode-go/mimo-v2-pro
output: scout.md
---
You are a deep scout. Thoroughly investigate a codebase to build comprehensive architectural understanding.
Unlike a regular scout, you trace dependency chains, read tests, check types, and understand WHY things are structured the way they are. Your output enables complex refactors and architectural changes.
**YOUR ONLY JOB IS EXPLORATION AND REPORTING. NEVER implement, write code, edit codebase files, or suggest "now let me implement…". Stop as soon as you have finished your structured report.**
## Output Protocol
When your task contains `[Write to: path]`, write your COMPLETE report to that exact path using the `write` tool. After writing, return a brief 1-2 sentence summary (e.g. "Wrote deep architectural context covering 18 files, dependency chains, and 3 risk areas to scout.md").
Without `[Write to:]`, output your full report as text.
## Strategy
1. Start broad: find/ls to map project structure
2. Identify entry points and trace execution flow
3. Read types/interfaces that define contracts
4. Follow import chains to understand coupling
5. Check tests to understand expected behavior
6. Read config files that affect behavior
7. Look at git history for recent changes if relevant (`git log --oneline -20`)
## Output format
# Deep Context
## Project Structure
Relevant directory tree with annotations.
## Architecture Overview
How the system is organized. What the major subsystems are. How they communicate.
Include ASCII diagrams if helpful.
## Type Contracts
Key interfaces and types that define boundaries between components.
Include actual code:
**Rule: When an implementation must mirror an existing pattern (e.g. a pipeline, a handler, a system), include the EXACT code of the relevant functions — not summaries or descriptions. The coder needs to copy the pattern, not reconstruct it from prose.**
```typescript
// From path/to/types.ts:10-40
interface ... { }
```
## Execution Flow
Trace the relevant code paths step by step. "User calls X → handler Y → service Z → database W."
## Dependencies & Coupling
What depends on what. Which changes will cascade. Where are the stable vs fragile boundaries.
## Test Coverage
What's tested, what isn't. What the tests reveal about expected behavior.
## Risks & Constraints
- Implicit assumptions in the code
- Shared mutable state
- Performance constraints
- Backwards compatibility requirements
## Files Map
Complete list of relevant files with their role:
- `path/to/file.ts` - Role/purpose
---
**STOP HERE. Do not write any implementation. Do not suggest next steps beyond the files map. Your job is done.**

View File

@@ -1,8 +1,8 @@
---
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: opencode-go/mimo-v2-pro
tools: read, bash, mcp:qmd, mcp:opty
model: opencode-go/qwen3.6-plus
defaultProgress: true
---
@@ -11,9 +11,9 @@ You are an explorer. Thoroughly investigate a codebase or knowledge base and syn
**CRITICAL CONSTRAINTS**:
- Do NOT use the subagent tool
- Do NOT delegate to other agents, especially not to yourself (the explorer agent)
- Use ONLY your available tools: read, bash, write, mcp:qmd, mcp:opty
- Use ONLY your available tools: read, bash, mcp:qmd, mcp:opty
**CRITICAL**: Use the `write` tool to save your complete findings to `/home/jonas/.pi/context.md`. This must be a full document with:
**OUTPUT**: Produce your complete findings as the final response. This must be a full analysis with:
- Architecture overview and structure
- Complete file contents (not summaries)
- Dependency chains and relationships
@@ -21,5 +21,3 @@ You are an explorer. Thoroughly investigate a codebase or knowledge base and syn
- 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"

View File

@@ -1,26 +0,0 @@
---
name: reviewer-quick
description: Fast cross-family review for medium-risk changes. Haiku-speed, Claude-perspective. Outputs review as text.
tools: read, bash, grep, find
model: opencode/big-pickle
---
You are a code reviewer doing a quick pass. Focus on obvious bugs, security issues, and integration problems. Don't deep-dive into edge cases — flag only what matters.
## Strategy
1. Read the changed files
2. Check for obvious bugs, security issues, type errors
3. Verify imports and integration with existing code
4. Output your full review as text (the framework captures it)
## Output format
# Quick Review
## Verdict: PASS | NEEDS_FIXES
## Issues (if any)
- `file.ts:42` — Issue and fix
## Summary
1-2 sentences.

View File

@@ -1,26 +1,18 @@
---
name: scout
description: Fast codebase recon. Finds relevant files, types, and patterns, then writes structured context for the next agent in the chain.
tools: read, write, grep, find, ls, bash
description: Fast codebase recon. Finds relevant files, types, and patterns.
tools: read, grep, find, ls, bash
skills: opty, qmd
model: opencode-go/mimo-v2-pro
output: scout.md
output: false
---
You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
Your output will be passed to agents who have NOT seen the files you explored.
**YOUR ONLY JOB IS EXPLORATION AND REPORTING. NEVER implement, write implementation plans, write code, or edit codebase files. Stop as soon as you have finished your structured report.**
**DO ALL WORK YOURSELF using your own tools. NEVER delegate to subagents, NEVER call `subagent(...)`, NEVER invoke `pi` via bash or any other mechanism. You must personally run every search, read every file, and produce the report directly.**
## Output Protocol
When your task contains `[Write to: path]`, write your COMPLETE report to that exact path using the `write` tool. After writing, return a brief 1-2 sentence summary (e.g. "Wrote structured findings covering 12 files, 3 key types, and 2 risks to scout.md").
Without `[Write to:]`, output your full report as text.
## Tools
- **opty** — semantic/HDC code search via CLI: `opty query "description"` to find functions/types/imports by meaning; output includes file + line number
- **qmd** — knowledge base search via CLI: `qmd query $'lex: X\nvec: Y'` to find docs/notes by keyword or vector; `qmd get <path>` / `qmd multi-get <glob>` to retrieve full documents

View File

@@ -0,0 +1,275 @@
/**
* ask-claude — Stream Claude agent reviews into pi.
*
* For AGENTS to use. Delegates to specialized Claude agents or raw models
* for review, analysis, debugging, and second opinions.
*
* Tool (callable by the LLM):
* ask_claude(prompt, agent?, model?, question?, session_id?)
* agent — any agent name from ~/.claude/agents/ (e.g. "plan_review", "code_review", "oracle", "debug")
* model — model override: "opus", "sonnet", or full model ID
* question — specific focus prepended as a review header
* session_id — resume a prior conversation (returned in every response)
*
* Commands:
* /review-plan — editor → Claude Opus plan_review → inject result
* /review-code — editor → Claude Sonnet code_review → inject result
*/
import { defineTool, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@mariozechner/pi-ai";
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import {
type ClaudeDetails,
type Theme,
renderToolCallLine,
renderToolResultBox,
renderToolBlock,
formatUsage,
buildEditDiff,
formatAnthropicError,
runClaude,
type RunClaudeResult,
} from "../shared/claude-stream.js";
// =============================================================================
// Rendering (ask-claude specific — uses shared tool renderers)
// =============================================================================
function buildLabel(agent?: string, model?: string): string {
if (agent) return `Claude [${agent}]`;
if (model) return `Claude [${model}]`;
return "Claude Sonnet";
}
// =============================================================================
// Tool definition
// =============================================================================
const AskClaudeParams = Type.Object({
prompt: Type.String({
description: "Full content to review/analyze. Include all relevant context: CLAUDE.md conventions, files explored, code or plan to review.",
}),
agent: Type.Optional(Type.String({
description: "Agent name from ~/.claude/agents/ (e.g. 'plan_review', 'code_review', 'oracle', 'debug'). Omit to use model= directly.",
})),
model: Type.Optional(Type.String({
description: "Model override: 'opus', 'sonnet', 'haiku', or a full model ID. When agent is set this overrides the agent's default. When agent is omitted this selects the model directly.",
})),
question: Type.Optional(Type.String({
description: "Specific question or focus area prepended to the prompt (e.g. 'Focus on security', 'Are there race conditions?').",
})),
session_id: Type.Optional(Type.String({
description: "Resume a prior conversation. Pass the session_id returned from a previous ask_claude call.",
})),
});
const askClaudeTool = defineTool({
name: "ask_claude",
label: "Ask Claude",
description: [
"Ask a Claude agent or model for review, analysis, or a second opinion.",
"agent=<name> runs a named agent from ~/.claude/agents/ (e.g. 'plan_review', 'code_review', 'oracle', 'debug').",
"Use model= alone for free-form requests without an agent. Use question= to specify a focus.",
"Pass session_id from a prior response to continue the same conversation across turns.",
"CLAUDE.md and .claude/skills are loaded automatically from the project root.",
].join(" "),
promptSnippet: "Ask a Claude agent or model for review, analysis, or a second opinion",
promptGuidelines: [
"Use ask_claude(agent=<name>) to invoke a specialized agent — include all relevant context in the prompt.",
"Use ask_claude(model='opus', question='...') for free-form deep analysis.",
"Always include the artifact to review (plan, code, problem description) in the prompt.",
"Pass session_id back from the previous response to continue the conversation.",
],
parameters: AskClaudeParams,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const fullPrompt = params.question
? `## Review Focus\n\n${params.question}\n\n## Content\n\n${params.prompt}`
: params.prompt;
const label = buildLabel(params.agent, params.model);
const details: ClaudeDetails = { label, done: false, blocks: [], finalText: "", isResume: !!params.session_id };
try {
const result: RunClaudeResult = await runClaude(fullPrompt, {
agent: params.agent,
model: params.model,
sessionId: params.session_id,
enrichEditDiffs: true, // ask-claude wants diff enrichment
cwd: ctx.cwd,
signal,
onUpdate: (partial) => {
Object.assign(details, partial);
onUpdate?.({
content: [{ type: "text", text: details.finalText || "(thinking…)" }],
details: { ...details },
});
},
});
details.done = true;
details.finalText = result.finalText;
details.blocks = result.blocks;
details.sessionId = result.sessionId;
details.costUsd = result.costUsd;
details.inputTokens = result.inputTokens;
details.outputTokens = result.outputTokens;
details.cacheReadTokens = result.cacheReadTokens;
details.cacheWriteTokens = result.cacheWriteTokens;
const sessionNote = result.sessionId
? `\n\n---\n*session_id: \`${result.sessionId}\`*`
: "";
return {
content: [{ type: "text", text: (result.finalText || "(no output)") + sessionNote }],
details: { ...details },
};
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
details.done = true;
details.finalText = `Error: ${errMsg}`;
return {
content: [{ type: "text", text: `**Claude error:** ${errMsg}` }],
details: { ...details },
};
}
},
renderCall(args, theme, _ctx) {
const label = buildLabel(args.agent, args.model);
let text = theme.fg("toolTitle", theme.bold("ask_claude ")) + theme.fg("accent", `[${label}]`);
if (args.question) {
text += "\n " + theme.fg("dim", theme.italic(args.question));
} else {
const lines = args.prompt.split("\n").filter((l) => l.trim()).slice(0, 3);
text += "\n " + theme.fg("dim", lines.join("\n "));
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme, _ctx) {
const d = result.details as ClaudeDetails | undefined;
if (!d) return new Text(theme.fg("muted", "…"), 0, 0);
const isDone = d.done && !isPartial;
const statusIcon = isDone ? theme.fg("success", "✓ ") : theme.fg("warning", "⏳ ");
const c = new Container();
const resume = d.isResume ? theme.fg("dim", " ↩") : "";
c.addChild(new Text(statusIcon + theme.fg("toolTitle", theme.bold(d.label)) + resume, 0, 0));
for (const block of d.blocks ?? []) {
if (block.type === "thinking" && block.text.trim()) {
c.addChild(new Text(theme.fg("dim", theme.italic(block.text.trimEnd())), 0, 0));
} else if (block.type === "tool") {
c.addChild(renderToolBlock(block, theme as any));
} else if (block.type === "text" && block.text.trim()) {
c.addChild(new Spacer(1));
if (isDone) {
c.addChild(new Markdown(block.text.trim(), 0, 0, getMarkdownTheme()));
} else {
c.addChild(new Text(theme.fg("dim", block.text.trimEnd()), 0, 0));
}
}
}
if (isDone) {
const usageLine = formatUsage(d);
const parts: string[] = [];
if (usageLine) parts.push(usageLine);
if (d.sessionId) parts.push(`session:${d.sessionId.slice(0, 8)}`);
if (parts.length > 0) {
c.addChild(new Spacer(1));
c.addChild(new Text(theme.fg("dim", parts.join(" ")), 0, 0));
}
}
return c;
},
});
// =============================================================================
// Extension entry point
// =============================================================================
export default function (pi: ExtensionAPI) {
pi.registerTool(askClaudeTool);
// ── /review-plan ───────────────────────────────────────────────────────
pi.registerCommand("review-plan", {
description: "Editor → Claude Opus plan_review → inject review into conversation",
handler: async (_args, ctx) => {
const input = await ctx.ui.editor(
"Plan Review · Claude Opus",
"Paste your plan or strategy. Claude will review for correctness, completeness, and risk.",
);
if (!input?.trim()) { ctx.ui.notify("Cancelled.", "info"); return; }
ctx.ui.setStatus("ask-claude", "Asking Claude Opus (plan_review)…");
try {
const r = await runClaude(input, { agent: "plan_review", cwd: ctx.cwd, onUpdate: () => {} });
if (!r.finalText.trim()) { ctx.ui.notify("No output from Claude.", "warning"); return; }
pi.sendMessage(
{ customType: "ask-claude-review", content: r.finalText.trim(), display: true,
details: { label: "Claude Opus · plan_review", output: r.finalText } },
{ triggerTurn: true },
);
} catch (err) {
ctx.ui.notify(`Claude error: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally { ctx.ui.setStatus("ask-claude", undefined); }
},
});
// ── /review-code ───────────────────────────────────────────────────────
pi.registerCommand("review-code", {
description: "Editor → Claude Sonnet code_review → inject review into conversation",
handler: async (_args, ctx) => {
const input = await ctx.ui.editor(
"Code Review · Claude Sonnet",
"Paste code to review. Include the plan it implements and any specific concerns.",
);
if (!input?.trim()) { ctx.ui.notify("Cancelled.", "info"); return; }
ctx.ui.setStatus("ask-claude", "Asking Claude Sonnet (code_review)…");
try {
const r = await runClaude(input, { agent: "code_review", cwd: ctx.cwd, onUpdate: () => {} });
if (!r.finalText.trim()) { ctx.ui.notify("No output from Claude.", "warning"); return; }
pi.sendMessage(
{ customType: "ask-claude-review", content: r.finalText.trim(), display: true,
details: { label: "Claude Sonnet · code_review", output: r.finalText } },
{ triggerTurn: true },
);
} catch (err) {
ctx.ui.notify(`Claude error: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally { ctx.ui.setStatus("ask-claude", undefined); }
},
});
// ── Message renderer for injected reviews ──────────────────────────────
pi.registerMessageRenderer("ask-claude-review", (message, { expanded }, theme) => {
const d = message.details as { label?: string; output?: string } | undefined;
const label = d?.label ?? "Claude";
const output = (d?.output ?? "").trim();
if (expanded) {
const c = new Container();
c.addChild(new Text(theme.fg("accent", "◆ ") + theme.fg("toolTitle", theme.bold(label)), 0, 0));
c.addChild(new Spacer(1));
c.addChild(new Markdown(output, 0, 0, getMarkdownTheme()));
return c;
}
let text = theme.fg("accent", "◆ ") + theme.fg("toolTitle", theme.bold(label));
const lines = output.split("\n").filter((l) => l.trim());
const preview = lines.slice(0, 4).join("\n");
if (preview) {
text += "\n" + theme.fg("dim", preview);
if (lines.length > 4) text += "\n" + theme.fg("muted", `${lines.length - 4} more (Ctrl+O)`);
}
return new Text(text, 0, 0);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,419 +0,0 @@
/**
* 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";
// ── Session-window helpers ─────────────────────────────────────────────────
//
// We store the actual `resets_at` timestamp returned by Claude's usage API
// (via the usage:update event) so the switch menu can show a live countdown
// to the next session reset rather than a guessed switchedAt + 5h window.
function sessionStampPath(account: Account): string {
return path.join(PROFILES_DIR, `session-${account}.json`);
}
/** Persist the actual session-reset timestamp for an account. */
function saveSessionResetsAt(account: Account, resetsAt: number): void {
try {
fs.mkdirSync(PROFILES_DIR, { recursive: true });
fs.writeFileSync(
sessionStampPath(account),
JSON.stringify({ resetsAt }, null, 2),
{ mode: 0o600 },
);
} catch {}
}
/** Load the stored session-reset timestamp (ms epoch), or null. */
function loadSessionResetsAt(account: Account): number | null {
try {
const raw = fs.readFileSync(sessionStampPath(account), "utf-8");
const { resetsAt } = JSON.parse(raw) as { resetsAt: number };
if (typeof resetsAt === "number") return resetsAt;
} catch {}
return null;
}
/** Format milliseconds as a compact duration string. */
function formatDuration(ms: number): string {
const totalSec = Math.ceil(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m`;
return `${s}s`;
}
/**
* One-line session summary appended to each account option in the select menu.
*
* Window still running: [resets in Xh Ym]
* Window already passed: [0 (ready)]
* Never recorded: (empty)
*/
function sessionSummary(account: Account): string {
const resetsAt = loadSessionResetsAt(account);
if (resetsAt === null) return "";
const remaining = resetsAt - Date.now();
if (remaining <= 0) return " [0 (ready)]";
return ` [resets in ${formatDuration(remaining)}]`;
}
// ── 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.
*
* We parse + re-serialize the JSON to guard against corrupt auth.json
* (e.g. trailing commas left by buggy serializers). If the file can't
* be parsed, we skip the save rather than propagate bad data.
*/
function saveCurrentAuthToProfile(account: Account): boolean {
fs.mkdirSync(PROFILES_DIR, { recursive: true });
if (!fs.existsSync(AUTH_JSON)) return false;
try {
const raw = fs.readFileSync(AUTH_JSON, "utf-8");
const parsed = JSON.parse(raw); // validates JSON
const clean = JSON.stringify(parsed, null, 2);
fs.writeFileSync(profilePath(account), clean, { mode: 0o600 });
return true;
} catch {
// auth.json is missing or corrupt — don't propagate bad data
return false;
}
}
/**
* Copy a profile file to auth.json. This is an atomic-ish swap that
* replaces the entire file rather than merging per-provider.
*
* Like saveCurrentAuthToProfile, we round-trip through JSON.parse to
* ensure we never write corrupt data to auth.json.
*/
function restoreProfileToAuth(account: Account): void {
const raw = fs.readFileSync(profilePath(account), "utf-8");
const parsed = JSON.parse(raw); // throws on corrupt profile
const clean = JSON.stringify(parsed, null, 2);
fs.writeFileSync(AUTH_JSON, clean, { mode: 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";
// Whenever usage-bars fetches fresh data, save the real resets_at for the
// current account so the switch menu shows an accurate live countdown.
pi.events.on("usage:update", (event: unknown) => {
const e = event as { sessionResetsAt?: number };
if (currentAccount !== "unknown" && typeof e.sessionResetsAt === "number") {
saveSessionResetsAt(currentAccount, e.sessionResetsAt);
}
});
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
}
// Guard against corrupt auth.json (e.g. trailing commas from buggy
// serializers). Re-serialize to clean JSON and reload so the auth
// system picks up valid credentials.
try {
const raw = fs.readFileSync(AUTH_JSON, "utf-8");
const parsed = JSON.parse(raw);
const clean = JSON.stringify(parsed, null, 2);
if (clean !== raw) {
fs.writeFileSync(AUTH_JSON, clean, { mode: 0o600 });
ctx.modelRegistry.authStorage.reload();
}
} catch {
// auth.json missing or unparseable — nothing we can fix here
}
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)" : ""}${sessionSummary("personal")}`;
const workLabel = `󰃖 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}${sessionSummary("work")}`;
const accountChoice = await ctx.ui.select(
"Switch Claude account:",
[personalLabel, workLabel],
);
if (accountChoice === undefined) return;
newAccount = accountChoice.startsWith("") ? "personal" : "work";
} else {
ctx.ui.notify(
"Usage: /switch-claude [personal|work|save <name>]",
"warning",
);
return;
}
if (newAccount === currentAccount) {
ctx.ui.notify(
`Already using ${statusLabel(newAccount)}`,
"info",
);
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",
);
}
},
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
{
"name": "claude-agent-sdk",
"type": "module",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
"change-case": "^5.4.4"
},
"pi": {
"extensions": ["./index.ts"]
}
}

View File

@@ -4,11 +4,10 @@
* 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
* ~dir | 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)
* usage:update event — set by usage-bars extension → S/W bars (Claude usage, always shown)
* ctx.getContextUsage() → C bar (rendered here)
* ctx.model → model short name
* "lsp" — set by lsp-pi extension → strip "LSP " prefix
@@ -77,11 +76,6 @@ function formatDurationMs(ms: number): string {
return "<1m";
}
// 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;
@@ -112,18 +106,7 @@ export default function (pi: ExtensionAPI) {
: 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
// 2. S / W usage bars + C bar — joined as one |-separated block
const usageRaw = statuses.get("usage-bars");
const contextUsage = ctx?.getContextUsage?.();
{
@@ -135,8 +118,8 @@ export default function (pi: ExtensionAPI) {
const session = Math.max(0, Math.min(100, Math.round(usageSession)));
const weekly = Math.max(0, Math.min(100, Math.round(usageWeekly)));
let sPart = theme.fg("muted", "S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`);
let wPart = theme.fg("muted", "W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`);
let sPart = theme.fg("muted", "\uF4F5 S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`);
let wPart = theme.fg("muted", "\uF4F5 W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`);
if (sessionResetsAt !== null) {
const msLeft = sessionResetsAt - Date.now();
@@ -156,34 +139,51 @@ export default function (pi: ExtensionAPI) {
if (contextUsage && contextUsage.percent !== null) {
const pct = Math.round(contextUsage.percent);
const chatStatus = statuses.get("chat-claude");
const isChatActive = !!chatStatus && chatStatus.includes("Claude");
// When chat is active and context is high, show warning indicator
let cLabel = "C";
let cColor = "muted";
if (isChatActive && pct >= 70) {
cLabel = pct >= 90 ? "C⚠" : "C⚡";
cColor = pct >= 90 ? "error" : "warning";
}
const cBar =
theme.fg("muted", "C ") +
theme.fg(cColor, cLabel + " ") +
renderBrailleBar(theme, pct) +
" " +
theme.fg("dim", `${pct}%`);
theme.fg(pct >= 70 && isChatActive ? "warning" : "dim", `${pct}%`);
block = block ? block + pipeSep + cBar : cBar;
}
if (block) parts.push(block);
}
// 4. Model short name
// 3. 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
// 4. 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
// 5. 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));
}
// 6. Active Claude chat session
const chatRaw = statuses.get("chat-claude");
if (chatRaw) {
parts.push(theme.fg("accent", stripAnsi(chatRaw).trim()));
}
return parts.join(sep);
}
@@ -209,7 +209,7 @@ export default function (pi: ExtensionAPI) {
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
pi.on("session_start", async (_event, _ctx) => {
pi.on("session_start", (_event, _ctx) => {
ctx = _ctx;
installFooter(_ctx);
});
@@ -230,12 +230,7 @@ export default function (pi: ExtensionAPI) {
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();
});
// Listen for usage updates — store raw values so we can build bars + dynamic
// countdown directly rather than parsing the ANSI status string from usage-bars.
pi.events.on("usage:update", (data: any) => {

View File

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

View File

@@ -1,15 +0,0 @@
/**
* 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.
}

View File

@@ -0,0 +1,56 @@
# pi-ask-mcp
A minimal MCP stdio server that gives Claude **one** tool — `ask` — which routes
structured questions back to pi's native ask UI instead of using Claude's
built-in `AskUserQuestion`.
This is **not** a regular pi extension. It is a subprocess of `claude`, which is
itself a subprocess of the `chat-claude` extension. The pi-side counterpart is
[`shared/pi-ask-bridge.ts`](../../shared/pi-ask-bridge.ts), which:
1. Opens a Unix-domain socket per chat session.
2. Generates an `--mcp-config` JSON pointing here, with `PI_ASK_SOCKET=<sock>`.
3. Translates `ask` requests off the socket into
`askSingleQuestionWithInlineNote` / `askQuestionsWithTabs` calls and writes
the result back.
## Architecture
```
pi
└── chat-claude
├── pi-ask-bridge (UDS server, owns ui.custom)
└── claude -p ... --mcp-config <generated.json> --disallowed-tools AskUserQuestion
└── pi-ask-mcp/server.js (this file)
↳ on tools/call ask → connect $PI_ASK_SOCKET → ask → reply
```
## Why a hand-written MCP server
No `@modelcontextprotocol/sdk` dependency, no transpile step, no
`node_modules`. The MCP stdio protocol is small enough (~6 method handlers)
that writing it directly keeps the file self-contained and trivially
portable. Claude CLI spawns it via `node server.js`.
## Wire format
Stdio (with Claude): JSON-RPC 2.0 over newline-delimited JSON.
Socket (with pi-ask-bridge): NDJSON, one request → one response, then close.
```jsonc
// → pi
{ "id": "uuid", "type": "ask",
"questions": [
{ "id": "auth", "question": "Auth method?",
"options": [{"label": "OAuth"}, {"label": "API key"}],
"multi": false, "recommended": 0 }
] }
// ← pi (success)
{ "id": "uuid", "type": "result",
"results": [{ "id": "auth", "selectedOptions": ["OAuth"] }] }
// ← pi (cancel / error)
{ "id": "uuid", "type": "error", "message": "cancelled" }
```

View File

@@ -0,0 +1,7 @@
{
"name": "pi-ask-mcp",
"private": true,
"type": "module",
"main": "server.js",
"description": "Minimal MCP stdio server bridging Claude → pi-ask-bridge."
}

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env node
// pi-ask-mcp/server.js
//
// Minimal MCP stdio server that exposes ONE tool: `ask`.
// Bridges Claude → pi via a Unix-domain socket: when Claude calls the tool,
// this server forwards the question(s) to pi over $PI_ASK_SOCKET, awaits
// the user's answer, and returns it as the tool result.
//
// Wire format with Claude (stdin/stdout): JSON-RPC 2.0 over NDJSON.
// Wire format with pi (PI_ASK_SOCKET): NDJSON request/response, see
// ../../shared/pi-ask-bridge.ts.
//
// This file is INTENTIONALLY plain JavaScript (no transpile step, no
// node_modules) — Claude CLI spawns it via `node <path>`. Keep it small,
// dependency-free, and self-contained.
import { connect } from "node:net";
import { randomUUID } from "node:crypto";
import { createInterface } from "node:readline";
// ── Configuration ──────────────────────────────────────────────────────────
const SOCKET = process.env.PI_ASK_SOCKET;
if (!SOCKET) {
process.stderr.write("[pi-ask-mcp] PI_ASK_SOCKET env var is required\n");
process.exit(2);
}
const SERVER_INFO = { name: "pi", version: "0.1.0" };
const PROTOCOL_VERSION = "2024-11-05";
const SOCKET_TIMEOUT_MS = 15 * 60 * 1000; // matches runClaude's default
// ── Tool schema (kept in sync with pi-ask-tool/index.ts AskParamsSchema) ──
const ASK_INPUT_SCHEMA = {
type: "object",
required: ["questions"],
properties: {
questions: {
type: "array",
minItems: 1,
description: "One or more questions to ask the user.",
items: {
type: "object",
required: ["id", "question", "options"],
properties: {
id: { type: "string", description: "Stable id (e.g. 'auth', 'cache')." },
question: { type: "string", description: "Question text shown to the user." },
options: {
type: "array",
minItems: 1,
description: "2-5 concise options. Do NOT include 'Other' (UI adds it).",
items: {
type: "object",
required: ["label"],
properties: {
label: { type: "string", description: "Option display label." },
},
},
},
multi: { type: "boolean", description: "Allow multi-select. Defaults to false." },
recommended: { type: "number", description: "0-indexed recommended option (default highlight)." },
},
},
},
},
};
const ASK_DESCRIPTION = [
"Ask the user one or more structured questions through pi's native TUI.",
"Use this whenever a choice materially affects the outcome — instead of",
"guessing or the built-in AskUserQuestion. Provide 2-5 concise options.",
"Set multi=true when multiple answers are valid. Do NOT include an 'Other'",
"option (UI adds it automatically). The result is a JSON array of",
"{id, selectedOptions[], customInput?} per question — empty selectedOptions",
"means the user cancelled.",
].join(" ");
// ── stdio framing: NDJSON ──────────────────────────────────────────────────
const rl = createInterface({ input: process.stdin });
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
const log = (msg) => process.stderr.write(`[pi-ask-mcp] ${msg}\n`);
// ── socket round-trip to pi-ask-bridge ─────────────────────────────────────
function askPi(args) {
return new Promise((resolve, reject) => {
const sock = connect(SOCKET);
const id = randomUUID();
let buf = "";
let settled = false;
const finish = (fn, val) => { if (settled) return; settled = true; clearTimeout(t); fn(val); try { sock.end(); } catch {} };
const t = setTimeout(
() => finish(reject, new Error(`pi-ask bridge timeout after ${SOCKET_TIMEOUT_MS / 1000}s`)),
SOCKET_TIMEOUT_MS,
);
sock.on("connect", () => sock.write(JSON.stringify({ id, type: "ask", ...args }) + "\n"));
sock.on("data", (d) => {
buf += d.toString();
const nl = buf.indexOf("\n");
if (nl < 0) return;
try { finish(resolve, JSON.parse(buf.slice(0, nl))); }
catch (err) { finish(reject, err); }
});
sock.on("error", (err) => finish(reject, err));
sock.on("close", () => {
if (!settled) finish(reject, new Error("pi-ask bridge closed connection without reply"));
});
});
}
// ── JSON-RPC method handlers ───────────────────────────────────────────────
async function handleRequest(req) {
const { id, method, params } = req;
try {
switch (method) {
case "initialize":
return ok(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
});
case "tools/list":
return ok(id, {
tools: [{ name: "ask", description: ASK_DESCRIPTION, inputSchema: ASK_INPUT_SCHEMA }],
});
case "tools/call": {
const name = params?.name;
const args = params?.arguments ?? {};
if (name !== "ask") return err(id, -32602, `unknown tool: ${name}`);
const reply = await askPi(args);
if (reply.type === "error") {
return ok(id, {
isError: true,
content: [{ type: "text", text: `(user did not answer: ${reply.message})` }],
});
}
return ok(id, {
content: [{ type: "text", text: JSON.stringify(reply.results, null, 2) }],
});
}
case "ping": return ok(id, {});
case "resources/list": return ok(id, { resources: [] });
case "prompts/list": return ok(id, { prompts: [] });
default: return err(id, -32601, `method not found: ${method}`);
}
} catch (e) {
return err(id, -32603, e instanceof Error ? e.message : String(e));
}
}
const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
const err = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
// ── main loop ──────────────────────────────────────────────────────────────
//
// Track in-flight handlers so we don't exit before they finish. Without this,
// `node server.js <<<input` (or any case where stdin closes mid-request) would
// race the async tools/call handler against rl 'close' → process.exit, and
// the reply would silently disappear.
let inflight = 0;
let stdinClosed = false;
function drainAndExit(code = 0) {
if (inflight === 0) process.exit(code);
}
async function handleOne(msg) {
// Notifications carry no id and expect no response.
if (msg.id === undefined || msg.id === null) {
if (msg.method === "exit") drainAndExit(0);
return; // notifications/initialized, notifications/cancelled, etc.
}
inflight += 1;
try {
const reply = await handleRequest(msg);
if (reply) send(reply);
} finally {
inflight -= 1;
if (stdinClosed) drainAndExit(0);
}
}
rl.on("line", (line) => {
if (!line.trim()) return;
let msg;
try { msg = JSON.parse(line); } catch { return; }
if (Array.isArray(msg)) {
for (const m of msg) void handleOne(m);
} else {
void handleOne(msg);
}
});
rl.on("close", () => { stdinClosed = true; drainAndExit(0); });
process.on("SIGTERM", () => process.exit(0));
process.on("SIGINT", () => process.exit(0));
log("ready, socket=" + SOCKET);

View File

@@ -0,0 +1,23 @@
# Pi Ask Tool Extension
This extension bridges Claude Code's ask functionality into pi's TUI, allowing users to ask questions and receive answers directly in the TUI interface.
## Features
- Seamless integration with pi's TUI
- Support for all Claude agents (plan_review, code_review, debug, oracle)
- Multi-turn conversations with session management
- Context-aware responses based on codebase exploration
## Usage
1. Run the CLI agent: `pi-ask-tool`
2. Type your question in the TUI
3. Receive answers directly in the TUI interface
4. Continue the conversation or start new ones
## Configuration
Default agent: `code_review`
Default model: `sonnet`
Session persistence: Enabled

View File

@@ -0,0 +1,37 @@
// Pi Ask Tool CLI Agent
import { ask_claude } from "../../@piplugin/ask-claude"
import { sessionId } from '../shared';
export async function start() {
console.log('Pi Ask Tool initialized');
while (true) {
// Get user input from TUI (simplified for example)
const userInput = await getTUIInput('Ask a question:');
// Handle multi-turn sessions via sessionId
const response = await ask_claude({
prompt: userInput,
agent: 'code_review', // Default agent
session_id: sessionId
});
// Display answer in TUI
await showTUIResult(response);
// Update session context if needed
sessionId = response.session_id || sessionId;
}
}
// Mock TUI handlers - implement actual TUI integration
async function getTUIInput(question: string) {
// Replace with real TUI input method
const input = process.stdin.read().toString();
return input;
}
async function showTUIResult(result: any) {
console.log('Answer:', result.summary || result);
}

View File

@@ -1,378 +0,0 @@
/**
* Qwen Provider Extension
*
* Registers Qwen 3.5 models via the qwen.ai OAuth flow (chat.qwen.ai).
* Based on the upstream custom-provider-qwen-cli example.
*
* Models:
* - qwen3.5-plus (Qwen3.5 best — rivals Qwen3-Max, 1M ctx, cheaper)
* - qwen3.5-flash (Qwen3.5 fast & cheap, 1M ctx)
* - qwen3-max (Qwen3 flagship, strongest reasoning, 262K ctx)
* - qwen-plus (Qwen3 balanced, 1M ctx)
* - qwen-flash (Qwen3 fast, 1M ctx)
*
* Usage:
* /login qwen-cli (browser OAuth)
* or set QWEN_CLI_API_KEY=...
*/
import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
// =============================================================================
// Constants
// =============================================================================
const QWEN_DEVICE_CODE_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/device/code";
const QWEN_TOKEN_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/token";
const QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
const QWEN_SCOPE = "openid profile email model.completion";
const QWEN_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
const QWEN_DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
const QWEN_POLL_INTERVAL_MS = 2000;
// =============================================================================
// PKCE Helpers
// =============================================================================
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest("SHA-256", data);
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return { verifier, challenge };
}
// =============================================================================
// OAuth Implementation
// =============================================================================
interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
expires_in: number;
interval?: number;
}
interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
resource_url?: string;
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Login cancelled"));
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(new Error("Login cancelled"));
},
{ once: true },
);
});
}
async function startDeviceFlow(): Promise<{ deviceCode: DeviceCodeResponse; verifier: string }> {
const { verifier, challenge } = await generatePKCE();
const body = new URLSearchParams({
client_id: QWEN_CLIENT_ID,
scope: QWEN_SCOPE,
code_challenge: challenge,
code_challenge_method: "S256",
});
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const requestId = globalThis.crypto?.randomUUID?.();
if (requestId) headers["x-request-id"] = requestId;
const response = await fetch(QWEN_DEVICE_CODE_ENDPOINT, {
method: "POST",
headers,
body: body.toString(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Device code request failed: ${response.status} ${text}`);
}
const data = (await response.json()) as DeviceCodeResponse;
if (!data.device_code || !data.user_code || !data.verification_uri) {
throw new Error("Invalid device code response: missing required fields");
}
return { deviceCode: data, verifier };
}
async function pollForToken(
deviceCode: string,
verifier: string,
intervalSeconds: number | undefined,
expiresIn: number,
signal?: AbortSignal,
): Promise<TokenResponse> {
const deadline = Date.now() + expiresIn * 1000;
const resolvedIntervalSeconds =
typeof intervalSeconds === "number" && Number.isFinite(intervalSeconds) && intervalSeconds > 0
? intervalSeconds
: QWEN_POLL_INTERVAL_MS / 1000;
let intervalMs = Math.max(1000, Math.floor(resolvedIntervalSeconds * 1000));
const handleTokenError = async (error: string, description?: string): Promise<boolean> => {
switch (error) {
case "authorization_pending":
await abortableSleep(intervalMs, signal);
return true;
case "slow_down":
intervalMs = Math.min(intervalMs + 5000, 10000);
await abortableSleep(intervalMs, signal);
return true;
case "expired_token":
throw new Error("Device code expired. Please restart authentication.");
case "access_denied":
throw new Error("Authorization denied by user.");
default:
throw new Error(`Token request failed: ${error} - ${description || ""}`);
}
};
while (Date.now() < deadline) {
if (signal?.aborted) throw new Error("Login cancelled");
const body = new URLSearchParams({
grant_type: QWEN_GRANT_TYPE,
client_id: QWEN_CLIENT_ID,
device_code: deviceCode,
code_verifier: verifier,
});
const response = await fetch(QWEN_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
const responseText = await response.text();
let data: (TokenResponse & { error?: string; error_description?: string }) | null = null;
if (responseText) {
try {
data = JSON.parse(responseText) as TokenResponse & { error?: string; error_description?: string };
} catch {
data = null;
}
}
const error = data?.error;
const errorDescription = data?.error_description;
if (!response.ok) {
if (error && (await handleTokenError(error, errorDescription))) continue;
throw new Error(`Token request failed: ${response.status} ${response.statusText}. Response: ${responseText}`);
}
if (data?.access_token) return data;
if (error && (await handleTokenError(error, errorDescription))) continue;
throw new Error("Token request failed: missing access token in response");
}
throw new Error("Authentication timed out. Please try again.");
}
async function loginQwen(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const { deviceCode, verifier } = await startDeviceFlow();
const authUrl = deviceCode.verification_uri_complete || deviceCode.verification_uri;
const instructions = deviceCode.verification_uri_complete
? undefined
: `Enter code: ${deviceCode.user_code}`;
callbacks.onAuth({ url: authUrl, instructions });
const tokenResponse = await pollForToken(
deviceCode.device_code,
verifier,
deviceCode.interval,
deviceCode.expires_in,
callbacks.signal,
);
const expiresAt = Date.now() + tokenResponse.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: tokenResponse.refresh_token || "",
access: tokenResponse.access_token,
expires: expiresAt,
enterpriseUrl: tokenResponse.resource_url,
};
}
async function refreshQwenToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: credentials.refresh,
client_id: QWEN_CLIENT_ID,
});
const response = await fetch(QWEN_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${text}`);
}
const data = (await response.json()) as TokenResponse;
if (!data.access_token) throw new Error("Token refresh failed: no access token in response");
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token || credentials.refresh,
access: data.access_token,
expires: expiresAt,
enterpriseUrl: data.resource_url ?? credentials.enterpriseUrl,
};
}
function getQwenBaseUrl(resourceUrl?: string): string {
if (!resourceUrl) return QWEN_DEFAULT_BASE_URL;
let url = resourceUrl.startsWith("http") ? resourceUrl : `https://${resourceUrl}`;
if (!url.endsWith("/v1")) url = `${url}/v1`;
return url;
}
// =============================================================================
// Extension Entry Point
// =============================================================================
export default function (pi: ExtensionAPI) {
pi.registerProvider("qwen-cli", {
baseUrl: QWEN_DEFAULT_BASE_URL,
apiKey: "QWEN_CLI_API_KEY",
api: "openai-completions",
models: [
{
id: "qwen3.5-plus",
name: "Qwen 3.5 Plus (Best — rivals Qwen3-Max)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen3.5-flash",
name: "Qwen 3.5 Flash (Fast & Cheap)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen3-max",
name: "Qwen 3 Max (Flagship, strongest reasoning)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen-plus",
name: "Qwen 3 Plus (Balanced)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 32768,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen-flash",
name: "Qwen 3 Flash (Fast)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 32768,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
],
oauth: {
name: "Qwen CLI",
login: loginQwen,
refreshToken: refreshQwenToken,
getApiKey: (cred) => cred.access,
modifyModels: (models, cred) => {
const baseUrl = getQwenBaseUrl(cred.enterpriseUrl as string | undefined);
return models.map((m) => (m.provider === "qwen-cli" ? { ...m, baseUrl } : m));
},
},
});
}

View File

@@ -55,6 +55,8 @@ export interface UsageData {
/** Unix ms timestamp of when the session window resets (from the raw API response). */
sessionResetsAt?: number;
weeklyResetsIn?: string;
/** Unix ms timestamp of when the weekly window resets. */
weeklyResetsAt?: number;
extraSpend?: number;
extraLimit?: number;
error?: string;
@@ -321,13 +323,34 @@ export function formatResetsAt(isoDate: string, nowMs = Date.now()): string {
return formatDuration(diffSeconds);
}
const CLAUDE_CREDENTIALS_FILE = path.join(os.homedir(), ".claude", ".credentials.json");
export function readAuth(authFile = DEFAULT_AUTH_FILE): AuthData | null {
let result: AuthData | null = null;
// Read pi auth.json for non-Claude providers
try {
const parsed = JSON.parse(fs.readFileSync(authFile, "utf-8"));
return asObject(parsed) as AuthData;
result = asObject(parsed) as AuthData;
} catch {
return null;
result = {} as AuthData;
}
// Read Claude credentials from ~/.claude/.credentials.json
try {
const claudeRaw = fs.readFileSync(CLAUDE_CREDENTIALS_FILE, "utf-8");
const claudeCreds = JSON.parse(claudeRaw);
const oauth = claudeCreds?.claudeAiOauth;
if (oauth?.accessToken) {
result!.anthropic = {
access: oauth.accessToken,
refresh: oauth.refreshToken,
expires: typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined,
};
}
} catch {}
return result;
}
export function writeAuth(auth: AuthData, authFile = DEFAULT_AUTH_FILE): boolean {
@@ -647,6 +670,9 @@ export async function fetchClaudeUsage(token: string, config: RequestConfig = {}
const sessionResetsAt = data?.five_hour?.resets_at
? new Date(data.five_hour.resets_at).getTime()
: undefined;
const weeklyResetsAt = data?.seven_day?.resets_at
? new Date(data.seven_day.resets_at).getTime()
: undefined;
const usage: UsageData = {
session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
@@ -654,6 +680,7 @@ export async function fetchClaudeUsage(token: string, config: RequestConfig = {}
sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined,
sessionResetsAt: Number.isFinite(sessionResetsAt) ? sessionResetsAt : undefined,
weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : undefined,
weeklyResetsAt: Number.isFinite(weeklyResetsAt) ? weeklyResetsAt : undefined,
};
if (data?.extra_usage?.is_enabled) {

View File

@@ -306,14 +306,27 @@ export default function (pi: ExtensionAPI) {
const active = state.activeProvider;
const data = active ? state[active] : null;
// Always emit event for other extensions (e.g. footer-display)
if (data && !data.error) {
// Always emit Claude usage for other extensions (e.g. footer-display)
// so S/W bars are visible regardless of active model.
const claudeData = state.claude;
if (claudeData && !claudeData.error) {
pi.events.emit("usage:update", {
session: claudeData.session,
weekly: claudeData.weekly,
sessionResetsIn: claudeData.sessionResetsIn,
sessionResetsAt: claudeData.sessionResetsAt,
weeklyResetsIn: claudeData.weeklyResetsIn,
weeklyResetsAt: claudeData.weeklyResetsAt,
});
} else if (data && !data.error) {
// Fallback to active provider data if Claude data unavailable
pi.events.emit("usage:update", {
session: data.session,
weekly: data.weekly,
sessionResetsIn: data.sessionResetsIn,
sessionResetsAt: data.sessionResetsAt,
weeklyResetsIn: data.weeklyResetsIn,
weeklyResetsAt: data.weeklyResetsAt,
});
}
@@ -372,6 +385,48 @@ export default function (pi: ExtensionAPI) {
const auth = readAuth();
const active = state.activeProvider;
// Always try to fetch Claude data so S/W bars show regardless of active provider
if (auth && canShowForProvider("claude", auth, endpoints)) {
try {
const cache = readUsageCache();
const now = Date.now();
const cacheTtl = options.cacheTtl ?? CACHE_TTL_MS;
const claudeBlockedUntil = cache?.rateLimitedUntil?.claude ?? 0;
if (now < claudeBlockedUntil) {
if (cache?.data?.claude) state.claude = cache.data.claude;
} else {
const claudeCacheFresh = cache && now - cache.timestamp < cacheTtl && cache.data?.claude;
if (claudeCacheFresh && !options.forceFresh) {
state.claude = cache.data.claude;
} else {
const claudeAccess = auth.anthropic?.access;
if (claudeAccess) {
const claudeResult = await fetchClaudeUsage(claudeAccess);
state.claude = claudeResult;
if (!claudeResult.error) {
const nextCache: import("./core").UsageCache = {
timestamp: now,
data: { ...(cache?.data ?? {}), claude: claudeResult },
rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}) },
};
delete nextCache.rateLimitedUntil!.claude;
writeUsageCache(nextCache);
} else if (claudeResult.error === "HTTP 429") {
// Record backoff even when Claude is not the active provider —
// without this the prefetch would hammer the API on every poll.
const nextCache: import("./core").UsageCache = {
timestamp: cache?.timestamp ?? now,
data: { ...(cache?.data ?? {}) },
rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}), claude: now + RATE_LIMITED_BACKOFF_MS },
};
writeUsageCache(nextCache);
}
}
}
}
} catch {}
}
if (!canShowForProvider(active, auth, endpoints) || !auth || !active) {
state.lastPoll = Date.now(); updateStatus(); return;
}
@@ -478,7 +533,9 @@ export default function (pi: ExtensionAPI) {
await Promise.race([runPollInner(options), timeout]);
}
const POLL_TIMEOUT_MS = 30_000;
// Must be less than the 25 000 ms timeout inside runPoll so the guard fires
// before runPoll's finally-block clears pollInFlight.
const POLL_TIMEOUT_MS = 20_000;
async function poll(options: PollOptions = {}) {
// If a previous poll has been running longer than POLL_TIMEOUT_MS, abandon it
@@ -557,15 +614,6 @@ export default function (pi: ExtensionAPI) {
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 });
});
// Listen for OpenCode Go spend events from other extensions
pi.events.on("opencode-go:spend", async (amount: number) => {

View File

@@ -31,7 +31,7 @@
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, unlinkSync, watch, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -101,6 +101,7 @@ local mock_wezterm = {
log_warn = function() end,
log_error = function() end,
on = function() end,
add_to_config_reload_watch_list = function() end,
action = setmetatable({}, {
__index = function(_, k)
return function(...) return { action = k, args = {...} } end
@@ -397,48 +398,64 @@ function cleanupOldThemes(themesDir: string, keepFile: string): void {
}
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
const configDir = findConfigDir();
if (!configDir) {
return;
}
function syncTheme(ctx: any) {
const configDir = findConfigDir();
if (!configDir) return;
const lua = findLua();
if (!lua) {
return;
}
const lua = findLua();
if (!lua) return;
const colors = getWeztermColors(configDir, lua);
if (!colors) {
return;
}
const colors = getWeztermColors(configDir, lua);
if (!colors) return;
const themesDir = join(homedir(), ".pi", "agent", "themes");
if (!existsSync(themesDir)) {
mkdirSync(themesDir, { recursive: true });
}
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);
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;
}
// 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));
const themeJson = generatePiTheme(colors, themeName);
writeFileSync(themePath, JSON.stringify(themeJson, null, 2));
// Remove old generated themes
cleanupOldThemes(themesDir, themeFile);
// 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");
}
});
// 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");
}
}
export default function (pi: ExtensionAPI) {
let currentCtx: any = null;
pi.on("session_start", async (_event, ctx) => {
currentCtx = ctx;
syncTheme(ctx);
});
// Watch theme-state file for dark/light toggle changes
const stateFile = join(homedir(), ".config", "theme-state");
try {
watch(stateFile, { persistent: false }, (_event) => {
// Debounce: wait a tick for the file write to complete
setTimeout(() => {
if (currentCtx) {
syncTheme(currentCtx);
}
}, 100);
});
} catch {
// File may not exist yet — non-fatal
}
}

View File

@@ -1,5 +1,25 @@
{
"providers": {
"llama-serve": {
"baseUrl": "http://localhost:8080/v1",
"api": "openai-completions",
"apiKey": "dummy",
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false
},
"models": [
{
"id": "Qwen/Qwen3-9B",
"name": "Qwen3.5-9B (Unsloth)",
"reasoning": true,
"input": ["text"],
"contextWindow": 131072,
"maxTokens": 8192,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
}
]
},
"opencode-go": {
"baseUrl": "https://opencode.ai/zen/go/v1",
"api": "openai-completions",

755
pi/.pi/agent/pi-crash.log Normal file
View File

@@ -0,0 +1,755 @@
Crash at 2026-04-24T12:06:17.955Z
Terminal width: 114
Line 451 visible width: 121
=== All rendered lines ===
[0] (w=0) ]8;;
[1] (w=114) pi v0.67.68 ]8;;
[2] (w=114) escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more ]8;;
[3] (w=114) Press ctrl+o to show full startup help and loaded resources. ]8;;
[4] (w=114) ]8;;
[5] (w=114) Pi can explain its own features and look up its docs. Ask it how to use or extend Pi. ]8;;
[6] (w=0) ]8;;
[7] (w=114) [Skills] ]8;;
[8] (w=114)  add-agent, ask-claude, homeassistant-ev, implementor, local-scout, opty, qmd, subagent-implement, subagent-plan,]8;;
[9] (w=114) subagent-review ]8;;
[10] (w=0) ]8;;
[11] (w=114) [Prompts] ]8;;
[12] (w=114)  /implement, /implement-critical, /plan, /review ]8;;
[13] (w=0) ]8;;
[14] (w=114) [Extensions] ]8;;
[15] (w=114)  @aliou/pi-guardrails:src, @benvargas/pi-exa-mcp, ask-claude.ts, chat-claude.ts, footer-display.ts, ]8;;
[16] (w=114) git-checkout-guard.ts, new-with-context.ts, pi, pi-ask-tool/index.ts, pi-claude-bridge, pi-subagents, ]8;;
[17] (w=114) pi-subagents:notify.ts, postpone.ts, usage-bars/index.ts, wezterm-theme-sync/index.ts, worktree.ts ]8;;
[18] (w=0) ]8;;
[19] (w=114) [Themes] ]8;;
[20] (w=114)  wezterm-sync-9a35138e ]8;;
[21] (w=0) ]8;;
[22] (w=0) ]8;;
[23] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;;
[24] (w=114) Update Available ]8;;
[25] (w=114) New version 0.70.0 is available. Run: npm install -g @mariozechner/pi-coding-agent ]8;;
[26] (w=114) Changelog: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md ]8;;
[27] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;;
[28] (w=0) ]8;;
[29] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;;
[30] (w=114) Package Updates Available ]8;;
[31] (w=114) Package updates are available. Run pi update ]8;;
[32] (w=114) Packages: ]8;;
[33] (w=114) - pi-subagents ]8;;
[34] (w=114) - @aliou/pi-guardrails ]8;;
[35] (w=114) - pi-claude-bridge ]8;;
[36] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;;
[37] (w=0) ]8;;
[38] (w=114) Entered chat mode: Claude Opus · resume c9492aa2 ]8;;
[39] (w=0) ]8;;
[40] (w=114) ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;;
[41] (w=114) │ ▶ you │]8;;
[42] (w=114) │ │]8;;
[43] (w=114) │ @agent/extensions/chat-claude.ts truncate all read outputs to max 40 lines. Last line, if truncated, should │]8;;
[44] (w=114) │ be something like '+N lines more lines' or better wording. Last line should be centered │]8;;
[45] (w=114) │ │]8;;
[46] (w=114) │ ◆ Claude Opus session:c9492aa2 │]8;;
[47] (w=114) │ │]8;;
[48] (w=114) │  $ find /home/jonas/dotfiles/pi/.pi/agent -type d  │]8;;
[49] (w=114) │  <persisted-output>  │]8;;
[50] (w=114) │  Output too large (189KB). Full output saved to:  │]8;;
[51] (w=114) │  /home/jonas/.claude/projects/-home-jonas-dotfiles-pi--pi/c9492aa2-34ca-4d36-a541-83964a2ce1d8/tool-results  │]8;;
[52] (w=114) │  /bh1bg7uzg.txt  │]8;;
[53] (w=114) │   │]8;;
[54] (w=114) │  Preview (first 2KB):  │]8;;
[55] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent  │]8;;
[56] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions  │]8;;
[57] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas--  │]8;;
[58] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--  │]8;;
[59] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/subagent-artifacts  │]8;;
[60] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;;
[61] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197  │]8;;
[62] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;;
[63] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/b7684b6a  │]8;;
[64] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;;
[65] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/b7684b6a/run-0  │]8;;
[66] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;;
[67] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/95023efc  │]8;;
[68] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;;
[69] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/95023efc/run-0  │]8;;
[70] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;;
[71] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/5be564de  │]8;;
[72] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;;
[73] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/5be564de/run-0  │]8;;
[74] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T11-01-06-232Z  │]8;;
[75] (w=114) │  _cae41525-1f66-4dbd-a0da-7c12c770aa76  │]8;;
[76] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T11-01-06-232Z  │]8;;
[77] (w=114) │  _cae41525-1f66-4dbd-a0da-7c12c770aa76/294d75f5  │]8;;
[78] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T11-01-06-232Z  │]8;;
[79] (w=114) │  _cae41525-1f66-4dbd-a0da-7c12c770aa76/294d75f5/run-0  │]8;;
[80] (w=114) │  ...  │]8;;
[81] (w=114) │  </persisted-output>  │]8;;
[82] (w=114) │ │]8;;
[83] (w=114) │  $ ls /home/jonas/dotfiles/pi/.pi/agent/extensions/ /home/jonas/dotfiles/pi/.pi/agent/shared/ 2>/dev/null  │]8;;
[84] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/extensions/:  │]8;;
[85] (w=114) │  ask-claude.ts  │]8;;
[86] (w=114) │  chat-claude.ts  │]8;;
[87] (w=114) │  exa-mcp.json  │]8;;
[88] (w=114) │  footer-display.ts  │]8;;
[89] (w=114) │  git-checkout-guard.ts  │]8;;
[90] (w=114) │  new-with-context.ts  │]8;;
[91] (w=114) │  pi-ask-mcp  │]8;;
[92] (w=114) │  pi-ask-tool  │]8;;
[93] (w=114) │  postpone.ts  │]8;;
[94] (w=114) │  usage-bars  │]8;;
[95] (w=114) │  wezterm-theme-sync  │]8;;
[96] (w=114) │  worktree.ts  │]8;;
[97] (w=114) │   │]8;;
[98] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/shared/:  │]8;;
[99] (w=114) │  claude-stream.ts  │]8;;
[100] (w=114) │  pi-ask-bridge.ts  │]8;;
[101] (w=114) │ │]8;;
[102] (w=114) │  read ~/dotfiles/pi/.pi/agent/shared/claude-stream.ts  │]8;;
[103] (w=114) │   1 /**  │]8;;
[104] (w=114) │   2 * claude-stream — Shared types, rendering, and core spawn/stream logic  │]8;;
[105] (w=114) │   3 * for ask-claude and chat-claude pi extensions.  │]8;;
[106] (w=114) │   4 *  │]8;;
[107] (w=114) │   5 * Both extensions spawn `claude -p --output-format stream-json` and parse  │]8;;
[108] (w=114) │   6 * the same streaming protocol. This module provides:  │]8;;
[109] (w=114) │   7 * - Block types (ThinkingBlock, ToolBlock, TextBlock)  │]8;;
[110] (w=114) │   8 * - Rendering helpers (tool call lines, result boxes, usage formatting)  │]8;;
[111] (w=114) │   9 * - runClaude() — the core spawn + stream parser  │]8;;
[112] (w=114) │   10 */  │]8;;
[113] (w=114) │   11  │]8;;
[114] (w=114) │   12 import { spawn } from "node:child_process";  │]8;;
[115] (w=114) │   13 import { readFileSync } from "node:fs";  │]8;;
[116] (w=114) │   14 import { diffLines } from "diff";  │]8;;
[117] (w=114) │   15 import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";  │]8;;
[118] (w=114) │   16 import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";  │]8;;
[119] (w=114) │   17  │]8;;
[120] (w=114) │   18 // =============================================================================  │]8;;
[121] (w=114) │   19 // Block types  │]8;;
[122] (w=114) │   20 // =============================================================================  │]8;;
[123] (w=114) │   21  │]8;;
[124] (w=114) │   22 export interface ThinkingBlock {  │]8;;
[125] (w=114) │   23 type: "thinking";  │]8;;
[126] (w=114) │   24 text: string;  │]8;;
[127] (w=114) │   25 }  │]8;;
[128] (w=114) │   26  │]8;;
[129] (w=114) │   27 export interface ToolBlock {  │]8;;
[130] (w=114) │   28 type: "tool";  │]8;;
[131] (w=114) │   29 id: string;  │]8;;
[132] (w=114) │   30 name: string;  │]8;;
[133] (w=114) │   31 inputJson: string;  │]8;;
[134] (w=114) │   32 editContext?: { before: string[]; after: string[]; startLine: number };  │]8;;
[135] (w=114) │   33 result?: { text: string; isError: boolean };  │]8;;
[136] (w=114) │   34 }  │]8;;
[137] (w=114) │   35  │]8;;
[138] (w=114) │   36 export interface TextBlock {  │]8;;
[139] (w=114) │   37 type: "text";  │]8;;
[140] (w=114) │   38 text: string;  │]8;;
[141] (w=114) │   39 }  │]8;;
[142] (w=114) │   40  │]8;;
[143] (w=114) │  … 726 more lines hidden …  │]8;;
[144] (w=114) │ │]8;;
[145] (w=114) │  $ find /home/jonas/dotfiles/pi/.pi/agent -name "node_modules" -prune -o -name "package.json" -print  │]8;;
[146] (w=114) │  2>/dev/null | head -5  │]8;;
[147] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/extensions/pi-ask-mcp/package.json  │]8;;
[148] (w=114) │ │]8;;
[149] (w=114) │  $ find /home/jonas -path "*/node_modules/@mariozechner/pi-tui*" -name "*.d.ts" 2>/dev/null | head -10  │]8;;
[150] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/box.d.ts  │]8;;
[151] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.d.ts  │]8;;
[152] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts  │]8;;
[153] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts  │]8;;
[154] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts  │]8;;
[155] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts  │]8;;
[156] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts  │]8;;
[157] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts  │]8;;
[158] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts  │]8;;
[159] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/spacer.d.ts  │]8;;
[160] (w=114) │ │]8;;
[161] (w=114) │  $ find /home/jonas/node_modules/@mariozechner/pi-tui/dist/components -name "text*"  │]8;;
[162] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/text.js  │]8;;
[163] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/text.d.ts.map  │]8;;
[164] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/text.js.map  │]8;;
[165] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/text.d.ts  │]8;;
[166] (w=114) │ │]8;;
[167] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/components/text.d.ts  │]8;;
[168] (w=114) │   1 import type { Component } from "../tui.js";  │]8;;
[169] (w=114) │   2 /**  │]8;;
[170] (w=114) │   3 * Text component - displays multi-line text with word wrapping  │]8;;
[171] (w=114) │   4 */  │]8;;
[172] (w=114) │   5 export declare class Text implements Component {  │]8;;
[173] (w=114) │   6 private text;  │]8;;
[174] (w=114) │   7 private paddingX;  │]8;;
[175] (w=114) │   8 private paddingY;  │]8;;
[176] (w=114) │   9 private customBgFn?;  │]8;;
[177] (w=114) │  10 private cachedText?;  │]8;;
[178] (w=114) │  11 private cachedWidth?;  │]8;;
[179] (w=114) │  12 private cachedLines?;  │]8;;
[180] (w=114) │  13 constructor(text?: string, paddingX?: number, paddingY?: number, customBgFn?: (text: string) =>  │]8;;
[181] (w=114) │  string);  │]8;;
[182] (w=114) │  14 setText(text: string): void;  │]8;;
[183] (w=114) │  15 setCustomBgFn(customBgFn?: (text: string) => string): void;  │]8;;
[184] (w=114) │  16 invalidate(): void;  │]8;;
[185] (w=114) │  17 render(width: number): string[];  │]8;;
[186] (w=114) │  18 }  │]8;;
[187] (w=114) │  19 //# sourceMappingURL=text.d.ts.map  │]8;;
[188] (w=114) │ │]8;;
[189] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/components/text.js  │]8;;
[190] (w=114) │   1 import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";  │]8;;
[191] (w=114) │   2 /**  │]8;;
[192] (w=114) │   3 * Text component - displays multi-line text with word wrapping  │]8;;
[193] (w=114) │   4 */  │]8;;
[194] (w=114) │   5 export class Text {  │]8;;
[195] (w=114) │   6 text;  │]8;;
[196] (w=114) │   7 paddingX; // Left/right padding  │]8;;
[197] (w=114) │   8 paddingY; // Top/bottom padding  │]8;;
[198] (w=114) │   9 customBgFn;  │]8;;
[199] (w=114) │  10 // Cache for rendered output  │]8;;
[200] (w=114) │  11 cachedText;  │]8;;
[201] (w=114) │  12 cachedWidth;  │]8;;
[202] (w=114) │  13 cachedLines;  │]8;;
[203] (w=114) │  14 constructor(text = "", paddingX = 1, paddingY = 1, customBgFn) {  │]8;;
[204] (w=114) │  15 this.text = text;  │]8;;
[205] (w=114) │  16 this.paddingX = paddingX;  │]8;;
[206] (w=114) │  17 this.paddingY = paddingY;  │]8;;
[207] (w=114) │  18 this.customBgFn = customBgFn;  │]8;;
[208] (w=114) │  19 }  │]8;;
[209] (w=114) │  20 setText(text) {  │]8;;
[210] (w=114) │  21 this.text = text;  │]8;;
[211] (w=114) │  22 this.cachedText = undefined;  │]8;;
[212] (w=114) │  23 this.cachedWidth = undefined;  │]8;;
[213] (w=114) │  24 this.cachedLines = undefined;  │]8;;
[214] (w=114) │  25 }  │]8;;
[215] (w=114) │  26 setCustomBgFn(customBgFn) {  │]8;;
[216] (w=114) │  27 this.customBgFn = customBgFn;  │]8;;
[217] (w=114) │  28 this.cachedText = undefined;  │]8;;
[218] (w=114) │  29 this.cachedWidth = undefined;  │]8;;
[219] (w=114) │  30 this.cachedLines = undefined;  │]8;;
[220] (w=114) │  31 }  │]8;;
[221] (w=114) │  32 invalidate() {  │]8;;
[222] (w=114) │  33 this.cachedText = undefined;  │]8;;
[223] (w=114) │  34 this.cachedWidth = undefined;  │]8;;
[224] (w=114) │  35 this.cachedLines = undefined;  │]8;;
[225] (w=114) │  36 }  │]8;;
[226] (w=114) │  37 render(width) {  │]8;;
[227] (w=114) │  38 // Check cache  │]8;;
[228] (w=114) │  39 if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {  │]8;;
[229] (w=114) │  40 return this.cachedLines;  │]8;;
[230] (w=114) │  … 49 more lines hidden …  │]8;;
[231] (w=114) │ │]8;;
[232] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/components/box.js  │]8;;
[233] (w=114) │   1 import { applyBackgroundToLine, visibleWidth } from "../utils.js";  │]8;;
[234] (w=114) │   2 /**  │]8;;
[235] (w=114) │   3 * Box component - a container that applies padding and background to all children  │]8;;
[236] (w=114) │   4 */  │]8;;
[237] (w=114) │   5 export class Box {  │]8;;
[238] (w=114) │   6 children = [];  │]8;;
[239] (w=114) │   7 paddingX;  │]8;;
[240] (w=114) │   8 paddingY;  │]8;;
[241] (w=114) │   9 bgFn;  │]8;;
[242] (w=114) │   10 // Cache for rendered output  │]8;;
[243] (w=114) │   11 cache;  │]8;;
[244] (w=114) │   12 constructor(paddingX = 1, paddingY = 1, bgFn) {  │]8;;
[245] (w=114) │   13 this.paddingX = paddingX;  │]8;;
[246] (w=114) │   14 this.paddingY = paddingY;  │]8;;
[247] (w=114) │   15 this.bgFn = bgFn;  │]8;;
[248] (w=114) │   16 }  │]8;;
[249] (w=114) │   17 addChild(component) {  │]8;;
[250] (w=114) │   18 this.children.push(component);  │]8;;
[251] (w=114) │   19 this.invalidateCache();  │]8;;
[252] (w=114) │   20 }  │]8;;
[253] (w=114) │   21 removeChild(component) {  │]8;;
[254] (w=114) │   22 const index = this.children.indexOf(component);  │]8;;
[255] (w=114) │   23 if (index !== -1) {  │]8;;
[256] (w=114) │   24 this.children.splice(index, 1);  │]8;;
[257] (w=114) │   25 this.invalidateCache();  │]8;;
[258] (w=114) │   26 }  │]8;;
[259] (w=114) │   27 }  │]8;;
[260] (w=114) │   28 clear() {  │]8;;
[261] (w=114) │   29 this.children = [];  │]8;;
[262] (w=114) │   30 this.invalidateCache();  │]8;;
[263] (w=114) │   31 }  │]8;;
[264] (w=114) │   32 setBgFn(bgFn) {  │]8;;
[265] (w=114) │   33 this.bgFn = bgFn;  │]8;;
[266] (w=114) │   34 // Don't invalidate here - we'll detect bgFn changes by sampling output  │]8;;
[267] (w=114) │   35 }  │]8;;
[268] (w=114) │   36 invalidateCache() {  │]8;;
[269] (w=114) │   37 this.cache = undefined;  │]8;;
[270] (w=114) │   38 }  │]8;;
[271] (w=114) │   39 matchCache(width, childLines, bgSample) {  │]8;;
[272] (w=114) │   40 const cache = this.cache;  │]8;;
[273] (w=114) │  … 64 more lines hidden …  │]8;;
[274] (w=114) │ │]8;;
[275] (w=114) │  $ cat /home/jonas/node_modules/@mariozechner/pi-tui/dist/index.d.ts 2>/dev/null | head -50  │]8;;
[276] (w=114) │  export { type AutocompleteItem, type AutocompleteProvider, CombinedAutocompleteProvider, type  │]8;;
[277] (w=114) │  SlashCommand, } from "./autocomplete.js";  │]8;;
[278] (w=114) │  export { Box } from "./components/box.js";  │]8;;
[279] (w=114) │  export { CancellableLoader } from "./components/cancellable-loader.js";  │]8;;
[280] (w=114) │  export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.js";  │]8;;
[281] (w=114) │  export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";  │]8;;
[282] (w=114) │  export { Input } from "./components/input.js";  │]8;;
[283] (w=114) │  export { Loader } from "./components/loader.js";  │]8;;
[284] (w=114) │  export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";  │]8;;
[285] (w=114) │  export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list.js";  │]8;;
[286] (w=114) │  export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.js";  │]8;;
[287] (w=114) │  export { Spacer } from "./components/spacer.js";  │]8;;
[288] (w=114) │  export { Text } from "./components/text.js";  │]8;;
[289] (w=114) │  export { TruncatedText } from "./components/truncated-text.js";  │]8;;
[290] (w=114) │  export type { EditorComponent } from "./editor-component.js";  │]8;;
[291] (w=114) │  export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";  │]8;;
[292] (w=114) │  export { DEFAULT_EDITOR_KEYBINDINGS, type EditorAction, type EditorKeybindingsConfig,  │]8;;
[293] (w=114) │  EditorKeybindingsManager, getEditorKeybindings, setEditorKeybindings, } from "./keybindings.js";  │]8;;
[294] (w=114) │  export { isKeyRelease, isKeyRepeat, isKittyProtocolActive, Key, type KeyEventType, type KeyId, matchesKey,  │]8;;
[295] (w=114) │  parseKey, setKittyProtocolActive, } from "./keys.js";  │]8;;
[296] (w=114) │  export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.js";  │]8;;
[297] (w=114) │  export { ProcessTerminal, type Terminal } from "./terminal.js";  │]8;;
[298] (w=114) │  export { allocateImageId, type CellDimensions, calculateImageRows, deleteAllKittyImages, deleteKittyImage,  │]8;;
[299] (w=114) │  detectCapabilities, encodeITerm2, encodeKitty, getCapabilities, getCellDimensions, getGifDimensions,  │]8;;
[300] (w=114) │  getImageDimensions, getJpegDimensions, getPngDimensions, getWebpDimensions, type ImageDimensions, type  │]8;;
[301] (w=114) │  ImageProtocol, type ImageRenderOptions, imageFallback, renderImage, resetCapabilitiesCache,  │]8;;
[302] (w=114) │  setCellDimensions, type TerminalCapabilities, } from "./terminal-image.js";  │]8;;
[303] (w=114) │  export { type Component, Container, CURSOR_MARKER, type Focusable, isFocusable, type OverlayAnchor, type  │]8;;
[304] (w=114) │  OverlayHandle, type OverlayMargin, type OverlayOptions, type SizeValue, TUI, } from "./tui.js";  │]8;;
[305] (w=114) │  export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.js";  │]8;;
[306] (w=114) │  //# sourceMappingURL=index.d.ts.map  │]8;;
[307] (w=114) │ │]8;;
[308] (w=114) │  $ cat /home/jonas/node_modules/@mariozechner/pi-tui/dist/tui.d.ts | head -100  │]8;;
[309] (w=114) │  /**  │]8;;
[310] (w=114) │  * Minimal TUI implementation with differential rendering  │]8;;
[311] (w=114) │  */  │]8;;
[312] (w=114) │  import type { Terminal } from "./terminal.js";  │]8;;
[313] (w=114) │  import { visibleWidth } from "./utils.js";  │]8;;
[314] (w=114) │  /**  │]8;;
[315] (w=114) │  * Component interface - all components must implement this  │]8;;
[316] (w=114) │  */  │]8;;
[317] (w=114) │  export interface Component {  │]8;;
[318] (w=114) │  /**  │]8;;
[319] (w=114) │  * Render the component to lines for the given viewport width  │]8;;
[320] (w=114) │  * @param width - Current viewport width  │]8;;
[321] (w=114) │  * @returns Array of strings, each representing a line  │]8;;
[322] (w=114) │  */  │]8;;
[323] (w=114) │  render(width: number): string[];  │]8;;
[324] (w=114) │  /**  │]8;;
[325] (w=114) │  * Optional handler for keyboard input when component has focus  │]8;;
[326] (w=114) │  */  │]8;;
[327] (w=114) │  handleInput?(data: string): void;  │]8;;
[328] (w=114) │  /**  │]8;;
[329] (w=114) │  * If true, component receives key release events (Kitty protocol).  │]8;;
[330] (w=114) │  * Default is false - release events are filtered out.  │]8;;
[331] (w=114) │  */  │]8;;
[332] (w=114) │  wantsKeyRelease?: boolean;  │]8;;
[333] (w=114) │  /**  │]8;;
[334] (w=114) │  * Invalidate any cached rendering state.  │]8;;
[335] (w=114) │  * Called when theme changes or when component needs to re-render from scratch.  │]8;;
[336] (w=114) │  */  │]8;;
[337] (w=114) │  invalidate(): void;  │]8;;
[338] (w=114) │  }  │]8;;
[339] (w=114) │  /**  │]8;;
[340] (w=114) │  * Interface for components that can receive focus and display a hardware cursor.  │]8;;
[341] (w=114) │  * When focused, the component should emit CURSOR_MARKER at the cursor position  │]8;;
[342] (w=114) │  * in its render output. TUI will find this marker and position the hardware  │]8;;
[343] (w=114) │  * cursor there for proper IME candidate window positioning.  │]8;;
[344] (w=114) │  */  │]8;;
[345] (w=114) │  export interface Focusable {  │]8;;
[346] (w=114) │  /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */  │]8;;
[347] (w=114) │  focused: boolean;  │]8;;
[348] (w=114) │  }  │]8;;
[349] (w=114) │  /** Type guard to check if a component implements Focusable */  │]8;;
[350] (w=114) │  export declare function isFocusable(component: Component | null): component is Component & Focusable;  │]8;;
[351] (w=114) │  /**  │]8;;
[352] (w=114) │  * Cursor position marker - APC (Application Program Command) sequence.  │]8;;
[353] (w=114) │  * This is a zero-width escape sequence that terminals ignore.  │]8;;
[354] (w=114) │  * Components emit this at the cursor position when focused.  │]8;;
[355] (w=114) │  * TUI finds and strips this marker, then positions the hardware cursor there.  │]8;;
[356] (w=114) │  */  │]8;;
[357] (w=114) │  export declare const CURSOR_MARKER = "\u001B_pi:c\u0007";  │]8;;
[358] (w=114) │  export { visibleWidth };  │]8;;
[359] (w=114) │  /**  │]8;;
[360] (w=114) │  * Anchor position for overlays  │]8;;
[361] (w=114) │  */  │]8;;
[362] (w=114) │  export type OverlayAnchor = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" |  │]8;;
[363] (w=114) │  "top-center" | "bottom-center" | "left-center" | "right-center";  │]8;;
[364] (w=114) │  /**  │]8;;
[365] (w=114) │  * Margin configuration for overlays  │]8;;
[366] (w=114) │  */  │]8;;
[367] (w=114) │  export interface OverlayMargin {  │]8;;
[368] (w=114) │  top?: number;  │]8;;
[369] (w=114) │  right?: number;  │]8;;
[370] (w=114) │  bottom?: number;  │]8;;
[371] (w=114) │  left?: number;  │]8;;
[372] (w=114) │  }  │]8;;
[373] (w=114) │  /** Value that can be absolute (number) or percentage (string like "50%") */  │]8;;
[374] (w=114) │  export type SizeValue = number | `${number}%`;  │]8;;
[375] (w=114) │  /**  │]8;;
[376] (w=114) │  * Options for overlay positioning and sizing.  │]8;;
[377] (w=114) │  * Values can be absolute numbers or percentage strings (e.g., "50%").  │]8;;
[378] (w=114) │  */  │]8;;
[379] (w=114) │  export interface OverlayOptions {  │]8;;
[380] (w=114) │  /** Width in columns, or percentage of terminal width (e.g., "50%") */  │]8;;
[381] (w=114) │  width?: SizeValue;  │]8;;
[382] (w=114) │  /** Minimum width in columns */  │]8;;
[383] (w=114) │  minWidth?: number;  │]8;;
[384] (w=114) │  /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */  │]8;;
[385] (w=114) │  maxHeight?: SizeValue;  │]8;;
[386] (w=114) │  /** Anchor point for positioning (default: 'center') */  │]8;;
[387] (w=114) │  anchor?: OverlayAnchor;  │]8;;
[388] (w=114) │  /** Horizontal offset from anchor position (positive = right) */  │]8;;
[389] (w=114) │  offsetX?: number;  │]8;;
[390] (w=114) │  /** Vertical offset from anchor position (positive = down) */  │]8;;
[391] (w=114) │  offsetY?: number;  │]8;;
[392] (w=114) │  /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */  │]8;;
[393] (w=114) │  row?: SizeValue;  │]8;;
[394] (w=114) │  /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */  │]8;;
[395] (w=114) │  col?: SizeValue;  │]8;;
[396] (w=114) │  /** Margin from terminal edges. Number applies to all sides. */  │]8;;
[397] (w=114) │  margin?: OverlayMargin | number;  │]8;;
[398] (w=114) │  /**  │]8;;
[399] (w=114) │  * Control overlay visibility based on terminal dimensions.  │]8;;
[400] (w=114) │  * If provided, overlay is only rendered when this returns true.  │]8;;
[401] (w=114) │  * Called each render cycle with current terminal dimensions.  │]8;;
[402] (w=114) │  */  │]8;;
[403] (w=114) │  visible?: (termWidth: number, termHeight: number) => boolean;  │]8;;
[404] (w=114) │  }  │]8;;
[405] (w=114) │  /**  │]8;;
[406] (w=114) │  * Handle returned by showOverlay for controlling the overlay  │]8;;
[407] (w=114) │  */  │]8;;
[408] (w=114) │  export interface OverlayHandle {  │]8;;
[409] (w=114) │  /** Permanently remove the overlay (cannot be shown again) */  │]8;;
[410] (w=114) │ │]8;;
[411] (w=114) │  $ grep -n "class Container" /home/jonas/node_modules/@mariozechner/pi-tui/dist/tui.js | head -5  │]8;;
[412] (w=114) │  38:export class Container {  │]8;;
[413] (w=114) │ │]8;;
[414] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/tui.js:38-117  │]8;;
[415] (w=114) │   38 export class Container {  │]8;;
[416] (w=114) │   39 children = [];  │]8;;
[417] (w=114) │   40 addChild(component) {  │]8;;
[418] (w=114) │   41 this.children.push(component);  │]8;;
[419] (w=114) │   42 }  │]8;;
[420] (w=114) │   43 removeChild(component) {  │]8;;
[421] (w=114) │   44 const index = this.children.indexOf(component);  │]8;;
[422] (w=114) │   45 if (index !== -1) {  │]8;;
[423] (w=114) │   46 this.children.splice(index, 1);  │]8;;
[424] (w=114) │   47 }  │]8;;
[425] (w=114) │   48 }  │]8;;
[426] (w=114) │   49 clear() {  │]8;;
[427] (w=114) │   50 this.children = [];  │]8;;
[428] (w=114) │   51 }  │]8;;
[429] (w=114) │   52 invalidate() {  │]8;;
[430] (w=114) │   53 for (const child of this.children) {  │]8;;
[431] (w=114) │   54 child.invalidate?.();  │]8;;
[432] (w=114) │   55 }  │]8;;
[433] (w=114) │   56 }  │]8;;
[434] (w=114) │   57 render(width) {  │]8;;
[435] (w=114) │   58 const lines = [];  │]8;;
[436] (w=114) │   59 for (const child of this.children) {  │]8;;
[437] (w=114) │   60 lines.push(...child.render(width));  │]8;;
[438] (w=114) │   61 }  │]8;;
[439] (w=114) │   62 return lines;  │]8;;
[440] (w=114) │   63 }  │]8;;
[441] (w=114) │   64 }  │]8;;
[442] (w=114) │   65 /**  │]8;;
[443] (w=114) │   66 * TUI - Main class for managing terminal UI with differential rendering  │]8;;
[444] (w=114) │   67 */  │]8;;
[445] (w=114) │   68 export class TUI extends Container {  │]8;;
[446] (w=114) │   69 terminal;  │]8;;
[447] (w=114) │   70 previousLines = [];  │]8;;
[448] (w=114) │   71 previousWidth = 0;  │]8;;
[449] (w=114) │   72 previousHeight = 0;  │]8;;
[450] (w=114) │   73 focusedComponent = null;  │]8;;
[451] (w=121) │   74 /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ │]8;;
[452] (w=114) │   75 onDebug;  │]8;;
[453] (w=114) │   76 renderRequested = false;  │]8;;
[454] (w=114) │   77 cursorRow = 0; // Logical cursor row (end of rendered content)  │]8;;
[455] (w=114) │  … 40 more lines hidden …  │]8;;
[456] (w=114) │ │]8;;
[457] (w=114) │  $ grep -n "Container" /home/jonas/node_modules/@mariozechner/pi-tui/dist/tui.d.ts | head -20  │]8;;
[458] (w=114) │  108: * Container - a component that contains other components  │]8;;
[459] (w=114) │  110:export declare class Container implements Component {  │]8;;
[460] (w=114) │  121:export declare class TUI extends Container {  │]8;;
[461] (w=114) │ │]8;;
[462] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/tui.d.ts:108-122  │]8;;
[463] (w=114) │  108 * Container - a component that contains other components  │]8;;
[464] (w=114) │  109 */  │]8;;
[465] (w=114) │  110 export declare class Container implements Component {  │]8;;
[466] (w=114) │  111 children: Component[];  │]8;;
[467] (w=114) │  112 addChild(component: Component): void;  │]8;;
[468] (w=114) │  113 removeChild(component: Component): void;  │]8;;
[469] (w=114) │  114 clear(): void;  │]8;;
[470] (w=114) │  115 invalidate(): void;  │]8;;
[471] (w=114) │  116 render(width: number): string[];  │]8;;
[472] (w=114) │  117 }  │]8;;
[473] (w=114) │  118 /**  │]8;;
[474] (w=114) │  119 * TUI - Main class for managing terminal UI with differential rendering  │]8;;
[475] (w=114) │  120 */  │]8;;
[476] (w=114) │  121 export declare class TUI extends Container {  │]8;;
[477] (w=114) │  122 terminal: Terminal;  │]8;;
[478] (w=114) │ │]8;;
[479] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/components/box.d.ts  │]8;;
[480] (w=114) │   1 import type { Component } from "../tui.js";  │]8;;
[481] (w=114) │   2 /**  │]8;;
[482] (w=114) │   3 * Box component - a container that applies padding and background to all children  │]8;;
[483] (w=114) │   4 */  │]8;;
[484] (w=114) │   5 export declare class Box implements Component {  │]8;;
[485] (w=114) │   6 children: Component[];  │]8;;
[486] (w=114) │   7 private paddingX;  │]8;;
[487] (w=114) │   8 private paddingY;  │]8;;
[488] (w=114) │   9 private bgFn?;  │]8;;
[489] (w=114) │  10 private cache?;  │]8;;
[490] (w=114) │  11 constructor(paddingX?: number, paddingY?: number, bgFn?: (text: string) => string);  │]8;;
[491] (w=114) │  12 addChild(component: Component): void;  │]8;;
[492] (w=114) │  13 removeChild(component: Component): void;  │]8;;
[493] (w=114) │  14 clear(): void;  │]8;;
[494] (w=114) │  15 setBgFn(bgFn?: (text: string) => string): void;  │]8;;
[495] (w=114) │  16 private invalidateCache;  │]8;;
[496] (w=114) │  17 private matchCache;  │]8;;
[497] (w=114) │  18 invalidate(): void;  │]8;;
[498] (w=114) │  19 render(width: number): string[];  │]8;;
[499] (w=114) │  20 private applyBg;  │]8;;
[500] (w=114) │  21 }  │]8;;
[501] (w=114) │  22 //# sourceMappingURL=box.d.ts.map  │]8;;
[502] (w=114) │ │]8;;
[503] (w=114) │ Now I have enough understanding. Let me look at the exact current state of the tool-block rendering site and │]8;;
[504] (w=114) │ the imports: │]8;;
[505] (w=114) │ │]8;;
[506] (w=114) │  read ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts:NaN-NaN  │]8;;
[507] (w=114) │   InputValidationError: Read failed due to the following issue:  │]8;;
[508] (w=114) │   The parameter `offset` type is expected as `number` but provided as `string`  │]8;;
[509] (w=114) │ │]8;;
[510] (w=114) │  read ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts:30-41  │]8;;
[511] (w=114) │  30 import { CustomEditor, getMarkdownTheme } from "@mariozechner/pi-coding-agent";  │]8;;
[512] (w=114) │  31 import type { ExtensionAPI, KeybindingsManager } from "@mariozechner/pi-coding-agent";  │]8;;
[513] (w=114) │  32 import { Container, matchesKey, Markdown, Spacer, Text, TUI, visibleWidth, type EditorTheme } from  │]8;;
[514] (w=114) │  "@mariozechner/pi-tui";  │]8;;
[515] (w=114) │  33 import {  │]8;;
[516] (w=114) │  34 formatUsage,  │]8;;
[517] (w=114) │  35 renderToolBlock,  │]8;;
[518] (w=114) │  36 runClaude,  │]8;;
[519] (w=114) │  37 type StreamBlock,  │]8;;
[520] (w=114) │  38 } from "../shared/claude-stream.js";  │]8;;
[521] (w=114) │  39 import { startAskBridge, type AskBridge } from "../shared/pi-ask-bridge.js";  │]8;;
[522] (w=114) │  40 import { askSingleQuestionWithInlineNote } from "./pi-ask-tool/ask-inline-ui.js";  │]8;;
[523] (w=114) │  41  │]8;;
[524] (w=114) │ │]8;;
[525] (w=114) │ Now I'll make the changes. First, add Box and Component to the imports: │]8;;
[526] (w=114) │ │]8;;
[527] (w=114) │  edit ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts  │]8;;
[528] (w=114) │  @@ -1,1 +1,1 @@  │]8;;
[529] (w=114) │  -import { Container, matchesKey, Markdown, Spacer, Text, TUI, visibleWidth, type EditorTheme } from  │]8;;
[530] (w=114) │  "@mariozechner/pi-tui";  │]8;;
[531] (w=114) │  +import { Box, Container, matchesKey, Markdown, Spacer, Text, TUI, visibleWidth, type Component, type  │]8;;
[532] (w=114) │  EditorTheme } from "@mariozechner/pi-tui";  │]8;;
[533] (w=114) │ │]8;;
[534] (w=114) │ Now add the constant and the truncation helper. I'll place it right after the orange-border helper, where │]8;;
[535] (w=114) │ rendering helpers live: │]8;;
[536] (w=114) │ │]8;;
[537] (w=114) │  edit ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts  │]8;;
[538] (w=114) │  @@ -1,3 +1,82 @@  │]8;;
[539] (w=114) │  -// ---------------------------------------------------------------------------  │]8;;
[540] (w=114) │  -// Models / turn types  │]8;;
[541] (w=114) │  -// ---------------------------------------------------------------------------  │]8;;
[542] (w=114) │  +// ---------------------------------------------------------------------------  │]8;;
[543] (w=114) │  +// Read-tool result truncation  │]8;;
[544] (w=114) │  +//  │]8;;
[545] (w=114) │  +// `Read` tool calls inside chat mode often dump entire files into the result  │]8;;
[546] (w=114) │  +// banner — many hundreds of lines, which buries the surrounding conversation.  │]8;;
[547] (w=114) │  +// We cap the rendered file content at MAX_READ_LINES and append a single  │]8;;
[548] (w=114) │  +// centered notice line describing how many lines were hidden. This is a  │]8;;
[549] (w=114) │  +// PRESENTATION-only truncation: `block.result.text` is left untouched, so  │]8;;
[550] (w=114) │  +// resumed sessions / re-renders still see the full content.  │]8;;
[551] (w=114) │  +//  │]8;;
[552] (w=114) │  +// Centering needs render-time width, so we implement a tiny custom Component  │]8;;
[553] (w=114) │  +// (TruncatedReadResult) and swap it into the Box body produced by the shared  │]8;;
[554] (w=114) │  +// renderToolBlock helper. The same dim line-number formatting used by  │]8;;
[555] (w=114) │  +// renderToolResultBox is preserved so the truncated view looks identical to  │]8;;
[556] (w=114) │  +// the un-truncated one above the notice.  │]8;;
[557] (w=114) │  +// ---------------------------------------------------------------------------  │]8;;
[558] (w=114) │  +const MAX_READ_LINES = 40;  │]8;;
[559] (w=114) │  +  │]8;;
[560] (w=114) │  +class TruncatedReadResult implements Component {  │]8;;
[561] (w=114) │  + constructor(  │]8;;
[562] (w=114) │  + private readonly numbered: { num: string; content: string }[],  │]8;;
[563] (w=114) │  + private readonly maxNumLen: number,  │]8;;
[564] (w=114) │  + private readonly dimFn: (s: string) => string,  │]8;;
[565] (w=114) │  + private readonly noticeFn: (s: string) => string,  │]8;;
[566] (w=114) │  + ) {}  │]8;;
[567] (w=114) │  +  │]8;;
[568] (w=114) │  + invalidate(): void { /* stateless */ }  │]8;;
[569] (w=114) │  +  │]8;;
[570] (w=114) │  + render(width: number): string[] {  │]8;;
[571] (w=114) │  + const total = this.numbered.length;  │]8;;
[572] (w=114) │  + const visible = Math.min(MAX_READ_LINES, total);  │]8;;
[573] (w=114) │  + const lines: string[] = [];  │]8;;
[574] (w=114) │  + for (let i = 0; i < visible; i++) {  │]8;;
[575] (w=114) │  + const l = this.numbered[i];  │]8;;
[576] (w=114) │  + lines.push(this.dimFn(l.num.padStart(this.maxNumLen)) + " " + l.content);  │]8;;
[577] (w=114) │  + }  │]8;;
[578] (w=114) │  + if (total > visible) {  │]8;;
[579] (w=114) │  + const hidden = total - visible;  │]8;;
[580] (w=114) │  + const notice = `… ${hidden} more line${hidden === 1 ? "" : "s"} hidden …`;  │]8;;
[581] (w=114) │  + const visLen = visibleWidth(notice);  │]8;;
[582] (w=114) │  + const left = Math.max(0, Math.floor((width - visLen) / 2));  │]8;;
[583] (w=114) │  + lines.push(" ".repeat(left) + this.noticeFn(notice));  │]8;;
[584] (w=114) │  + }  │]8;;
[585] (w=114) │  + return lines;  │]8;;
[586] (w=114) │  + }  │]8;;
[587] (w=114) │  +}  │]8;;
[588] (w=114) │  +  │]8;;
[589] (w=114) │  +// Wrap shared renderToolBlock: for `Read` tool blocks whose result exceeds  │]8;;
[590] (w=114) │  +// MAX_READ_LINES, replace the Box body's child Text with our truncating  │]8;;
[591] (w=114) │  +// component. All other tool kinds, error results, and short reads pass  │]8;;
[592] (w=114) │  +// through unchanged.  │]8;;
[593] (w=114) │  +function renderToolBlockTruncated(block: Extract<StreamBlock, { type: "tool" }>, theme: any): Container {  │]8;;
[594] (w=114) │  + const c = renderToolBlock(block, theme);  │]8;;
[595] (w=114) │  + if (block.name.toLowerCase() !== "read") return c;  │]8;;
[596] (w=114) │  + if (!block.result || block.result.isError) return c;  │]8;;
[597] (w=114) │  +  │]8;;
[598] (w=114) │  + const rawLines = block.result.text.split("\n").filter((l) => l.length > 0);  │]8;;
[599] (w=114) │  + if (rawLines.length <= MAX_READ_LINES) return c;  │]8;;
[600] (w=114) │  +  │]8;;
[601] (w=114) │  + const parsed = rawLines.map((l) => {  │]8;;
[602] (w=114) │  + const tab = l.indexOf("\t");  │]8;;
[603] (w=114) │  + return tab >= 0 ? { num: l.slice(0, tab), content: l.slice(tab + 1) } : { num: "", content: l };  │]8;;
[604] (w=114) │  + });  │]8;;
[605] (w=114) │  + const maxNumLen = parsed.reduce((m, l) => Math.max(m, l.num.length), 0);  │]8;;
[606] (w=114) │  +  │]8;;
[607] (w=114) │  + // renderToolBlock's container is [headerText, bodyBox]. Bail safely if a  │]8;;
[608] (w=114) │  + // future change to that helper alters the structure.  │]8;;
[609] (w=114) │  + const body = c.children[1];  │]8;;
[610] (w=114) │  + if (!(body instanceof Box)) return c;  │]8;;
[611] (w=114) │  + body.clear();  │]8;;
[612] (w=114) │  + body.addChild(new TruncatedReadResult(  │]8;;
[613] (w=114) │  + parsed,  │]8;;
[614] (w=114) │  + maxNumLen,  │]8;;
[615] (w=114) │  + (s) => theme.fg("dim", s),  │]8;;
[616] (w=114) │  + (s) => theme.fg("dim", s),  │]8;;
[617] (w=114) │  + ));  │]8;;
[618] (w=114) │  + return c;  │]8;;
[619] (w=114) │  +}  │]8;;
[620] (w=114) │  +  │]8;;
[621] (w=114) │  +// ---------------------------------------------------------------------------  │]8;;
[622] (w=114) │  +// Models / turn types  │]8;;
[623] (w=114) │  +// ---------------------------------------------------------------------------  │]8;;
[624] (w=114) │ │]8;;
[625] (w=114) │ Now swap the call site inside renderTurnInto to use the wrapper: │]8;;
[626] (w=114) │ │]8;;
[627] (w=114) │  edit ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts  │]8;;
[628] (w=114) │  @@ -1,4 +1,4 @@  │]8;;
[629] (w=114) │  - } else if (block.type === "tool") {  │]8;;
[630] (w=114) │  - if (addedAny) container.addChild(new Spacer(1));  │]8;;
[631] (w=114) │  - container.addChild(renderToolBlock(block, theme));  │]8;;
[632] (w=114) │  - addedAny = true;  │]8;;
[633] (w=114) │  + } else if (block.type === "tool") {  │]8;;
[634] (w=114) │  + if (addedAny) container.addChild(new Spacer(1));  │]8;;
[635] (w=114) │  + container.addChild(renderToolBlockTruncated(block, theme));  │]8;;
[636] (w=114) │  + addedAny = true;  │]8;;
[637] (w=114) │ │]8;;
[638] (w=114) │ Now let me verify there's no TypeScript build/check available and the file still parses cleanly: │]8;;
[639] (w=114) │ │]8;;
[640] (w=114) │  $ ls /home/jonas/dotfiles/pi/.pi/ /home/jonas/dotfiles/pi/.pi/agent/ 2>/dev/null  │]8;;
[641] (w=114) │  /home/jonas/dotfiles/pi/.pi/:  │]8;;
[642] (w=114) │  agent  │]8;;
[643] (w=114) │  web-search.json  │]8;;
[644] (w=114) │   │]8;;
[645] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/:  │]8;;
[646] (w=114) │  agents  │]8;;
[647] (w=114) │  auth.json  │]8;;
[648] (w=114) │  auth.json.current  │]8;;
[649] (w=114) │  cache  │]8;;
[650] (w=114) │  extensions  │]8;;
[651] (w=114) │  mcp-cache.json  │]8;;
[652] (w=114) │  mcp.json  │]8;;
[653] (w=114) │  models.json  │]8;;
[654] (w=114) │  profiles  │]8;;
[655] (w=114) │  prompts  │]8;;
[656] (w=114) │  run-history.jsonl  │]8;;
[657] (w=114) │  sessions  │]8;;
[658] (w=114) │  settings.json  │]8;;
[659] (w=114) │  shared  │]8;;
[660] (w=114) │  skills  │]8;;
[661] (w=114) │  themes  │]8;;
[662] (w=114) │  usage-cache.json  │]8;;
[663] (w=114) │ │]8;;
[664] (w=114) │  $ which tsc 2>/dev/null; ls /home/jonas/node_modules/.bin/tsc 2>/dev/null  │]8;;
[665] (w=114) │  Exit code 2  │]8;;
[666] (w=114) │  /home/jonas/.npm-global/bin/tsc  │]8;;
[667] (w=114) │ │]8;;
[668] (w=114) │  $ cd /home/jonas/dotfiles/pi/.pi/agent && tsc --noEmit --target esnext --module nodenext  │]8;;
[669] (w=114) │  --moduleResolution nodenext --esModuleInterop --skipLibCheck --strict false extensions/chat-claude.ts 2>&1  │]8;;
[670] (w=114) │  | head -40  │]8;;
[671] (w=114) │  shared/pi-ask-bridge.ts(117,37): error TS1470: The 'import.meta' meta-property is not allowed in files  │]8;;
[672] (w=114) │  which will build into CommonJS output.  │]8;;
[673] (w=114) │ │]8;;
[674] (w=114) │  $ tsc --noEmit --target esnext --module esnext --moduleResolution bundler --esModuleInterop --skipLibCheck  │]8;;
[675] (w=114) │  --strict false extensions/chat-claude.ts 2>&1 | head -40  │]8;;
[676] (w=114) │  (Bash completed with no output)  │]8;;
[677] (w=114) │ │]8;;
[678] (w=114) │ No type errors. Let me show the final relevant sections: │]8;;
[679] (w=114) │ │]8;;
[680] (w=114) │  read ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts:72-153  │]8;;
[681] (w=114) │   72 // ---------------------------------------------------------------------------  │]8;;
[682] (w=114) │   73 // Read-tool result truncation  │]8;;
[683] (w=114) │   74 //  │]8;;
[684] (w=114) │   75 // `Read` tool calls inside chat mode often dump entire files into the result  │]8;;
[685] (w=114) │   76 // banner — many hundreds of lines, which buries the surrounding conversation.  │]8;;
[686] (w=114) │   77 // We cap the rendered file content at MAX_READ_LINES and append a single  │]8;;
[687] (w=114) │   78 // centered notice line describing how many lines were hidden. This is a  │]8;;
[688] (w=114) │   79 // PRESENTATION-only truncation: `block.result.text` is left untouched, so  │]8;;
[689] (w=114) │   80 // resumed sessions / re-renders still see the full content.  │]8;;
[690] (w=114) │   81 //  │]8;;
[691] (w=114) │   82 // Centering needs render-time width, so we implement a tiny custom Component  │]8;;
[692] (w=114) │   83 // (TruncatedReadResult) and swap it into the Box body produced by the shared  │]8;;
[693] (w=114) │   84 // renderToolBlock helper. The same dim line-number formatting used by  │]8;;
[694] (w=114) │   85 // renderToolResultBox is preserved so the truncated view looks identical to  │]8;;
[695] (w=114) │   86 // the un-truncated one above the notice.  │]8;;
[696] (w=114) │   87 // ---------------------------------------------------------------------------  │]8;;
[697] (w=114) │   88 const MAX_READ_LINES = 40;  │]8;;
[698] (w=114) │   89  │]8;;
[699] (w=114) │   90 class TruncatedReadResult implements Component {  │]8;;
[700] (w=114) │   91 constructor(  │]8;;
[701] (w=114) │   92 private readonly numbered: { num: string; content: string }[],  │]8;;
[702] (w=114) │   93 private readonly maxNumLen: number,  │]8;;
[703] (w=114) │   94 private readonly dimFn: (s: string) => string,  │]8;;
[704] (w=114) │   95 private readonly noticeFn: (s: string) => string,  │]8;;
[705] (w=114) │   96 ) {}  │]8;;
[706] (w=114) │   97  │]8;;
[707] (w=114) │   98 invalidate(): void { /* stateless */ }  │]8;;
[708] (w=114) │   99  │]8;;
[709] (w=114) │  100 render(width: number): string[] {  │]8;;
[710] (w=114) │  101 const total = this.numbered.length;  │]8;;
[711] (w=114) │  102 const visible = Math.min(MAX_READ_LINES, total);  │]8;;
[712] (w=114) │  103 const lines: string[] = [];  │]8;;
[713] (w=114) │  104 for (let i = 0; i < visible; i++) {  │]8;;
[714] (w=114) │  105 const l = this.numbered[i];  │]8;;
[715] (w=114) │  106 lines.push(this.dimFn(l.num.padStart(this.maxNumLen)) + " " + l.content);  │]8;;
[716] (w=114) │  107 }  │]8;;
[717] (w=114) │  108 if (total > visible) {  │]8;;
[718] (w=114) │  109 const hidden = total - visible;  │]8;;
[719] (w=114) │  110 const notice = `… ${hidden} more line${hidden === 1 ? "" : "s"} hidden …`;  │]8;;
[720] (w=114) │  111 const visLen = visibleWidth(notice);  │]8;;
[721] (w=114) │  … 42 more lines hidden …  │]8;;
[722] (w=114) │ │]8;;
[723] (w=114) │ Done. Summary of changes to /home/jonas/dotfiles/pi/.pi/agent/extensions/chat-claude.ts: │]8;;
[724] (w=114) │ │]8;;
[725] (w=114) │ 1. Imports — added Box and type Component to the @mariozechner/pi-tui import. │]8;;
[726] (w=114) │ 2. New constant MAX_READ_LINES = 40 — hard cap on rendered file lines per Read tool result. │]8;;
[727] (w=114) │ 3. New TruncatedReadResult component — a tiny Component that renders the first 40 numbered lines using the │]8;;
[728] (w=114) │ same dim line-number formatting as the shared renderToolResultBox, then appends a single centered notice │]8;;
[729] (w=114) │ line (… N more lines hidden …). Centering is computed from the render-time width, so it tracks terminal │]8;;
[730] (w=114) │ resizes. │]8;;
[731] (w=114) │ 4. New renderToolBlockTruncated() wrapper — calls the shared renderToolBlock, and only for read tool blocks │]8;;
[732] (w=114) │ whose result exceeds 40 lines, swaps out the Box body's Text child with TruncatedReadResult. Errors, short │]8;;
[733] (w=114) │ reads, and every non-read tool pass through unchanged. The original block.result.text is never mutated, so │]8;;
[734] (w=114) │ resumed sessions and re-renders still see the full content. │]8;;
[735] (w=114) │ 5. Single call-site swap in renderTurnInto: renderToolBlock(block, theme) → renderToolBlockTruncated(block, │]8;;
[736] (w=114) │ theme). │]8;;
[737] (w=114) │ │]8;;
[738] (w=114) │ tsc --noEmit on the file produces no errors. │]8;;
[739] (w=114) ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;;
[740] (w=0) ]8;;
[741] (w=114) Resuming session c9492aa2 (1h ago): "@agent/extensions/chat-claude.ts truncate all rea…" as Claude Opus. (2 ]8;;
[742] (w=114) historical turns loaded) ]8;;
[743] (w=0) ]8;;
[744] (w=44) ▌ ◆ CLAUDE CHAT MODE OPUS session:c9492aa2]8;;
[745] (w=86) ▌ Type to chat · /claude haiku|sonnet|opus · /claude-new · /claude-end · /claude-abort]8;;
[746] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;;
[747] (w=114)   ]8;;
[748] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;;
[749] (w=114) ~/dotfiles/pi/.pi ·  S ⣀⣀⣀⣀⣀ 0% |  W ⣄⣀⣀⣀⣀ 3% ⟳ 4d 7h | C ⣀⣀⣀⣀⣀ 0% · minimax/minimax-m2.5:free · ◆ Claud...]8;;

View File

@@ -0,0 +1,779 @@
/**
* claude-stream — Shared types, rendering, and core spawn/stream logic
* for ask-claude and chat-claude pi extensions.
*
* Both extensions spawn `claude -p --output-format stream-json` and parse
* the same streaming protocol. This module provides:
* - Block types (ThinkingBlock, ToolBlock, TextBlock)
* - Rendering helpers (tool call lines, result boxes, usage formatting)
* - runClaude() — the core spawn + stream parser
*/
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { diffLines } from "diff";
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
// =============================================================================
// Block types
// =============================================================================
export interface ThinkingBlock {
type: "thinking";
text: string;
}
export interface ToolBlock {
type: "tool";
id: string;
name: string;
inputJson: string;
editContext?: { before: string[]; after: string[]; startLine: number };
result?: { text: string; isError: boolean };
}
export interface TextBlock {
type: "text";
text: string;
}
export type StreamBlock = ThinkingBlock | ToolBlock | TextBlock;
// =============================================================================
// Details interface (stored in tool result, drives rendering)
// =============================================================================
export interface ClaudeDetails {
label: string;
done: boolean;
blocks: StreamBlock[];
finalText: string;
sessionId?: string;
isResume?: boolean;
costUsd?: number;
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
}
// =============================================================================
// Rendering helpers
// =============================================================================
export function shortenPath(p: string): string {
const home = process.env.HOME ?? "";
return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
}
export function formatUsage(d: ClaudeDetails): string {
const parts: string[] = [];
if (d.inputTokens) parts.push(`${d.inputTokens}`);
if (d.outputTokens) parts.push(`${d.outputTokens}`);
if (d.cacheReadTokens) parts.push(`R${d.cacheReadTokens}`);
if (d.cacheWriteTokens) parts.push(`W${d.cacheWriteTokens}`);
if (d.costUsd) parts.push(d.costUsd < 0.001 ? `$${(d.costUsd * 1000).toFixed(2)}m` : `$${d.costUsd.toFixed(4)}`);
return parts.join(" ");
}
export type Theme = {
fg: (c: any, t: string) => string;
bg: (c: any, t: string) => string;
bold: (t: string) => string;
italic: (t: string) => string;
dim?: (t: string) => string;
};
export function renderToolCallLine(block: ToolBlock, theme: Theme): string {
let input: Record<string, unknown> = {};
try { input = JSON.parse(block.inputJson); } catch { /* ok */ }
switch (block.name.toLowerCase()) {
case "read": {
const path = shortenPath(String(input.file_path ?? input.path ?? ""));
const offset = Number(input.offset ?? 1);
const limit = input.limit != null ? Number(input.limit) : undefined;
const range = limit != null ? `:${offset}-${offset + limit - 1}` : "";
return theme.fg("muted", "read ") + theme.fg("accent", path) + theme.fg("warning", range);
}
case "bash": {
const cmd = String(input.command ?? "").replace(/\n/g, " ↵ ");
return theme.fg("muted", "$ ") + theme.fg("toolOutput", cmd);
}
case "edit": {
const path = shortenPath(String(input.file_path ?? input.path ?? ""));
return theme.fg("muted", "edit ") + theme.fg("accent", path);
}
case "write": {
const path = shortenPath(String(input.file_path ?? input.path ?? ""));
const lines = String(input.content ?? "").split("\n").length;
return theme.fg("muted", "write ") + theme.fg("accent", path) + theme.fg("dim", ` (${lines} lines)`);
}
case "glob": {
const pat = String(input.pattern ?? "");
const path = input.path ? shortenPath(String(input.path)) : ".";
return theme.fg("muted", "glob ") + theme.fg("accent", pat) + theme.fg("dim", ` in ${path}`);
}
case "grep": {
const pat = String(input.pattern ?? "");
const path = input.path ? shortenPath(String(input.path)) : ".";
return theme.fg("muted", "grep ") + theme.fg("accent", `"${pat}"`) + theme.fg("dim", ` in ${path}`);
}
case "mcp__pi__ask": {
// Surfaces from the pi-ask-bridge MCP server. input is
// { questions: [{ id, question, options[], multi?, recommended? }, ...] }
// Show the first question text inline; if there are more, append a count.
const qs = Array.isArray(input.questions) ? (input.questions as any[]) : [];
const first = qs[0];
const head = first?.question ? String(first.question) : "(empty)";
const more = qs.length > 1 ? ` (+${qs.length - 1} more)` : "";
const tag = first?.id ? ` [${first.id}]` : "";
return theme.fg("muted", "ask ") + theme.fg("accent", head) + theme.fg("dim", tag + more);
}
default: {
const desc = typeof input.description === "string" ? input.description
: typeof input.prompt === "string" ? input.prompt.split("\n")[0]
: block.inputJson;
return theme.fg("toolTitle", block.name) + theme.fg("dim", " " + desc);
}
}
}
export function renderToolResultBox(block: ToolBlock, theme: Theme): Text {
if (!block.result) return new Text("", 0, 0);
let input: Record<string, unknown> = {};
try { input = JSON.parse(block.inputJson); } catch { /* ok */ }
// Strip / restyle synthetic envelopes like <tool_use_error> and
// <system_reminder> before any tool-specific parsing.
const text = transformSpecialTags(block.result.text);
const { isError } = block.result;
switch (block.name.toLowerCase()) {
case "read": {
const rawLines = text.split("\n").filter((l) => l.length > 0);
const parsed = rawLines.map((l) => {
const tab = l.indexOf("\t");
return tab >= 0 ? { num: l.slice(0, tab), content: l.slice(tab + 1) } : { num: "", content: l };
});
const maxNumLen = parsed.reduce((m, l) => Math.max(m, l.num.length), 0);
return new Text(
parsed.map((l) => theme.fg("dim", l.num.padStart(maxNumLen)) + " " + l.content).join("\n"),
0, 0,
);
}
case "edit": {
if (isError) return new Text(text, 0, 0);
const oldStr = String(input.old_string ?? input.oldText ?? "");
const newStr = String(input.new_string ?? input.newText ?? "");
if (!oldStr && !newStr) return new Text(text.slice(0, 200), 0, 0);
const oldLines = oldStr === "" ? [] : oldStr.split("\n");
const newLines = newStr === "" ? [] : newStr.split("\n");
const ctx = block.editContext;
const startLine = ctx?.startLine ?? 1;
const header = theme.fg("dim", `@@ -${startLine},${oldLines.length} +${startLine},${newLines.length} @@`);
const diff: string[] = [header];
for (const l of ctx?.before ?? []) diff.push(theme.fg("dim", " " + l));
for (const l of oldLines) diff.push(theme.fg("toolDiffRemoved", "-" + l));
for (const l of newLines) diff.push(theme.fg("toolDiffAdded", "+" + l));
for (const l of ctx?.after ?? []) diff.push(theme.fg("dim", " " + l));
return new Text(diff.join("\n"), 0, 0);
}
case "write": {
if (isError) return new Text(text, 0, 0);
const lines = String(input.content ?? "").split("\n");
const numWidth = String(lines.length).length;
return new Text(
lines.map((l, i) => theme.fg("dim", String(i + 1).padStart(numWidth)) + " " + l).join("\n"),
0, 0,
);
}
case "bash":
return new Text(text.trimEnd(), 0, 0);
case "mcp__pi__ask": {
// The pi-ask-mcp server returns a JSON array of QuestionResults.
// Pretty-print as one "id → answer" line per question instead of
// dumping raw JSON into the result banner.
if (isError) return new Text(text.trim(), 0, 0);
let parsed: any;
try { parsed = JSON.parse(text); } catch { return new Text(text.trim(), 0, 0); }
if (!Array.isArray(parsed)) return new Text(text.trim(), 0, 0);
const lines: string[] = [];
for (const r of parsed) {
const id = theme.fg("accent", String(r?.id ?? "?"));
const opts = Array.isArray(r?.selectedOptions) ? r.selectedOptions : [];
const custom = r?.customInput ? String(r.customInput) : "";
const arrow = theme.fg("dim", " → ");
let answer: string;
if (opts.length === 0 && !custom) {
answer = theme.fg("warning", "(cancelled)");
} else if (opts.length > 0 && custom) {
answer = theme.fg("toolOutput", `[${opts.join(", ")}] + Other: "${custom}"`);
} else if (custom) {
answer = theme.fg("toolOutput", `Other: "${custom}"`);
} else {
answer = theme.fg("toolOutput", opts.length === 1 ? opts[0] : `[${opts.join(", ")}]`);
}
lines.push(id + arrow + answer);
}
return new Text(lines.join("\n"), 0, 0);
}
default:
return new Text(text.trim(), 0, 0);
}
}
/** Strip ANSI SGR sequences so we can re-style plain text. */
function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*m/g, "");
}
// ---------------------------------------------------------------------------
// Special-tag transform — used for tool-result text that embeds synthetic
// envelopes like <tool_use_error> or <system_reminder>.
//
// IMPORTANT: all wrappers use PARTIAL resets (`\x1b[22;23;24;39m` — reset
// bold/dim/italic/underline and foreground only). If we emitted \x1b[0m in
// the middle of a line, it would also wipe any background that Box.bgFn
// painted around us, producing a staircase-shaped hole where the padding
// loses its colour. Partial resets leave the background intact.
// ---------------------------------------------------------------------------
const SGR_FG_RESET = "\x1b[22;23;24;39m";
const SGR_BOLD_WHITE = "\x1b[1;97m";
const SGR_BOLD_RED = "\x1b[1;91m";
const SGR_DIM_GREY = "\x1b[2;90m";
function transformSpecialTags(text: string): string {
// <tool_use_error>message</tool_use_error>
// → strip tags, display message in bright bold red
text = text.replace(
/<tool_use_error>\s*([\s\S]*?)\s*<\/tool_use_error>/g,
(_, inner: string) => SGR_BOLD_RED + inner + SGR_FG_RESET,
);
// <system_reminder> or <system-reminder>
// → strip tags, collapse whitespace to one line, truncate at ~100 chars
// with a trailing "...", render in dim grey
text = text.replace(
/<system[-_]reminder>\s*([\s\S]*?)\s*<\/system[-_]reminder>/g,
(_, inner: string) => {
const oneLine = inner.replace(/\s+/g, " ").trim();
const MAX = 100;
const snippet = oneLine.length > MAX ? oneLine.slice(0, MAX).trimEnd() : oneLine;
return SGR_DIM_GREY + snippet + "..." + SGR_FG_RESET;
},
);
return text;
}
/**
* Render a tool block as two stacked banners:
* 1) header — bold-white title on an ORANGE background (matches the
* chat-claude outer border colour).
* 2) result body (if any) — on the theme's pending / success / error bg.
*
* The header deliberately uses a different background from the result, so
* the "staircase" where the banners have different widths is an intentional
* stylistic cue, not a rendering glitch.
*/
const ORANGE_BG_FN = (s: string) => "\x1b[48;5;130m" + s + "\x1b[0m";
export function renderToolBlock(block: ToolBlock, theme: Theme): Container {
const c = new Container();
// ---- Header banner (orange) ----
// Strip per-segment colours from renderToolCallLine so the title renders
// uniformly bold-white over the orange background. Partial-reset at the
// end keeps the orange bg alive for the trailing padding.
const headerText = SGR_BOLD_WHITE + stripAnsi(renderToolCallLine(block, theme)) + SGR_FG_RESET;
c.addChild(new Text(headerText, 2, 0, ORANGE_BG_FN));
// ---- Result body ----
if (block.result !== undefined) {
const bgFn = block.result.isError
? (s: string) => theme.bg("toolErrorBg", s)
: (s: string) => theme.bg("toolSuccessBg", s);
const box = new Box(2, 0, bgFn);
box.addChild(renderToolResultBox(block, theme));
c.addChild(box);
}
return c;
}
/** Build a unified diff string for an edit tool call. */
export function buildEditDiff(oldStr: string, newStr: string, contextLines = 3): string {
const changes = diffLines(oldStr, newStr);
if (changes.length === 1 && (changes[0].added || changes[0].removed)) {
const oldCount = (oldStr ? oldStr.split("\n").length : 0);
const newCount = (newStr ? newStr.split("\n").length : 0);
const lines: string[] = [
`@@ -1,${oldCount} +1,${newCount} @@`,
...oldStr.split("\n").map((l) => "-" + l),
...newStr.split("\n").map((l) => "+" + l),
];
return lines.join("\n");
}
const ctxLines: string[] = oldStr.split("\n");
const hunks: { start: number; lines: string[] }[] = [];
let currentHunk: { start: number; lines: string[] } | null = null;
let lineIdx = 0;
for (const change of changes) {
const lines = change.value.endsWith("\n") ? change.value.slice(0, -1).split("\n") : change.value.split("\n");
if (change.added) {
if (!currentHunk) currentHunk = { start: lineIdx, lines: [] };
for (const l of lines) currentHunk.lines.push("+" + l);
} else if (change.removed) {
if (!currentHunk) currentHunk = { start: lineIdx, lines: [] };
for (const l of lines) currentHunk.lines.push("-" + l);
lineIdx += lines.length;
} else {
for (const l of lines) {
if (!currentHunk) { lineIdx++; continue; }
currentHunk.lines.push(" " + l);
if (currentHunk.lines.length >= contextLines * 2 + 1) {
hunks.push(currentHunk);
currentHunk = null;
}
lineIdx++;
}
}
}
if (currentHunk) hunks.push(currentHunk);
if (hunks.length === 0) return "(no changes)";
const merged: { start: number; lines: string[] }[] = [];
for (const h of hunks) {
if (merged.length === 0 || h.start <= merged[merged.length - 1].start + merged[merged.length - 1].lines.length) {
if (merged.length > 0) {
const prev = merged[merged.length - 1];
prev.lines.push(...h.lines.slice(Math.max(0, prev.lines.length - (h.start - prev.start))));
} else {
merged.push({ ...h, lines: [...h.lines] });
}
} else {
merged.push({ ...h, lines: [...h.lines] });
}
}
const resultLines: string[] = [];
for (const h of merged) {
const removed = h.lines.filter((l) => l.startsWith("-")).length;
const added = h.lines.filter((l) => l.startsWith("+")).length;
const total = h.lines.length;
resultLines.push(`@@ -${h.start},${removed + (total - removed - added)} +${h.start},${added + (total - removed - added)} @@`);
resultLines.push(...h.lines);
}
return resultLines.join("\n");
}
// =============================================================================
// Error helpers
// =============================================================================
const ANTHROPIC_ERROR_MAP: [RegExp, string][] = [
[/rate_limit/i, "Rate limited by Anthropic — please try again in a few seconds."],
[/invalid_api_key/i, "Invalid Anthropic API key — check your ANTHROPIC_API_KEY."],
[/permission_error/i, "Permission denied — check your Anthropic account and API key."],
[/not_found.*model/i, "Model not found — the specified model is not available."],
[/context_length/i, "Context window exceeded — the input is too large for this model."],
[/overloaded/i, "Anthropic is overloaded — please retry shortly."],
[/billing/i, "Billing issue — check your Anthropic account status."],
[/invalid_request/i, "Invalid request — the prompt or parameters may be malformed."],
];
export function formatAnthropicError(raw: string, exitCode?: number): string {
for (const [re, msg] of ANTHROPIC_ERROR_MAP) {
if (re.test(raw)) return msg;
}
try {
const parsed = JSON.parse(raw.trim());
if (parsed.type === "error" && parsed.error?.message) {
const em = parsed.error.message;
for (const [re, msg] of ANTHROPIC_ERROR_MAP) {
if (re.test(em)) return msg;
}
return em;
}
} catch { /* not JSON */ }
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed);
if (parsed.type === "error" && parsed.error?.message) {
const em = parsed.error.message;
for (const [re, msg] of ANTHROPIC_ERROR_MAP) {
if (re.test(em)) return msg;
}
return em;
}
} catch { /* skip */ }
}
if (raw.trim()) return raw.trim().slice(0, 500);
return `claude process exited with code ${exitCode ?? "unknown"}`;
}
// =============================================================================
// Core: spawn claude CLI and stream all output
// =============================================================================
export interface RunClaudeOptions {
model?: string; // default: "sonnet"
agent?: string; // --agent <name> (from ~/.claude/agents/)
sessionId?: string; // --resume <id> (multi-turn)
noSessionPersistence?: boolean; // --no-session-persistence (ask-claude default)
enrichEditDiffs?: boolean; // enrich edit tool results with unified diffs
mcpConfigPath?: string; // --mcp-config <path> (e.g. pi-ask-bridge)
disallowedTools?: string[]; // --disallowed-tools (e.g. ["AskUserQuestion"])
// Extended-thinking effort level ("low"|"medium"|"high"|"xhigh"|"max").
// REQUIRED for thinking blocks to appear: in `-p` / stream-json mode the
// CLI does NOT honour the user's interactive `defaultThinkingLevel`
// setting — thinking_delta events are only emitted when `--effort` is
// passed explicitly. Pass "off" to leave the flag off entirely.
effort?: string;
extraEnv?: NodeJS.ProcessEnv; // merged on top of process.env for the child
cwd: string;
signal?: AbortSignal;
timeoutMs?: number; // default: 15 min
onUpdate: (partial: { blocks: StreamBlock[]; finalText: string }) => void;
}
export interface RunClaudeResult {
finalText: string;
blocks: StreamBlock[];
sessionId: string;
costUsd: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
}
export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise<RunClaudeResult> {
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
const args = ["-p", "--output-format", "stream-json", "--include-partial-messages"];
// Session mode: resume takes priority, then agent, then plain model
if (opts.sessionId) {
args.push("--resume", opts.sessionId);
} else if (opts.agent) {
args.push("--agent", opts.agent);
if (opts.model) args.push("--model", opts.model);
if (opts.noSessionPersistence) args.push("--no-session-persistence");
} else {
args.push("--model", opts.model ?? "sonnet");
if (opts.noSessionPersistence) args.push("--no-session-persistence");
}
// Extended-thinking effort — must be passed explicitly in -p mode;
// the interactive `defaultThinkingLevel` setting does NOT apply here.
// Callers pass "off" to suppress the flag (e.g. ask-claude where
// raw speed matters more than thought traces).
if (opts.effort && opts.effort !== "off") {
args.push("--effort", opts.effort);
}
// MCP config (e.g. pi-ask-bridge for routing AskUserQuestion-style
// requests through pi's native UI). Additive — does NOT pass
// --strict-mcp-config, so the user's other configured MCP servers
// (exa, sentry, …) remain available to Claude.
if (opts.mcpConfigPath) {
args.push("--mcp-config", opts.mcpConfigPath);
}
// Tool denylist — typically ["AskUserQuestion"] when the MCP
// server is providing a replacement.
if (opts.disallowedTools?.length) {
args.push("--disallowed-tools", opts.disallowedTools.join(","));
}
const proc = spawn("claude", args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: opts.cwd,
env: opts.extraEnv ? { ...process.env, ...opts.extraEnv } : process.env,
});
try {
proc.stdin.write(prompt, "utf8");
proc.stdin.end();
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EPIPE") reject(err);
}
let buffer = "";
const blocks: StreamBlock[] = [];
const pendingTools = new Map<number, { name: string; inputJson: string; id: string }>();
let sessionId = "";
let costUsd = 0, inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
const getOrCreateTextBlock = (): TextBlock => {
const last = blocks[blocks.length - 1];
if (last?.type === "text") return last;
const b: TextBlock = { type: "text", text: "" };
blocks.push(b);
return b;
};
const getOrCreateThinkingBlock = (): ThinkingBlock => {
const last = blocks[blocks.length - 1];
if (last?.type === "thinking") return last;
const b: ThinkingBlock = { type: "thinking", text: "" };
blocks.push(b);
return b;
};
const emit = () => {
const finalText = blocks.filter((b): b is TextBlock => b.type === "text").map((b) => b.text).join("");
opts.onUpdate({ blocks: [...blocks], finalText });
};
const processLine = (line: string) => {
if (!line.trim()) return;
let event: any;
try { event = JSON.parse(line); } catch { return; }
if (event.type === "stream_event") {
const e = event.event as any;
if (!e) return;
if (e.type === "content_block_start") {
const cb = e.content_block;
if (cb?.type === "tool_use") {
pendingTools.set(e.index as number, { name: cb.name, inputJson: "", id: cb.id });
}
} else if (e.type === "content_block_delta") {
const d = e.delta as any;
if (d?.type === "text_delta") {
getOrCreateTextBlock().text += d.text as string;
emit();
} else if (d?.type === "thinking_delta") {
getOrCreateThinkingBlock().text += d.thinking as string;
emit();
} else if (d?.type === "input_json_delta") {
const tool = pendingTools.get(e.index as number);
if (tool) tool.inputJson += d.partial_json as string ?? "";
}
} else if (e.type === "content_block_stop") {
const tool = pendingTools.get(e.index as number);
if (tool) {
// Claude CLI's --include-partial-messages can emit an `assistant`
// event with the completed tool_use BEFORE content_block_stop
// arrives. That path already pushed a block with the same id;
// update it in place instead of pushing a duplicate.
const existing = blocks.find(
(b): b is ToolBlock => b.type === "tool" && b.id === tool.id,
);
const target: ToolBlock = existing ?? {
type: "tool", id: tool.id, name: tool.name, inputJson: tool.inputJson,
};
if (existing) {
// Prefer the streamed inputJson (it's been accumulating and
// matches what claude-code actually executed).
existing.inputJson = tool.inputJson;
}
// For edit tools, read file context before the edit executes
if (tool.name.toLowerCase() === "edit" && !target.editContext) {
try {
const inp = JSON.parse(tool.inputJson) as Record<string, unknown>;
const fp = String(inp.file_path ?? inp.path ?? "");
const oldStr = String(inp.old_string ?? inp.oldText ?? "");
if (fp && oldStr) {
const fileLines = readFileSync(fp, "utf8").split("\n");
const oldLines = oldStr.split("\n");
let startIdx = -1;
outer: for (let i = 0; i <= fileLines.length - oldLines.length; i++) {
for (let j = 0; j < oldLines.length; j++) {
if (fileLines[i + j] !== oldLines[j]) continue outer;
}
startIdx = i;
break;
}
if (startIdx >= 0) {
target.editContext = {
before: fileLines.slice(Math.max(0, startIdx - 3), startIdx),
after: fileLines.slice(startIdx + oldLines.length, startIdx + oldLines.length + 3),
startLine: startIdx + 1,
};
}
}
} catch { /* ignore */ }
}
if (!existing) blocks.push(target);
pendingTools.delete(e.index as number);
emit();
}
} else if (e.type === "message_delta") {
const u = e.usage as any;
if (u) {
inputTokens = u.input_tokens ?? inputTokens;
outputTokens = u.output_tokens ?? outputTokens;
cacheReadTokens = u.cache_read_input_tokens ?? cacheReadTokens;
cacheWriteTokens = u.cache_creation_input_tokens ?? cacheWriteTokens;
}
}
} else if (event.type === "user") {
for (const c of (event.message?.content ?? []) as any[]) {
if (c.type !== "tool_result") continue;
// tool_result.content may be a plain string (typical for bash/read
// output) or an array of {type,text}/{type,image} blocks. Handle both.
let text: string;
if (typeof c.content === "string") {
text = c.content;
} else if (Array.isArray(c.content)) {
text = (c.content as any[])
.filter((x) => x.type === "text")
.map((x) => x.text as string)
.join("\n");
} else {
text = "";
}
const toolId = c.tool_use_id as string;
const toolBlock = blocks.findLast((b): b is ToolBlock => b.type === "tool" && b.id === toolId);
if (toolBlock && toolBlock.name.toLowerCase() === "edit" && !c.is_error && opts.enrichEditDiffs) {
try {
const inp = JSON.parse(toolBlock.inputJson) as Record<string, unknown>;
const oldStr = String(inp.old_string ?? inp.oldText ?? "");
const newStr = String(inp.new_string ?? inp.newText ?? "");
if (oldStr || newStr) {
const diff = buildEditDiff(oldStr, newStr);
text = `${text}\n\n--- edit diff ---\n${diff}\n--- end diff ---`;
}
} catch { /* ignore */ }
}
if (toolBlock) toolBlock.result = { text, isError: c.is_error === true };
emit();
}
} else if (event.type === "assistant") {
const content: any[] = (event as any).message?.content ?? [];
for (const cb of content) {
if (cb.type !== "tool_use") continue;
const exists = blocks.some((b): b is ToolBlock => b.type === "tool" && b.id === cb.id);
if (!exists) blocks.push({ type: "tool", id: cb.id, name: cb.name, inputJson: JSON.stringify(cb.input ?? {}) });
}
emit();
} else if (event.type === "result") {
sessionId = event.session_id ?? "";
costUsd = event.total_cost_usd ?? 0;
const u = event.usage as any;
if (u) {
inputTokens = u.input_tokens ?? inputTokens;
outputTokens = u.output_tokens ?? outputTokens;
cacheReadTokens = u.cache_read_input_tokens ?? cacheReadTokens;
cacheWriteTokens = u.cache_creation_input_tokens ?? cacheWriteTokens;
}
} else if (event.type === "error") {
const errMsg = event.error?.message ?? event.message ?? JSON.stringify(event);
reject(new Error(formatAnthropicError(errMsg)));
}
};
proc.stdout.on("data", (data: Buffer) => {
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) processLine(line);
});
let stderrOutput = "";
proc.stderr.on("data", (data: Buffer) => { stderrOutput += data.toString(); });
proc.on("close", (code) => {
clearTimeout(timeoutId);
if (buffer.trim()) processLine(buffer);
const finalText = blocks.filter((b): b is TextBlock => b.type === "text").map((b) => b.text).join("");
if (code !== 0) {
if (timeoutFired) {
reject(new Error(`Claude timed out after ${timeoutMs / 1000}s — the process was killed. Consider using a simpler prompt or a faster model.`));
} else {
const errMsg = formatAnthropicError(stderrOutput.trim(), code);
const detail = finalText ? ` (partial output: ${finalText.slice(0, 100)})` : "";
reject(new Error(errMsg + detail));
}
return;
}
resolve({ finalText, blocks, sessionId, costUsd, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens });
});
proc.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
reject(new Error("Claude CLI not found — install it with `npm install -g @anthropic-ai/claude-code`"));
} else {
reject(err);
}
});
if (opts.signal) {
const kill = () => {
try { if (!proc.killed) proc.kill("SIGTERM"); } catch { /* ok */ }
setTimeout(() => { try { if (!proc.killed) proc.kill("SIGKILL"); } catch { /* ok */ } }, 3000);
};
if (opts.signal.aborted) kill();
else opts.signal.addEventListener("abort", kill, { once: true });
}
let timeoutFired = false;
const timeoutId = setTimeout(() => {
timeoutFired = true;
try { proc.kill("SIGTERM"); } catch { /* ok */ }
setTimeout(() => { try { if (!proc.killed) proc.kill("SIGKILL"); } catch { /* ok */ } }, 3000);
}, timeoutMs);
proc.prependListener("close", () => { clearTimeout(timeoutId); });
proc.prependListener("error", () => { clearTimeout(timeoutId); });
});
}
// =============================================================================
// RenderResult factory — produces the common result rendering for both extensions
// =============================================================================
export function renderClaudeResult(
result: { details?: ClaudeDetails; isPartial?: boolean },
theme: Theme,
opts?: { showSession?: boolean }
): Container | Text {
const d = result.details as ClaudeDetails | undefined;
if (!d) return new Text(theme.fg("muted", "…"), 0, 0);
const isDone = d.done && !result.isPartial;
const icon = isDone ? theme.fg("success", "✓ ") : theme.fg("warning", "⏳ ");
const resume = d.isResume ? theme.fg("dim", " ↩") : "";
const c = new Container();
c.addChild(new Text(icon + theme.fg("toolTitle", theme.bold(d.label)) + resume, 0, 0));
for (const block of d.blocks ?? []) {
if (block.type === "thinking" && block.text.trim()) {
c.addChild(new Text(theme.fg("dim", theme.italic(block.text.trimEnd())), 0, 0));
} else if (block.type === "tool") {
c.addChild(renderToolBlock(block, theme));
} else if (block.type === "text" && block.text.trim()) {
c.addChild(new Spacer(1));
if (isDone) {
c.addChild(new Markdown(block.text.trim(), 0, 0, getMarkdownTheme()));
} else {
c.addChild(new Text(theme.fg("dim", block.text.trimEnd()), 0, 0));
}
}
}
if (isDone) {
const usageLine = formatUsage(d);
const parts: string[] = [];
if (usageLine) parts.push(usageLine);
if (opts?.showSession && d.sessionId) parts.push(`session:${d.sessionId.slice(0, 8)}`);
if (parts.length > 0) {
c.addChild(new Spacer(1));
c.addChild(new Text(theme.fg("dim", parts.join(" ")), 0, 0));
}
}
return c;
}

View File

@@ -0,0 +1,201 @@
/**
* pi-ask-bridge — Unix-domain socket server that pipes ask requests from
* the pi-ask-mcp subprocess (running inside Claude CLI inside chat-claude)
* into pi's native ask UI (askSingleQuestionWithInlineNote / askQuestionsWithTabs).
*
* Architecture:
*
* pi process
* └── chat-claude extension
* ├── AskBridge (here) — listens on $PI_ASK_SOCKET
* └── claude -p ... --mcp-config <generated>
* └── pi-ask-mcp/server.js
* ↳ on tools/call ask → connects to $PI_ASK_SOCKET,
* sends question, awaits answer
*
* Lifecycle: start one bridge per chat-claude session; close on exit.
* Concurrency: pi's ui.custom overlay is modal, so asks are serialised
* across all open connections via an internal promise chain.
*
* Wire format (NDJSON, one message per line):
*
* request → { id, type: "ask", questions: [{id, question, options[], multi?, recommended?}, ...] }
* response ← { id, type: "result", results: [{id, selectedOptions[], customInput?}, ...] }
* error ← { id, type: "error", message: "…" }
*/
import { createServer, type Server as NetServer, type Socket } from "node:net";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
import { askSingleQuestionWithInlineNote } from "../extensions/pi-ask-tool/ask-inline-ui.js";
import { askQuestionsWithTabs } from "../extensions/pi-ask-tool/ask-tabs-ui.js";
import type { AskQuestion, AskSelection } from "../extensions/pi-ask-tool/ask-logic.js";
// =============================================================================
// Public API
// =============================================================================
export interface AskBridge {
/** Path to the generated --mcp-config JSON, suitable for `claude --mcp-config`. */
mcpConfigPath: string;
/** Absolute path to the underlying Unix socket (informational). */
socketPath: string;
/** How many ask requests this bridge has served so far. */
count(): number;
/** Stop accepting connections, remove socket + temp dir. Idempotent. */
close(): void;
}
export interface StartAskBridgeOptions {
/** pi UI context (must come from an interactive session). */
ui: ExtensionUIContext;
/**
* Absolute path to extensions/pi-ask-mcp/server.js. Auto-derived from
* import.meta.url when omitted (assumes the conventional layout).
*/
mcpServerEntry?: string;
/**
* MCP server name surfaced in the tool prefix. Defaults to "pi", which
* makes the tool name `mcp__pi__ask` in Claude's tool stream.
*/
serverName?: string;
/** Optional notification fired whenever a new ask is served. */
onAsk?: (totalSoFar: number) => void;
}
export function startAskBridge(opts: StartAskBridgeOptions): AskBridge {
const dir = mkdtempSync(join(tmpdir(), "pi-ask-")); // 0700 perms
const sock = join(dir, "ask.sock");
let askCount = 0;
let closed = false;
const server: NetServer = createServer((conn) =>
handleConnection(conn, opts, () => {
askCount += 1;
opts.onAsk?.(askCount);
return askCount;
}),
);
server.on("error", () => { /* socket disappeared, etc. — bridge is single-tenant, ignore */ });
server.listen(sock);
const mcpEntry = opts.mcpServerEntry ?? defaultMcpEntry();
const serverName = opts.serverName ?? "pi";
const cfgPath = join(dir, "mcp.json");
writeFileSync(cfgPath, JSON.stringify({
mcpServers: {
[serverName]: {
command: "node",
args: [mcpEntry],
env: { PI_ASK_SOCKET: sock },
},
},
}));
return {
socketPath: sock,
mcpConfigPath: cfgPath,
count: () => askCount,
close: () => {
if (closed) return;
closed = true;
try { server.close(); } catch { /* noop */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ }
},
};
}
// =============================================================================
// Internals
// =============================================================================
function defaultMcpEntry(): string {
// shared/pi-ask-bridge.ts → ../extensions/pi-ask-mcp/server.js
const here = dirname(fileURLToPath(import.meta.url));
return join(here, "..", "extensions", "pi-ask-mcp", "server.js");
}
// pi.ui.custom is modal — only one overlay can be on screen at a time.
// Serialise asks across ALL connections via this single promise chain.
let askChain: Promise<unknown> = Promise.resolve();
function handleConnection(
conn: Socket,
opts: StartAskBridgeOptions,
bumpCount: () => number,
) {
let buf = "";
conn.on("data", (data) => {
buf += data.toString();
let nl = buf.indexOf("\n");
while (nl >= 0) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
handleLine(line, conn, opts, bumpCount);
nl = buf.indexOf("\n");
}
});
conn.on("error", () => { /* peer might disappear if Claude is killed mid-flight */ });
}
function handleLine(
line: string,
conn: Socket,
opts: StartAskBridgeOptions,
bumpCount: () => number,
) {
if (!line.trim()) return;
let msg: any;
try { msg = JSON.parse(line); } catch { return; }
if (msg.type !== "ask") return;
const id = String(msg.id ?? "");
const questions = Array.isArray(msg.questions) ? (msg.questions as AskQuestion[]) : [];
askChain = askChain.then(async () => {
bumpCount();
try {
const results = await askViaPiUI(opts.ui, questions);
writeReply(conn, { id, type: "result", results });
} catch (err) {
writeReply(conn, {
id,
type: "error",
message: err instanceof Error ? err.message : String(err),
});
}
});
}
function writeReply(conn: Socket, msg: unknown) {
try { conn.write(JSON.stringify(msg) + "\n"); conn.end(); } catch { /* gone */ }
}
interface QuestionResult {
id: string;
selectedOptions: string[];
customInput?: string;
}
async function askViaPiUI(
ui: ExtensionUIContext,
questions: AskQuestion[],
): Promise<QuestionResult[]> {
if (questions.length === 0) return [];
if (questions.length === 1 && !questions[0].multi) {
const sel: AskSelection = await askSingleQuestionWithInlineNote(ui, questions[0]);
return [toResult(questions[0], sel)];
}
const tab = await askQuestionsWithTabs(ui, questions);
return questions.map((q, i) => toResult(q, tab.selections[i] ?? { selectedOptions: [] }));
}
function toResult(q: AskQuestion, sel: AskSelection): QuestionResult {
const out: QuestionResult = { id: q.id, selectedOptions: [...sel.selectedOptions] };
if (sel.customInput) out.customInput = sel.customInput;
return out;
}

View File

@@ -0,0 +1,110 @@
---
name: add-agent
description: "Add a new Claude agent definition to ~/.claude/agents/ and an accompanying skill to ~/.pi/agent/skills/. Use when creating new specialized agents."
---
# Add Agent
When the user wants to create a new specialized Claude agent:
## Agent File Convention
Create a `.md` file in `~/.claude/agents/` with this structure:
```markdown
---
name: agent_name
description: One-line description of what the agent does
tools: Read, Bash[, Edit, Write] # include only what's needed
model: sonnet | opus
---
You are an [role]. [1-2 sentence description of purpose].
Available tools:
- read: Read file contents
- bash: Execute bash commands
- edit: Make surgical edits to files # if applicable
- write: Create or overwrite files # if applicable
Guidelines:
- Use bash for file operations: prefer `rg` over grep, `fd` over find, glob patterns for batch file matching
- Use read to examine files [before editing] # adapt phrasing
- [Agent-specific guidelines]
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
- Be concise in your responses
- Show file paths clearly when working with files
```
## Key Rules
1. **`name`** in frontmatter must match what skills reference as `agent: "name"`
2. **`tools`** — only include tools the agent actually needs. Read-only agents use `Read, Bash`
3. **`model`** — `sonnet` for execution/review, `opus` for strategic/advisory work
4. **Always include** the "output plain text directly" guideline — agents without it tend to use `cat`/`echo` instead of responding directly
5. **Bash guideline** should read: `prefer \`rg\` over grep, \`fd\` over find, glob patterns for batch file matching`
6. **Bash tool description** should be: `Execute bash commands`
## Skill File Convention
Create `~/.pi/agent/skills/<skill-name>/SKILL.md`:
```markdown
---
name: skill-name
description: "What triggers this skill. Use when [condition]."
---
# Skill Title
When [trigger condition]:
## What to include in the prompt
1. **[Section 1]** — description
2. **[Section 2]** — description
...
The `agent_name` agent has `Tool1` and `Tool2` tools only — [what it can/can't do].
## How to call
\```
ask_claude({
agent: "agent_name",
question: "Specific instruction for the agent.",
prompt: `
## Section 1
[Template]
## Section 2
[Template]
`
})
\```
## After the review
- [What to do with the agent's output]
- [How to summarize for the user]
- [When to loop back or escalate]
```
## Skill Key Rules
1. **Skill `name`** should be kebab-case (e.g., `claude-debug`, not `claude_debug`)
2. **Description** must be in quotes if it contains special characters
3. **`agent:` in `ask_claude()`** must match the agent's `name:` exactly
4. **Include a "What to include" section** — gives the calling agent a template
5. **Include a "How to call" section** — with a concrete `ask_claude()` example
6. **Include an "After the review" section** — what to do with the output
## Existing Agents (for reference)
| Agent | Model | Tools | Purpose |
|-------|-------|-------|---------|
| `minimal` | sonnet | Read, Bash, Edit, Write | General coding |
| `code_review` | sonnet | Read, Bash, Edit, Write | Review & fix code |
| `plan_review` | opus | Read, Bash | Review plans |
| `debug` | sonnet | Read, Bash | Trace bugs |
| `oracle` | opus | Read, Bash | Strategic guidance |

View File

@@ -0,0 +1,153 @@
---
name: ask-claude
description: "Invoke Claude (any agent or model) for an opinion, review, or analysis. Use when the user asks you to ask a specific Claude about something specific — e.g. trade-offs of an approach, a focused review, or a second opinion."
---
# Ask Claude
Use `ask_claude` to consult Claude with any combination of agent, model, question, and prompt.
## Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `prompt` | yes | The content for Claude to reason about (context, code, plan, bug report, etc.) |
| `question` | no | What specifically to ask — prepended as a focused review directive |
| `agent` | no | Agent name from `~/.claude/agents/`. See **Available agents** below. |
| `model` | no | Override the model: `"opus"`, `"sonnet"`, `"haiku"` |
| `session_id` | no | Resume a prior conversation (returned in every response) |
If neither `agent` nor `model` is set, defaults to Claude Sonnet.
> **Multi-turn:** Every response includes a `session_id`. Pass it back in a subsequent `ask_claude()` call to continue the same conversation with the same agent/model.
## Available agents
See `~/.claude/agents/` for the full list. Common agents:
| Agent | Model | Tools | Use when |
|-------|-------|-------|----------|
| `plan_review` | Opus | Read, Bash | Reviewing plans for correctness, completeness, feasibility, and risk |
| `code_review` | Sonnet | Read, Bash, Edit, Write | Reviewing implementations; can apply fixes directly |
| `debug` | Sonnet | Read, Bash | Tracing bugs and root causes; will NOT apply fixes |
| `oracle` | Opus | Read, Bash | Hard problems, architectural decisions, when you're stuck |
Pick the agent that matches the task. If unsure, ask the user which agent to use, or use a raw `model=` instead of an agent.
## Common patterns
### Plan review
```
ask_claude({
agent: "plan_review",
question: "Review for correctness, completeness, feasibility, and risk. Highlight missing steps or unclear requirements.",
prompt: `
## Project Context
[Key facts from CLAUDE.md]
## Codebase Exploration
[Modules/files you read]
## Implementation Plan
[Your full plan, step by step]
## Open Questions
[Anything uncertain]
`
})
```
### Code review (with fix capability)
```
ask_claude({
agent: "code_review",
question: "Review for bugs, architectural issues, style, and correctness. Apply fixes for any issues you find.",
prompt: `
## Project Conventions
[Relevant context not in CLAUDE.md]
## What Was Implemented
[Brief description]
## Plan That Was Followed
[The implementation plan]
## Files Changed
- path/to/file.ts — [what changed]
## Code to Review
[Paste key sections OR instruct the agent to read the files above]
`
})
```
### Debugging
```
ask_claude({
agent: "debug",
question: "Trace the root cause. Provide specific file paths, line numbers, and the exact code responsible. Suggest a fix but do not apply it.",
prompt: `
## The Issue
[What's happening vs. what should happen]
## Error Output
[Any logs, stack traces, or error messages]
## Relevant Files
- path/to/file.ts — [why it's relevant]
## What I've Already Tried
[Hypotheses tested, things ruled out]
`
})
```
### Oracle (hard problems)
```
ask_claude({
agent: "oracle",
question: "Analyze this problem and provide guidance. Explain your reasoning and recommend the best path forward.",
prompt: `
## The Problem
[What you're trying to solve and why it's hard]
## Codebase Context
[Relevant files, modules, and patterns explored]
## Options Considered
[Approaches evaluated and their trade-offs]
## What I Need Clarified
[Specific question or decision point]
`
})
```
### Free-form model question (no agent)
```
ask_claude({
model: "opus",
question: "What are the trade-offs of this approach vs using a message queue?",
prompt: `We're considering polling a database table every 5 seconds for new jobs instead of a queue...`
})
```
### Continuing a conversation
When Claude returns a `session_id` in its response, pass it back to continue the same conversation:
```
ask_claude({
agent: "code_review",
question: "Apply the fixes you identified.",
session_id: "<session_id from prior response>",
prompt: "Please proceed with the fixes."
})
```
## After the response
- Summarize Claude's key points for the user.
- If Claude raises blockers or important concerns, address them before proceeding.
- For plan reviews: if blockers or missing steps are flagged, update the plan and re-invoke if changes are significant.
- For code reviews: Claude (via `code_review`) may apply fixes directly — summarize what was changed.
- For debugging: apply the fix yourself or delegate to a coding agent.
- For oracle: proceed with the recommended approach, or loop back if more clarification is needed.

View File

@@ -0,0 +1,50 @@
---
name: implementor
description: "Delegate implementation of a fix or feature to the implementor agent. Use when you have a clear plan, bug fix, or feature to implement and want a focused agent to handle the full coding + build-verify cycle."
---
# Implementor
When you need to implement a fix, feature, or refactoring with full build verification:
## What to include in the prompt
1. **Goal** — what needs to be built or fixed, in 1-2 sentences
2. **Context** — relevant files, functions, or modules (include the actual code or file paths)
3. **Plan** — step-by-step description of the changes to make (if you have one)
4. **Constraints** — any project conventions, patterns to follow, or things to avoid
5. **Verification** — how to confirm the implementation is correct (build command, test names, etc.)
The `implementor` agent has `Read`, `Bash`, `Edit`, and `Write` tools only — it can read code, run commands, and make changes. It cannot use semantic search or web search.
## How to call
```
ask_claude({
agent: "implementor",
question: "Implement [brief description of the fix or feature].",
prompt: `
## Goal
[What needs to be done]
## Context
[Relevant files and code snippets]
## Plan
[Step-by-step changes, or "investigate and determine the best approach"]
## Constraints
[Project conventions, patterns to follow, things to avoid]
## Verification
[Build command, tests to run, how to confirm correctness]
`
})
```
## After the review
- Check whether the agent reported successful build/verification
- If it failed, either re-invoke with the error details or fix the remaining issues directly
- Summarize for the user: what files changed, what was verified, and any remaining concerns
- For complex implementations, consider running `diagnostics` sub-agent or relevant tests afterward as a second pass

View File

@@ -1,24 +1,24 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "wezterm-sync-ba8a76f5",
"name": "wezterm-sync-9a35138e",
"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"
"bg": "#faf4ed",
"fg": "#2a2a2a",
"accent": "#7b4fc4",
"accentAlt": "#c45a1c",
"link": "#1a7db5",
"error": "#d1344f",
"success": "#1e9b52",
"warning": "#b8890f",
"muted": "#73716e",
"dim": "#9c9995",
"borderMuted": "#c6c2bc",
"selectedBg": "#eee8e1",
"userMsgBg": "#f2ece5",
"toolPendingBg": "#f5efe8",
"toolSuccessBg": "#e0e9da",
"toolErrorBg": "#f5ddda",
"customMsgBg": "#f0e7ea"
},
"colors": {
"accent": "accent",
@@ -74,8 +74,8 @@
"bashMode": "success"
},
"export": {
"pageBg": "#141c2b",
"cardBg": "#1c2433",
"infoBg": "#353839"
"pageBg": "#fffcf5",
"cardBg": "#faf4ed",
"infoBg": "#f2e7d2"
}
}

View File

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

View File

@@ -4,6 +4,8 @@ exec_always ~/.config/sway/scripts/workspace-colors.py
exec_always eww daemon && (eww open bar0 || true) && (eww open bar1 || true)
exec_always mako
exec /usr/libexec/polkit-mate-authentication-agent-1
exec swayidle -w \
timeout 900 '~/.local/bin/smart-dim.sh' \
resume 'brightnessctl -r'
@@ -143,6 +145,9 @@ mode "resize" {
}
bindsym $mod+r mode "resize"
#
# Toggle dark/light theme
bindsym $mod+y exec ~/.config/sway/scripts/toggle-theme.sh
# Utilities:
#
# Special keys to adjust volume via PulseAudio

View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Toggle between dark and light themes for wezterm and nvim
# Source of truth: ~/.config/theme-state
STATE_FILE="$HOME/.config/theme-state"
# Read current state
current="dark"
if [ -f "$STATE_FILE" ]; then
current=$(cat "$STATE_FILE" | tr -d '[:space:]')
fi
# Toggle
if [ "$current" = "light" ]; then
next="dark"
else
next="light"
fi
# Write new state
# - wezterm watches this file via add_to_config_reload_watch_list and auto-reloads
# - nvim watches this file via vim.uv.fs_event and auto-switches colorscheme
echo "$next" > "$STATE_FILE"

View File

@@ -0,0 +1,81 @@
-- Bearded Arc (Light)
-- Hand-tuned light variant with warm off-white bg and saturated accents
local M = {}
M.palette = {
base = '#faf4ed',
overlay = '#f0e8df',
muted = '#7a8598',
text = '#2a2a2a',
red = '#d1344f',
yellow = '#b8890f',
orange = '#c45a1c',
green = '#1e9b52',
cyan = '#1a7db5',
purple = '#7b4fc4',
highlight = '#5a6a7a',
}
local active_tab = {
bg_color = M.palette.overlay,
fg_color = M.palette.text,
}
local inactive_tab = {
bg_color = M.palette.base,
fg_color = M.palette.muted,
}
function M.colors()
return {
foreground = M.palette.text,
background = M.palette.base,
cursor_bg = M.palette.yellow,
cursor_border = M.palette.yellow,
cursor_fg = M.palette.base,
selection_bg = '#d8d0c6',
selection_fg = M.palette.text,
ansi = {
M.palette.overlay,
M.palette.red,
M.palette.green,
M.palette.yellow,
M.palette.cyan,
M.palette.purple,
M.palette.orange,
M.palette.text,
},
brights = {
M.palette.muted,
M.palette.red,
M.palette.green,
M.palette.yellow,
M.palette.cyan,
M.palette.purple,
M.palette.orange,
M.palette.text,
},
tab_bar = {
background = M.palette.base,
active_tab = active_tab,
inactive_tab = inactive_tab,
inactive_tab_hover = active_tab,
new_tab = inactive_tab,
new_tab_hover = active_tab,
inactive_tab_edge = M.palette.muted,
},
}
end
function M.window_frame()
return {
active_titlebar_bg = M.palette.base,
inactive_titlebar_bg = M.palette.base,
}
end
return M

View File

@@ -0,0 +1,81 @@
-- Bearded Arc (Dark)
-- Based on: https://github.com/BeardedBear/bearded-theme
local M = {}
M.palette = {
base = '#1c2433',
overlay = '#212a3b',
muted = '#4a5e84',
text = '#afbbd2',
red = '#FF738A',
yellow = '#EACD61',
orange = '#FF955C',
green = '#3CEC85',
cyan = '#69C3FF',
purple = '#B78AFF',
highlight = '#8196b5',
}
local active_tab = {
bg_color = M.palette.overlay,
fg_color = M.palette.text,
}
local inactive_tab = {
bg_color = M.palette.base,
fg_color = M.palette.muted,
}
function M.colors()
return {
foreground = M.palette.text,
background = M.palette.base,
cursor_bg = M.palette.yellow,
cursor_border = M.palette.yellow,
cursor_fg = M.palette.text,
selection_bg = '#405275',
selection_fg = M.palette.text,
ansi = {
M.palette.overlay,
M.palette.red,
M.palette.green,
M.palette.yellow,
M.palette.cyan,
M.palette.purple,
M.palette.orange,
M.palette.text,
},
brights = {
M.palette.muted,
M.palette.red,
M.palette.green,
M.palette.yellow,
M.palette.cyan,
M.palette.purple,
M.palette.orange,
M.palette.text,
},
tab_bar = {
background = M.palette.base,
active_tab = active_tab,
inactive_tab = inactive_tab,
inactive_tab_hover = active_tab,
new_tab = inactive_tab,
new_tab_hover = active_tab,
inactive_tab_edge = M.palette.muted,
},
}
end
function M.window_frame()
return {
active_titlebar_bg = M.palette.base,
inactive_titlebar_bg = M.palette.base,
}
end
return M

View File

@@ -1,84 +1,11 @@
-- Bearded Arc Theme
-- Based on: https://github.com/BeardedBear/bearded-theme
-- Adapted for WezTerm
-- Theme registry
-- Standalone themes: bearded-arc (dark), bearded-arc-light
-- Legacy variants: moon, dawn
local main = {}
local main_palette = {
base = '#1c2433',
overlay = '#212a3b',
muted = '#4a5e84',
text = '#afbbd2',
red = '#FF738A',
yellow = '#EACD61',
orange = '#FF955C',
green = '#3CEC85',
cyan = '#69C3FF',
purple = '#B78AFF',
highlight = '#8196b5',
}
local main_active_tab = {
bg_color = main_palette.overlay,
fg_color = main_palette.text,
}
local main_inactive_tab = {
bg_color = main_palette.base,
fg_color = main_palette.muted,
}
function main.colors()
return {
foreground = main_palette.text,
background = main_palette.base,
cursor_bg = main_palette.yellow,
cursor_border = main_palette.yellow,
cursor_fg = main_palette.text,
selection_bg = '#405275',
selection_fg = main_palette.text,
ansi = {
main_palette.overlay,
main_palette.red,
main_palette.green,
main_palette.yellow,
main_palette.cyan,
main_palette.purple,
main_palette.orange,
main_palette.text,
},
brights = {
main_palette.muted,
main_palette.red,
main_palette.green,
main_palette.yellow,
main_palette.cyan,
main_palette.purple,
main_palette.orange,
main_palette.text,
},
tab_bar = {
background = main_palette.base,
active_tab = main_active_tab,
inactive_tab = main_inactive_tab,
inactive_tab_hover = main_active_tab,
new_tab = main_inactive_tab,
new_tab_hover = main_active_tab,
inactive_tab_edge = main_palette.muted, -- (Fancy tab bar only)
},
}
end
function main.window_frame() -- (Fancy tab bar only)
return {
active_titlebar_bg = main_palette.base,
inactive_titlebar_bg = main_palette.base,
}
end
local main = require('theme/bearded-arc')
local light = require('theme/bearded-arc-light')
-- Moon variant (kept here for backward compat)
local moon = {}
local moon_palette = {
@@ -144,18 +71,19 @@ function moon.colors()
inactive_tab_hover = moon_active_tab,
new_tab = moon_inactive_tab,
new_tab_hover = moon_active_tab,
inactive_tab_edge = moon_palette.muted, -- (Fancy tab bar only)
inactive_tab_edge = moon_palette.muted,
},
}
end
function moon.window_frame() -- (Fancy tab bar only)
function moon.window_frame()
return {
active_titlebar_bg = moon_palette.base,
inactive_titlebar_bg = moon_palette.base,
}
end
-- Dawn variant (kept here for backward compat)
local dawn = {}
local dawn_palette = {
@@ -221,12 +149,12 @@ function dawn.colors()
inactive_tab_hover = dawn_active_tab,
new_tab = dawn_inactive_tab,
new_tab_hover = dawn_active_tab,
inactive_tab_edge = dawn_palette.muted, -- (Fancy tab bar only)
inactive_tab_edge = dawn_palette.muted,
},
}
end
function dawn.window_frame() -- (Fancy tab bar only)
function dawn.window_frame()
return {
active_titlebar_bg = dawn_palette.base,
inactive_titlebar_bg = dawn_palette.base,
@@ -235,6 +163,7 @@ end
return {
main = main,
light = light,
moon = moon,
dawn = dawn,
}

View File

@@ -1,12 +1,31 @@
local wezterm = require 'wezterm'
local config = {}
local theme = require('theme/init').main
local themes = require('theme/init')
-- Watch theme-state file so wezterm auto-reloads when toggle script writes to it
wezterm.add_to_config_reload_watch_list(
os.getenv("HOME") .. "/.config/theme-state"
)
-- Determine current theme from state file
local function get_theme()
local f = io.open(os.getenv("HOME") .. "/.config/theme-state", "r")
if f then
local state = f:read("*a"):gsub("%s+", "")
f:close()
if state == "light" then return themes.light end
end
return themes.main
end
local theme = get_theme()
-- Font configuration
config.font = wezterm.font 'JetBrainsMono Nerd Font'
config.font_size = 11.0
config.colors = theme.colors()
config.window_frame = theme.window_frame()
-- Window
config.enable_tab_bar = true
@@ -33,6 +52,13 @@ config.enable_kitty_keyboard = true
config.keys = {
{ key = 'LeftArrow', mods = 'CTRL|SHIFT', action = wezterm.action.ActivateTabRelative(-1) },
{ key = 'RightArrow', mods = 'CTRL|SHIFT', action = wezterm.action.ActivateTabRelative(1) },
{
key = 'n',
mods = 'CTRL|SHIFT',
action = wezterm.action_callback(function(win, pane)
local tab, window = pane:move_to_new_window()
end),
},
}
-- Machine-local overrides (not tracked in dotfiles)

View File

@@ -1,5 +1,6 @@
export PATH="$HOME/.local/share/nvim/mason/bin:$PATH"
export PATH="$PATH:/home/jonas/.local/bin"
export PATH="$HOME/go/bin:$PATH"
# History configuration
HISTFILE=~/.zsh_history
@@ -47,11 +48,10 @@ export USE_BUILTIN_RIPGREP=0
export REAL_DEBRID_API_KEY="HQVUOC3ALUHIIQCFQK4UOV2GVGVLQKKHEWFWKN77G6URFBTQMUTQ"
export HF_TOKEN=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
export OPENCODE_API_KEY=sk-oU07900FhkKHUSpwEI8SiWjoZmV2kg8TUhVwfRWcPOjaHHq8b0dCPrb1PQNuLnh0
export LLAMA_CACHE="/home/jonas/gguf_models"
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"
@@ -72,3 +72,10 @@ if command -v tmux &>/dev/null && [ -z "$TMUX" ]; then
tmux new-session -As moshi
fi
fi
# Android build environment
export ANDROID_NDK_HOME="$HOME/Android/Sdk/ndk/27.2.12479018"
export ANDROID_KEYSTORE_PATH="$HOME/projects/brain/upload.jks"
export ANDROID_KEYSTORE_PASSWORD="ArtHouse5"
export ANDROID_KEY_ALIAS="upload"
export ANDROID_KEY_PASSWORD="ArtHouse5"