From abb9ef5b631da16460db6fe788a60432ae604ceb Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 28 Mar 2026 12:23:26 +0100 Subject: [PATCH 1/5] sync dotfiles on Mac Mini --- macos/ghostty/config | 5 +++++ macos/zshrc | 18 +++++++++++++++--- shared/nvim/lazy-lock.json | 34 +++++++++++++++++----------------- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/macos/ghostty/config b/macos/ghostty/config index a3d09a1..d257e6e 100644 --- a/macos/ghostty/config +++ b/macos/ghostty/config @@ -1,4 +1,9 @@ # macOS-specific Ghostty configuration config-file = ~/dotfiles/shared/ghostty/config +cursor-style-blink = false +shell-integration-features = no-cursor,ssh-terminfo,ssh-env +font-family = Ioskeley Mono +font-size = 14 +window-decoration = true macos-non-native-fullscreen = true diff --git a/macos/zshrc b/macos/zshrc index f8fcf39..2977f2a 100644 --- a/macos/zshrc +++ b/macos/zshrc @@ -7,6 +7,7 @@ alias la='ls -la' alias vim="nvim" alias vi="nvim" alias lg="lazygit" +alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale" # the code are the 256 color codes # refrence here --> https://www.ditig.com/256-colors-cheat-sheet @@ -51,9 +52,20 @@ function y() { rm -f -- "$tmp" } -# bun completions -[ -s "/Users/matteo/.bun/_bun" ] && source "/Users/matteo/.bun/_bun" - # bun export BUN_INSTALL="$HOME/.bun" export PATH="$BUN_INSTALL/bin:$PATH" +[ -s "$BUN_INSTALL/_bun" ] && source "$BUN_INSTALL/_bun" + +# pnpm +export PNPM_HOME="/Users/kai/Library/pnpm" +case ":$PATH:" in + *":$PNPM_HOME:"*) ;; + *) export PATH="$PNPM_HOME:$PATH" ;; +esac +# pnpm end + +# OpenClaw Completion +autoload -Uz compinit +compinit +source "/Users/kai/.openclaw/completions/openclaw.zsh" diff --git a/shared/nvim/lazy-lock.json b/shared/nvim/lazy-lock.json index f41b0f1..369a9d7 100644 --- a/shared/nvim/lazy-lock.json +++ b/shared/nvim/lazy-lock.json @@ -1,28 +1,28 @@ { - "LuaSnip": { "branch": "master", "commit": "0abc8f390b278c3b4aabc4c004ac8a088b65cf24" }, - "blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" }, - "catppuccin": { "branch": "main", "commit": "79e2049a0fdf7ec840f8463fe1d962c94493196b" }, - "conform.nvim": { "branch": "master", "commit": "619363c30309d29ffa631e67c8183f2a72caa373" }, + "LuaSnip": { "branch": "master", "commit": "dae4f5aaa3574bd0c2b9dd20fb9542a02c10471c" }, + "blink.cmp": { "branch": "main", "commit": "451168851e8e2466bc97ee3e026c3dcb9141ce07" }, + "catppuccin": { "branch": "main", "commit": "edefef779ab08ce1a4a404713e3012b0d202bd35" }, + "conform.nvim": { "branch": "master", "commit": "016802de402556da54c36bd7359b441266b01cdd" }, "fidget.nvim": { "branch": "main", "commit": "6f793b2bcd2d35e201c09520f698bb763220908a" }, "friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" }, - "fzf-lua": { "branch": "main", "commit": "28440c741e927592877514fba3db21eb188caec8" }, - "gitsigns.nvim": { "branch": "main", "commit": "31d6fb2d618bca1482b9f274751ead5f03461408" }, - "lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" }, + "fzf-lua": { "branch": "main", "commit": "6e41ba7505f35a6f054ca682b043aa0cea36c2ef" }, + "gitsigns.nvim": { "branch": "main", "commit": "7c4faa3540d0781a28588cafbd4dd187a28ac6e3" }, + "lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" }, "lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" }, - "mason-lspconfig.nvim": { "branch": "main", "commit": "7adc933dabcc7c86ae6b07aff7ee68eac398491f" }, + "mason-lspconfig.nvim": { "branch": "main", "commit": "a676ab7282da8d651e175118bcf54483ca11e46d" }, "mason-tool-installer.nvim": { "branch": "main", "commit": "443f1ef8b5e6bf47045cb2217b6f748a223cf7dc" }, - "mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" }, - "mini.nvim": { "branch": "main", "commit": "946ae64e0ee807ae3c41f382f0114b4ed4915b2c" }, - "nvim-lspconfig": { "branch": "master", "commit": "b7b920947f21339ee41fbb38c79d6445e12900aa" }, - "nvim-tree.lua": { "branch": "master", "commit": "4213bd6eabac38b16dd6615002b6243b23cf3bf6" }, - "nvim-treesitter": { "branch": "main", "commit": "61df84986b4b4ec469ee745a182e433d49f8c27e" }, - "nvim-treesitter-context": { "branch": "master", "commit": "b311b30818951d01f7b4bf650521b868b3fece16" }, + "mason.nvim": { "branch": "main", "commit": "44d1e90e1f66e077268191e3ee9d2ac97cc18e65" }, + "mini.nvim": { "branch": "main", "commit": "9990c41f10f54f29a888d13024c9f765037bde23" }, + "nvim-lspconfig": { "branch": "master", "commit": "dd261ad5266ab5bbec249d21efeceda98ff3e1a6" }, + "nvim-tree.lua": { "branch": "master", "commit": "b3772adec8db61ba9098c5624a0823a77be3a23d" }, + "nvim-treesitter": { "branch": "main", "commit": "2f5d4c3f3c675962242096bcc8e586d76dd72eb2" }, + "nvim-treesitter-context": { "branch": "master", "commit": "f3061339b8eaf9fda873600bc425b8d2d8502533" }, "nvim-treesitter-textobjects": { "branch": "main", "commit": "898ee307df58f854d11cd7edd06472574d48014e" }, - "nvim-web-devicons": { "branch": "master", "commit": "2ae6958df7ced50baac5035cec0c15799eedfbf7" }, + "nvim-web-devicons": { "branch": "master", "commit": "d7462543c9e366c0d196c7f67a945eaaf5d99414" }, "outline.nvim": { "branch": "main", "commit": "2a132953b944561d45b52e4541ebfff71934a742" }, - "render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" }, + "render-markdown.nvim": { "branch": "main", "commit": "e3c18ddd27a853f85a6f513a864cf4f2982b9f26" }, "snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" }, "tiny-inline-diagnostic.nvim": { "branch": "main", "commit": "6264451f14119d63a52580e5198d6baf8518b0b2" }, - "vimtex": { "branch": "master", "commit": "853eb587873a7061a083655578be56dd8027b834" }, + "vimtex": { "branch": "master", "commit": "82d2305ff71dfb3bd91602534cc9bb9a195bcb38" }, "which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" } } From a65be8770092329ccf3d5ff064896960386b44a2 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 29 Aug 2026 11:18:20 +0200 Subject: [PATCH 2/5] configure neovim classroom tooling --- install.sh | 3 +- shared/nvim/init.lua | 3 + shared/nvim/lua/plugins/format.lua | 2 + shared/nvim/lua/plugins/lsp.lua | 44 ++++++++----- shared/nvim/lua/test_runner.lua | 99 ++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 shared/nvim/lua/test_runner.lua diff --git a/install.sh b/install.sh index e268a97..985a240 100755 --- a/install.sh +++ b/install.sh @@ -53,7 +53,7 @@ install_apps() { /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" fi # tree-sitter-cli: nvim-treesitter main branch builds parsers with it - brew install neovim uv yazi tmux lazygit btop fzf fd ripgrep tree-sitter-cli imagemagick ghostscript mermaid-cli bun + brew install neovim uv yazi tmux lazygit btop fzf fd ripgrep tree-sitter-cli clang-format imagemagick ghostscript mermaid-cli bun else # Detect architecture ARCH=$(uname -m) # x86_64 or aarch64 @@ -105,6 +105,7 @@ install_apps() { btop \ build-essential \ ca-certificates \ + clang-format \ curl \ git \ gzip \ diff --git a/shared/nvim/init.lua b/shared/nvim/init.lua index ec32c02..e94d0d7 100644 --- a/shared/nvim/init.lua +++ b/shared/nvim/init.lua @@ -138,6 +138,9 @@ map("n", "yp", function() yank_path(vim.fn.expand("%:p")) end, { desc = map("n", "yr", function() yank_path(vim.fn.fnamemodify(vim.fn.expand("%"), ":.")) end, { desc = "Yank relative path" }) map("n", "yn", function() yank_path(vim.fn.expand("%:t")) end, { desc = "Yank filename" }) +-- Run the nearest Python or CMake project's tests in a terminal. +require("test_runner").setup() + -- Edit map("v", "J", ":m '>+1gv=gv") map("v", "K", ":m '<-2gv=gv") diff --git a/shared/nvim/lua/plugins/format.lua b/shared/nvim/lua/plugins/format.lua index 855ce67..2551561 100644 --- a/shared/nvim/lua/plugins/format.lua +++ b/shared/nvim/lua/plugins/format.lua @@ -36,6 +36,8 @@ return { end, formatters_by_ft = { python = { "ruff_fix", "ruff_format" }, + c = { "clang-format" }, + cpp = { "clang-format" }, lua = { "stylua" }, json = { "jq" }, sh = { "shfmt" }, diff --git a/shared/nvim/lua/plugins/lsp.lua b/shared/nvim/lua/plugins/lsp.lua index 9afaffc..cd74417 100644 --- a/shared/nvim/lua/plugins/lsp.lua +++ b/shared/nvim/lua/plugins/lsp.lua @@ -13,26 +13,38 @@ return { "neovim/nvim-lspconfig", event = { "BufReadPre", "BufNewFile" }, config = function() - local lspconfig = require("lspconfig") - + -- Mason installs the managed servers, but activation stays explicit so + -- system servers such as clangd follow the same predictable path. require("mason-lspconfig").setup({ - handlers = { - function(server_name) - lspconfig[server_name].setup({}) - end, - ["basedpyright"] = function() - lspconfig.basedpyright.setup({ - settings = { - basedpyright = { typeCheckingMode = "standard" }, - }, - }) - end, - ["ruff"] = function() - lspconfig.ruff.setup({}) - end, + automatic_enable = false, + }) + + vim.lsp.config("basedpyright", { + settings = { + basedpyright = { + analysis = { + typeCheckingMode = "standard", + }, + }, }, }) + -- clangd comes from Xcode/Homebrew rather than Mason. Including + -- CMakeLists.txt lets each classroom exercise form its own LSP root. + vim.lsp.config("clangd", { + root_markers = { + ".clangd", + ".clang-tidy", + ".clang-format", + "compile_commands.json", + "compile_flags.txt", + "CMakeLists.txt", + ".git", + }, + }) + + vim.lsp.enable({ "basedpyright", "ruff", "texlab", "clangd" }) + vim.api.nvim_create_autocmd("LspAttach", { group = vim.api.nvim_create_augroup("UserLspConfig", { clear = true }), callback = function(args) diff --git a/shared/nvim/lua/test_runner.lua b/shared/nvim/lua/test_runner.lua new file mode 100644 index 0000000..4bb6961 --- /dev/null +++ b/shared/nvim/lua/test_runner.lua @@ -0,0 +1,99 @@ +local M = {} + +local markers = { + { name = "pyproject.toml", kind = "python" }, + { name = "CMakeLists.txt", kind = "cmake" }, +} + +local function marker_at(dir, preferred_kind) + if preferred_kind then + for _, marker in ipairs(markers) do + if marker.kind == preferred_kind and vim.uv.fs_stat(vim.fs.joinpath(dir, marker.name)) then + return marker + end + end + end + + for _, marker in ipairs(markers) do + if vim.uv.fs_stat(vim.fs.joinpath(dir, marker.name)) then + return marker + end + end +end + +local function find_from(start, preferred_kind) + local dir = vim.fs.normalize(start) + while dir do + local marker = marker_at(dir, preferred_kind) + if marker then + return { root = dir, kind = marker.kind, marker = marker.name } + end + + local parent = vim.fs.dirname(dir) + if not parent or parent == dir then + break + end + dir = parent + end +end + +function M.find_project(bufnr) + bufnr = bufnr or 0 + local filetype = vim.bo[bufnr].filetype + local preferred_kind = filetype == "python" and "python" + or ({ c = true, cpp = true, objc = true, objcpp = true, cuda = true })[filetype] and "cmake" + or nil + local cwd = vim.fn.getcwd() + local name = vim.api.nvim_buf_get_name(bufnr) + + if name ~= "" then + local project = find_from(vim.fs.dirname(name), preferred_kind) + if project then + return project + end + end + + return find_from(cwd, preferred_kind) +end + +local commands = { + python = "uv run pytest", + cmake = table.concat({ + "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + "cmake -E copy_if_different build/compile_commands.json compile_commands.json", + "cmake --build build", + "ctest --test-dir build --output-on-failure", + }, " && "), +} + +function M.run() + local project = M.find_project(0) + if not project then + vim.notify("No pyproject.toml or CMakeLists.txt found above the buffer or working directory", vim.log.levels.ERROR) + return + end + + vim.cmd("botright new") + local job = vim.fn.jobstart({ "sh", "-c", commands[project.kind] }, { + cwd = project.root, + term = true, + }) + if job <= 0 then + vim.cmd("bdelete!") + vim.notify("Could not start test command", vim.log.levels.ERROR) + return + end + vim.cmd("startinsert") +end + +function M.setup() + vim.api.nvim_create_user_command("RunTests", M.run, { + desc = "Run tests for the nearest Python or CMake project", + }) + vim.keymap.set("n", "tt", "RunTests", { + desc = "Run project tests", + silent = true, + }) +end + +return M From eb0b32ccd7a5f2a0b7c86228705c27c0bb5d80fe Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 29 Aug 2026 11:36:03 +0200 Subject: [PATCH 3/5] make remote classroom setup portable --- README.md | 6 ++++++ install.sh | 29 ++++++++++++++++++++++++++--- macos/zshrc | 4 ++-- shared/nvim/init.lua | 5 +++-- shared/nvim/lua/plugins/format.lua | 8 +++++++- shared/nvim/lua/test_runner.lua | 1 - 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 351d5ea..8de2973 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ git clone https://github.com/maedmatt/dotfiles.git ~/dotfiles && cd ~/dotfiles ./install.sh --all # everything ``` +`--apps` installs both tmux and Herdr. Herdr uses its own terminal runtime, so +tmux remains available as an independent fallback rather than a dependency. +It also installs the shared Neovim toolchain: `uv`, CMake/CTest, `clangd`, +`clang-format`, and the Tree-sitter CLI. +After connecting over SSH, run `herdr` to start or attach to the multiplexer. + ## Linux The app installer supports Debian/Ubuntu on `x86_64` and `aarch64`. It uses diff --git a/install.sh b/install.sh index 985a240..8fcf471 100755 --- a/install.sh +++ b/install.sh @@ -53,7 +53,7 @@ install_apps() { /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" fi # tree-sitter-cli: nvim-treesitter main branch builds parsers with it - brew install neovim uv yazi tmux lazygit btop fzf fd ripgrep tree-sitter-cli clang-format imagemagick ghostscript mermaid-cli bun + brew install neovim uv cmake yazi tmux herdr lazygit btop fzf fd ripgrep tree-sitter-cli clang-format imagemagick ghostscript mermaid-cli bun else # Detect architecture ARCH=$(uname -m) # x86_64 or aarch64 @@ -105,7 +105,9 @@ install_apps() { btop \ build-essential \ ca-certificates \ + clangd \ clang-format \ + cmake \ curl \ git \ gzip \ @@ -135,8 +137,17 @@ install_apps() { rm -f /tmp/fd.deb fi - # neovim - if ! command -v nvim &> /dev/null; then + # Keep every workstation on the shared, tested Neovim 0.12 baseline. + # Replace older distro/manual installs too. + nvim_works() { + local version + + command -v nvim &> /dev/null || return 1 + version=$(nvim --version 2>/dev/null | awk 'NR == 1 { sub(/^v/, "", $2); print $2 }') || return 1 + [ -n "$version" ] && dpkg --compare-versions "$version" ge 0.12.0 + } + + if ! nvim_works; then download /tmp/nvim.tar.gz \ "https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${NVIM_ARCH}.tar.gz" @@ -187,6 +198,12 @@ install_apps() { rm -f /tmp/tree-sitter.gz /tmp/tree-sitter fi + # uv drives every Python classroom project and installs into + # ~/.local/bin, which linux/bashrc already adds to PATH. + if ! command -v uv &> /dev/null; then + curl -LsSf https://astral.sh/uv/install.sh | sh + fi + # lazygit if ! command -v lazygit &> /dev/null; then V=$(gh_latest jesseduffield/lazygit) @@ -225,6 +242,12 @@ install_apps() { --no-zsh \ --no-fish + # Herdr publishes a self-updating binary for Linux. It is independent + # of tmux; tmux remains installed as a separate fallback multiplexer. + if ! command -v herdr &> /dev/null; then + curl -fsSL https://herdr.dev/install.sh | sh + fi + fi } diff --git a/macos/zshrc b/macos/zshrc index 2977f2a..41a5aa2 100644 --- a/macos/zshrc +++ b/macos/zshrc @@ -58,7 +58,7 @@ export PATH="$BUN_INSTALL/bin:$PATH" [ -s "$BUN_INSTALL/_bun" ] && source "$BUN_INSTALL/_bun" # pnpm -export PNPM_HOME="/Users/kai/Library/pnpm" +export PNPM_HOME="$HOME/Library/pnpm" case ":$PATH:" in *":$PNPM_HOME:"*) ;; *) export PATH="$PNPM_HOME:$PATH" ;; @@ -68,4 +68,4 @@ esac # OpenClaw Completion autoload -Uz compinit compinit -source "/Users/kai/.openclaw/completions/openclaw.zsh" +[ -r "$HOME/.openclaw/completions/openclaw.zsh" ] && source "$HOME/.openclaw/completions/openclaw.zsh" diff --git a/shared/nvim/init.lua b/shared/nvim/init.lua index e94d0d7..d0504a6 100644 --- a/shared/nvim/init.lua +++ b/shared/nvim/init.lua @@ -7,8 +7,9 @@ vim.g.loaded_netrwPlugin = 1 -- Editor behavior vim.opt.mouse = "a" --- Use OSC 52 for remote sessions; prefer the native provider locally. -if vim.env.SSH_CONNECTION then +-- Use OSC 52 for SSH and Herdr panes; prefer the native provider locally. +-- Herdr's long-lived server may outlive the original SSH environment. +if vim.env.SSH_CONNECTION or vim.env.HERDR_ENV == "1" then vim.g.clipboard = "osc52" end vim.opt.clipboard = "unnamedplus" diff --git a/shared/nvim/lua/plugins/format.lua b/shared/nvim/lua/plugins/format.lua index 2551561..c5574ec 100644 --- a/shared/nvim/lua/plugins/format.lua +++ b/shared/nvim/lua/plugins/format.lua @@ -1,5 +1,7 @@ -- Formatting on save through conform, with the LSP as fallback. -- :ConformDisable turns it off globally, :ConformDisable! for this buffer only. +local manual_format_only = { c = true, cpp = true, objc = true, objcpp = true, cuda = true } + vim.api.nvim_create_user_command("ConformDisable", function(args) if args.bang then vim.b.disable_autoformat = true @@ -29,7 +31,11 @@ return { notify_on_error = false, default_format_opts = { timeout_ms = 1000, lsp_format = "fallback" }, format_after_save = function(bufnr) - if vim.g.disable_autoformat or vim.b[bufnr].disable_autoformat then + if + vim.g.disable_autoformat + or vim.b[bufnr].disable_autoformat + or manual_format_only[vim.bo[bufnr].filetype] + then return end return { timeout_ms = 1000, lsp_format = "fallback" } diff --git a/shared/nvim/lua/test_runner.lua b/shared/nvim/lua/test_runner.lua index 4bb6961..778085b 100644 --- a/shared/nvim/lua/test_runner.lua +++ b/shared/nvim/lua/test_runner.lua @@ -60,7 +60,6 @@ local commands = { python = "uv run pytest", cmake = table.concat({ "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON", - "cmake -E copy_if_different build/compile_commands.json compile_commands.json", "cmake --build build", "ctest --test-dir build --output-on-failure", }, " && "), From 6521b2fe2332dda07360975c7c12b2d1af80c6c8 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 29 Aug 2026 20:28:46 +0200 Subject: [PATCH 4/5] add Pi programming teaching mode --- shared/pi/extensions/programming-classroom.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 shared/pi/extensions/programming-classroom.ts diff --git a/shared/pi/extensions/programming-classroom.ts b/shared/pi/extensions/programming-classroom.ts new file mode 100644 index 0000000..50adbc2 --- /dev/null +++ b/shared/pi/extensions/programming-classroom.ts @@ -0,0 +1,140 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +/** + * Load Kai's canonical programming tutor skill only when Matteo wants a lesson. + * + * Interactive: /teach on | /teach off | /teach status + * New session: pi --teach + * Override the canonical skill path with PI_PROGRAMMING_CLASSROOM_SKILL. + */ +const STATE_TYPE = "programming-classroom-mode"; +const STATUS_KEY = "programming-classroom"; +const SKILL_NAME = "programming-classroom-tutor"; +const DEFAULT_SKILL_PATH = join( + homedir(), + ".openclaw", + "workspace", + "skills", + SKILL_NAME, + "SKILL.md", +); + +interface TeachingModeState { + enabled: boolean; +} + +function skillPath(): string { + return process.env.PI_PROGRAMMING_CLASSROOM_SKILL?.trim() || DEFAULT_SKILL_PATH; +} + +function savedMode(ctx: ExtensionContext): boolean | undefined { + let restored: boolean | undefined; + + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== "custom" || entry.customType !== STATE_TYPE) continue; + const data = entry.data as Partial | undefined; + if (typeof data?.enabled === "boolean") restored = data.enabled; + } + + return restored; +} + +function requestedMode(args: string, current: boolean): boolean | "status" | undefined { + const value = args.trim().toLowerCase(); + if (!value) return !current; + if (value === "on" || value === "enable" || value === "enabled") return true; + if (value === "off" || value === "disable" || value === "disabled") return false; + if (value === "status") return "status"; + return undefined; +} + +function updateStatus(ctx: ExtensionContext, enabled: boolean): void { + ctx.ui.setStatus(STATUS_KEY, enabled ? "teaching" : undefined); +} + +async function changeMode( + pi: ExtensionAPI, + args: string, + ctx: ExtensionCommandContext, + current: boolean, + setCurrent: (enabled: boolean) => void, +): Promise { + const requested = requestedMode(args, current); + if (requested === undefined) { + ctx.ui.notify("Usage: /teach [on|off|status]", "warning"); + return; + } + + if (requested === "status") { + ctx.ui.notify(`Programming teaching mode is ${current ? "enabled" : "disabled"}.`, "info"); + return; + } + + if (requested && !existsSync(skillPath())) { + ctx.ui.notify(`Tutor skill not found: ${skillPath()}`, "error"); + return; + } + + if (requested === current) { + ctx.ui.notify(`Programming teaching mode is already ${current ? "enabled" : "disabled"}.`, "info"); + return; + } + + setCurrent(requested); + pi.appendEntry(STATE_TYPE, { enabled: requested } satisfies TeachingModeState); + updateStatus(ctx, requested); + ctx.ui.notify( + requested + ? "Programming teaching mode enabled. Reloading the tutor skill." + : "Programming teaching mode disabled. Reloading Pi.", + "info", + ); + await ctx.reload(); +} + +export default function programmingClassroom(pi: ExtensionAPI) { + let enabled = false; + + pi.registerFlag("teach", { + description: "Start with programming teaching mode enabled", + type: "boolean", + default: false, + }); + + pi.on("session_start", (_event, ctx) => { + enabled = Boolean(pi.getFlag("teach")) || (savedMode(ctx) ?? false); + if (enabled && !existsSync(skillPath())) { + enabled = false; + ctx.ui.notify(`Programming teaching mode could not find ${skillPath()}`, "error"); + } + updateStatus(ctx, enabled); + }); + + pi.on("resources_discover", () => { + if (!enabled) return undefined; + return { skillPaths: [skillPath()] }; + }); + + pi.on("before_agent_start", (event) => { + if (!enabled) return undefined; + + const loaded = event.systemPromptOptions.skills?.some((skill) => skill.name === SKILL_NAME) ?? false; + const skillInstruction = loaded + ? `Teaching mode is active. The ${SKILL_NAME} skill is mandatory for this session. Read it before the first teaching action and keep following it on every turn. Its teaching rules take precedence over ordinary coding-assistant defaults.` + : `Teaching mode is active, but the ${SKILL_NAME} skill did not load. Stop and tell Matteo the tutor skill is unavailable at ${skillPath()}.`; + + return { systemPrompt: `${event.systemPrompt}\n\n## Programming teaching mode\n\n${skillInstruction}` }; + }); + + pi.registerCommand("teach", { + description: "Toggle programming teaching mode, or use /teach on|off|status", + handler: async (args, ctx) => { + await changeMode(pi, args, ctx, enabled, (next) => { + enabled = next; + }); + }, + }); +} From 1147f3a0edb95e0174da73f4d0a250b51733ba27 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 29 Aug 2026 20:39:42 +0200 Subject: [PATCH 5/5] clarify Pi teaching mode in prompt --- shared/pi/extensions/programming-classroom.ts | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/shared/pi/extensions/programming-classroom.ts b/shared/pi/extensions/programming-classroom.ts index 50adbc2..47138ba 100644 --- a/shared/pi/extensions/programming-classroom.ts +++ b/shared/pi/extensions/programming-classroom.ts @@ -1,7 +1,12 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { + CustomEditor, + type ExtensionAPI, + type ExtensionCommandContext, + type ExtensionContext, +} from "@earendil-works/pi-coding-agent"; /** * Load Kai's canonical programming tutor skill only when Matteo wants a lesson. @@ -11,8 +16,8 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@e * Override the canonical skill path with PI_PROGRAMMING_CLASSROOM_SKILL. */ const STATE_TYPE = "programming-classroom-mode"; -const STATUS_KEY = "programming-classroom"; const SKILL_NAME = "programming-classroom-tutor"; +const MODE_LABEL = " teaching: on "; const DEFAULT_SKILL_PATH = join( homedir(), ".openclaw", @@ -51,8 +56,52 @@ function requestedMode(args: string, current: boolean): boolean | "status" | und return undefined; } -function updateStatus(ctx: ExtensionContext, enabled: boolean): void { - ctx.ui.setStatus(STATUS_KEY, enabled ? "teaching" : undefined); +function installTeachingEditor(ctx: ExtensionContext): void { + if (!ctx.hasUI) return; + + // Preserve an editor installed by another extension, such as the Ghostty + // Shift+Enter adapter, and add only the classroom presentation around it. + const baseFactory = ctx.ui.getEditorComponent(); + const uiTheme = ctx.ui.theme; + + ctx.ui.setEditorComponent((tui, theme, keybindings) => { + const editor = baseFactory?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings); + const teachingBorder = (text: string) => { + if (editor.getText().trimStart().startsWith("!")) { + return uiTheme.getBashModeBorderColor()(text); + } + return uiTheme.fg("warning", text); + }; + + // Pi normally updates the border when thinking/model state changes. Teaching + // mode keeps its own color, while retaining the standard bash-mode color. + Object.defineProperty(editor, "borderColor", { + get: () => teachingBorder, + set: () => {}, + configurable: true, + enumerable: true, + }); + + const renderBase = editor.render.bind(editor); + editor.render = (width: number) => { + const lines = renderBase(width); + if (lines.length < 2) return lines; + + const topPlain = (lines[0] ?? "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); + const scrollPrefix = topPlain.match(/^(─── ↑ \d+ more )/)?.[1]; + const prefix = scrollPrefix ?? "──"; + const remaining = width - prefix.length - MODE_LABEL.length; + if (remaining < 1) return lines; + + lines[0] = + teachingBorder(prefix) + + uiTheme.bold(uiTheme.fg("warning", MODE_LABEL)) + + teachingBorder("─".repeat(remaining)); + return lines; + }; + + return editor; + }); } async function changeMode( @@ -85,7 +134,6 @@ async function changeMode( setCurrent(requested); pi.appendEntry(STATE_TYPE, { enabled: requested } satisfies TeachingModeState); - updateStatus(ctx, requested); ctx.ui.notify( requested ? "Programming teaching mode enabled. Reloading the tutor skill." @@ -105,12 +153,12 @@ export default function programmingClassroom(pi: ExtensionAPI) { }); pi.on("session_start", (_event, ctx) => { - enabled = Boolean(pi.getFlag("teach")) || (savedMode(ctx) ?? false); + enabled = savedMode(ctx) ?? Boolean(pi.getFlag("teach")); if (enabled && !existsSync(skillPath())) { enabled = false; ctx.ui.notify(`Programming teaching mode could not find ${skillPath()}`, "error"); } - updateStatus(ctx, enabled); + if (enabled) installTeachingEditor(ctx); }); pi.on("resources_discover", () => {