[Sync] Pi chat mode + plan-mode rework, fastfetch icons, Claude statusline, fl KDE shortcuts
Re-export of the private tree at b5580f8 (2026-11-11). - pi: new chat-mode extension, plan-mode rework, README and settings updates, version bump - fastfetch: Packages/Display/WM/GPU/Memory key icons moved off the legacy Nerd Fonts v2 Material range (blank on v3 fonts) to Font Awesome glyphs - claude: statusline runs git with --no-optional-locks, model default, pr-loop skill note, gh sync fix - fl: kglobalshortcutsrc update
This commit is contained in:
1 parent
47166d877f
commit
78354d9afe
9 files changed
+254
-40
No files matched your search
@@ -87,7 +87,6 @@
|
|||||||
],
|
],
|
||||||
"defaultMode": "auto"
|
"defaultMode": "auto"
|
||||||
},
|
},
|
||||||
"model": "opus",
|
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"SessionStart": [
|
"SessionStart": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -230,6 +230,10 @@ Current state (auth + the current branch's PR):
|
|||||||
```
|
```
|
||||||
`COMMENT_NODE_ID` is the `relevantComments[].id` (`IC_…`). Never minimize unpushed work, a
|
`COMMENT_NODE_ID` is the `relevantComments[].id` (`IC_…`). Never minimize unpushed work, a
|
||||||
"needs input" comment, or a summary/status/usage-limit comment.
|
"needs input" comment, or a summary/status/usage-limit comment.
|
||||||
|
- **Sync the status line** in any cycle where you resolved or minimized something (step 6, or
|
||||||
|
a 👎): `python3 ~/.claude/statusline.py --pr-sync` — one foreground GitHub call that
|
||||||
|
rewrites the branch's cached 💬 count. Without it the bar keeps the count from its last
|
||||||
|
render until something re-renders it, which can be an hour on an idle session.
|
||||||
|
|
||||||
9. **Draft the commit message — exactly like `/commit-msg`:** from the staged diff and the repo's
|
9. **Draft the commit message — exactly like `/commit-msg`:** from the staged diff and the repo's
|
||||||
recent `git log` style (this repo uses `[Scope] summary`), write a message that matches. Then:
|
recent `git log` style (this repo uses `[Scope] summary`), write a message that matches. Then:
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ def dir_segment(cwd, top=None):
|
|||||||
|
|
||||||
def _git(cwd, *args):
|
def _git(cwd, *args):
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["git", "-C", cwd, *args], capture_output=True, text=True, timeout=1
|
["git", "--no-optional-locks", "-C", cwd, *args], capture_output=True, text=True, timeout=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -508,6 +508,24 @@ def pr_refresh(cwd, branch, path):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def pr_sync(cwd):
|
||||||
|
"""Foreground half (`statusline.py --pr-sync [CWD]`): refresh the current branch's counts
|
||||||
|
now and print them. For a caller that just changed them -- pr-loop resolving a thread --
|
||||||
|
so the next render shows the new count rather than the cached one, which is otherwise
|
||||||
|
only refreshed by a render that finds it older than PR_TTL."""
|
||||||
|
top = git_top(cwd)
|
||||||
|
branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() if top else ""
|
||||||
|
if not top or not branch or branch == "HEAD":
|
||||||
|
sys.exit("statusline --pr-sync: not on a branch")
|
||||||
|
path = _pr_cache_path(top, branch)
|
||||||
|
pr_refresh(cwd, branch, path)
|
||||||
|
state = _read_json(path)
|
||||||
|
if state.get("error"):
|
||||||
|
sys.exit("statusline --pr-sync: fetch failed")
|
||||||
|
num = state.get("number")
|
||||||
|
print(f"#{num} open={state.get('open') or 0} codex={state.get('codex') or 0}" if num else "no open PR")
|
||||||
|
|
||||||
|
|
||||||
def pr_segment(cwd, top):
|
def pr_segment(cwd, top):
|
||||||
"""Unresolved review threads (any author, outdated ones included) on the current branch's
|
"""Unresolved review threads (any author, outdated ones included) on the current branch's
|
||||||
open PR: `💬 #N ✓` when none are left, else the count with Codex's share broken out.
|
open PR: `💬 #N ✓` when none are left, else the count with Codex's share broken out.
|
||||||
@@ -515,8 +533,11 @@ def pr_segment(cwd, top):
|
|||||||
A GitHub round trip takes ~0.7 s, too slow for a render, so this only reads a per-branch
|
A GitHub round trip takes ~0.7 s, too slow for a render, so this only reads a per-branch
|
||||||
cache (~/.claude/cache/statusline.pr.<hash>.json) and, once it is older than PR_TTL,
|
cache (~/.claude/cache/statusline.pr.<hash>.json) and, once it is older than PR_TTL,
|
||||||
spawns a detached `--pr-refresh` child to update it -- the new count shows on the next
|
spawns a detached `--pr-refresh` child to update it -- the new count shows on the next
|
||||||
render. PR-conversation comments are not counted: they have no resolved state, so a
|
render. Nothing re-renders on its own between events, so a count can sit stale for as long
|
||||||
summary or an LGTM would keep the count from ever reaching zero.
|
as the session is idle; a caller that changes the threads itself runs `--pr-sync`
|
||||||
|
(pr_sync) to rewrite the cache at once. PR-conversation comments are not counted: they
|
||||||
|
have no resolved state, so a summary or an LGTM would keep the count from ever reaching
|
||||||
|
zero.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not top or not shutil.which("gh"):
|
if not top or not shutil.which("gh"):
|
||||||
@@ -881,6 +902,9 @@ def main():
|
|||||||
if sys.argv[1:2] == ["--pr-refresh"] and len(sys.argv) == 5:
|
if sys.argv[1:2] == ["--pr-refresh"] and len(sys.argv) == 5:
|
||||||
pr_refresh(*sys.argv[2:])
|
pr_refresh(*sys.argv[2:])
|
||||||
return
|
return
|
||||||
|
if sys.argv[1:2] == ["--pr-sync"] and len(sys.argv) <= 3:
|
||||||
|
pr_sync(sys.argv[2] if len(sys.argv) == 3 else os.getcwd())
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
data = json.load(sys.stdin)
|
data = json.load(sys.stdin)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -28,17 +28,17 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "packages",
|
"type": "packages",
|
||||||
"key": " Packages",
|
"key": " Packages",
|
||||||
"keyColor": "green"
|
"keyColor": "green"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "display",
|
"type": "display",
|
||||||
"key": " Display",
|
"key": " Display",
|
||||||
"keyColor": "green"
|
"keyColor": "green"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "wm",
|
"type": "wm",
|
||||||
"key": " WM",
|
"key": " WM",
|
||||||
"keyColor": "yellow"
|
"keyColor": "yellow"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
{
|
{
|
||||||
"type": "gpu",
|
"type": "gpu",
|
||||||
"format": "{2}",
|
"format": "{2}",
|
||||||
"key": " GPU",
|
"key": " GPU",
|
||||||
"keyColor": "blue"
|
"keyColor": "blue"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "memory",
|
"type": "memory",
|
||||||
"key": " Memory",
|
"key": " Memory",
|
||||||
"keyColor": "magenta"
|
"keyColor": "magenta"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,14 +57,15 @@ have a slash command to disable them for the current session without deleting.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `statusbar.ts` | Replaces the footer with a Claude-Code-style status line (cwd, git branch, model, context %, decode t/s, token counts) in Nerd Font glyphs; adds a streaming pulse + a ≥80%-context warning widget | **custom** | delete file → default footer returns; or `/statusbar` to toggle off for the session |
|
| `statusbar.ts` | Replaces the footer with a Claude-Code-style status line (cwd, git branch, model, context %, decode t/s, token counts) in Nerd Font glyphs; adds a streaming pulse + a ≥80%-context warning widget | **custom** | delete file → default footer returns; or `/statusbar` to toggle off for the session |
|
||||||
| `vim-toggle.ts` | `/vim` — drops the modal editor for the rest of the session, and brings it back via `ctx.reload()`. Pairs with the **`pi-vim` package** (below), which registers no command of its own | **custom** | delete file → you lose the toggle, not the editor; remove the package to drop modal editing itself |
|
| `vim-toggle.ts` | `/vim` — drops the modal editor for the rest of the session, and brings it back via `ctx.reload()`. Pairs with the **`pi-vim` package** (below), which registers no command of its own | **custom** | delete file → you lose the toggle, not the editor; remove the package to drop modal editing itself |
|
||||||
| `plan-mode/` | Read-only "plan" mode (disables edit/write, restricts bash) bound to **Shift+Tab**; `/plan` toggles, `--plan` starts in it, `/plan-done` clears a stuck progress checklist | **vendored** copy of pi's bundled `examples/extensions/plan-mode/`, rebound from upstream `Ctrl+Alt+P` | delete the folder **and** revert the `keybindings.json` `app.thinking.cycle` remap (see below) |
|
| `plan-mode/` | Read-only "plan" mode (disables edit/write; allowlisted read-only bash runs freely, any other command asks Yes/No first) bound to **Shift+Tab**; `/plan` toggles, `--plan` starts in it, `/plan-done` clears a stuck progress checklist | **vendored** copy of pi's bundled `examples/extensions/plan-mode/`, rebound from upstream `Ctrl+Alt+P`; upstream blocks non-allowlisted bash instead of asking, and re-offers a stale plan after every later answer | delete the folder **and** revert the `keybindings.json` `app.thinking.cycle` remap (see below) |
|
||||||
|
| `chat-mode.ts` | Question-answering mode: **Alt+C** or `/chat [on\|off]` toggles, `--chat` starts in it (footer `💬 chat`). The model is told to just answer (no plan, no implementing); every `bash` command and every `edit`/`write` asks Yes/No first, while `read`/`grep`/`find`/`ls` stay free. Defers to plan mode while that is on. Note `pi -p --chat "q"` swallows the prompt as the flag's value, so put `--chat` earlier | **custom** | delete file |
|
||||||
| `notify.ts` | Native terminal notification (OSC 777/99, Windows toast) when the agent goes idle | **verbatim** from pi's `examples/extensions/notify.ts` | delete file |
|
| `notify.ts` | Native terminal notification (OSC 777/99, Windows toast) when the agent goes idle | **verbatim** from pi's `examples/extensions/notify.ts` | delete file |
|
||||||
| `agent-state.ts` | Mirrors Pi's state (`agent_start` busy, `agent_settled` done, `ui_prompt_*` input, `session_shutdown` off) into the tmux pane it runs in via `~/.config/tmux/scripts/agent-state.sh`, for the status-bar agent badges; no-op outside tmux | own | delete file |
|
| `agent-state.ts` | Mirrors Pi's state (`agent_start` busy, `agent_settled` done, `ui_prompt_*` input, `session_shutdown` off) into the tmux pane it runs in via `~/.config/tmux/scripts/agent-state.sh`, for the status-bar agent badges; no-op outside tmux | own | delete file |
|
||||||
| `questionnaire.ts` | Interactive multi-question overlay the model can call as a tool | **near-verbatim** from pi's `examples/extensions/questionnaire.ts` (one-line fix) | delete file |
|
| `questionnaire.ts` | Interactive multi-question overlay the model can call as a tool | **near-verbatim** from pi's `examples/extensions/questionnaire.ts` (one-line fix) | delete file |
|
||||||
| `permission-gate.ts` | Prompts before dangerous `bash` commands (blocks outright with no UI). Local list covers git history rewrites, package managers, `systemctl` and irreversible disk ops on top of upstream's `rm -rf`/`sudo`/`chmod 777` | **adapted** from pi's `examples/extensions/permission-gate.ts` | delete file |
|
| `permission-gate.ts` | Prompts before dangerous `bash` commands (blocks outright with no UI). Local list covers git history rewrites, package managers, `systemctl` and irreversible disk ops on top of upstream's `rm -rf`/`sudo`/`chmod 777` | **adapted** from pi's `examples/extensions/permission-gate.ts` | delete file |
|
||||||
| `protected-paths.ts` | Blocks `write`/`edit` to secrets and repo plumbing (`secrets.fish`, `auth.json`, `.env`, `.ssh/`, `.git/`, …) | **adapted** from pi's `examples/extensions/protected-paths.ts` | delete file |
|
| `protected-paths.ts` | Blocks `write`/`edit` to secrets and repo plumbing (`secrets.fish`, `auth.json`, `.env`, `.ssh/`, `.git/`, …) | **adapted** from pi's `examples/extensions/protected-paths.ts` | delete file |
|
||||||
| `compaction-watch.ts` | Surfaces every compaction and its `reason`; on `overflow` (recovery rather than prevention) it reads the turn that caused it and says which kind it was, with numbers — a reply pi itself had clamped (`max_tokens` = window − estimate − 4096, the usual one on thinking models) or a prompt the server really rejected | **custom** | delete file |
|
| `compaction-watch.ts` | Surfaces every compaction and its `reason`; on `overflow` (recovery rather than prevention) it reads the turn that caused it and says which kind it was, with numbers — a reply pi itself had clamped (`max_tokens` = window − estimate − 4096, the usual one on thinking models) or a prompt the server really rejected | **custom** | delete file |
|
||||||
| `verbose.ts` | `/verbose [on|off|status]` and **Alt+V** toggle the *persisted* verbose default (`hideThinkingBlock`, `showCacheMissNotices`) and report pi's live keys — `ctrl+o` tool output, `ctrl+t` thinking, which an extension cannot drive itself | **custom** | delete file; the live keys are built in and keep working |
|
| `verbose.ts` | `/verbose [on\|off\|status]` and **Alt+V** toggle the *persisted* verbose default (`hideThinkingBlock`, `showCacheMissNotices`) and report pi's live keys — `ctrl+o` tool output, `ctrl+t` thinking, which an extension cannot drive itself | **custom** | delete file; the live keys are built in and keep working |
|
||||||
| `confirm-destructive.ts` | Confirms before session clear / switch / fork | **verbatim** from pi's `examples/extensions/confirm-destructive.ts` | delete file |
|
| `confirm-destructive.ts` | Confirms before session clear / switch / fork | **verbatim** from pi's `examples/extensions/confirm-destructive.ts` | delete file |
|
||||||
| `session-name.ts` | `/session-name [name]` — friendly names in the session picker instead of the first message | **verbatim** from pi's `examples/extensions/session-name.ts` | delete file |
|
| `session-name.ts` | `/session-name [name]` — friendly names in the session picker instead of the first message | **verbatim** from pi's `examples/extensions/session-name.ts` | delete file |
|
||||||
| `todo.ts` | A `todo` tool the model calls (add/toggle/list/clear) plus `/todos` to view it; state lives in tool results, so forking rewinds it correctly. Distinct from plan-mode's checklist | **verbatim** from pi's `examples/extensions/todo.ts` | delete file (it also adds a tool schema to every request) |
|
| `todo.ts` | A `todo` tool the model calls (add/toggle/list/clear) plus `/todos` to view it; state lives in tool results, so forking rewinds it correctly. Distinct from plan-mode's checklist | **verbatim** from pi's `examples/extensions/todo.ts` | delete file (it also adds a tool schema to every request) |
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Chat mode — `/chat [on|off]`, Alt+C, `--chat`.
|
||||||
|
*
|
||||||
|
* For asking questions rather than handing over a task. It sits next to plan-mode but is
|
||||||
|
* deliberately not part of that vendored fork, and it is the lighter of the two:
|
||||||
|
*
|
||||||
|
* plan mode removes edit/write, runs allowlisted read-only bash freely and asks before
|
||||||
|
* any other command, and steers the model into a numbered "Plan:" that it
|
||||||
|
* then offers to execute.
|
||||||
|
* chat mode steers the model into just answering (no plan, no implementing) and puts
|
||||||
|
* every bash command and every edit/write behind a Yes/No prompt. read, grep,
|
||||||
|
* find and ls stay free, so a question about the code costs no clicks.
|
||||||
|
*
|
||||||
|
* The instructions go in through `before_agent_start`'s systemPrompt, which is rebuilt every
|
||||||
|
* turn, rather than as an injected message the way plan-mode does it. Nothing is stored in
|
||||||
|
* the session, so there is no stale "[CHAT MODE]" text to filter out after leaving and nothing
|
||||||
|
* piles up in the 24k windows. The price: a toggle changes the system prompt (and the tool
|
||||||
|
* list), so the first reply after one re-prefills the whole context on llama.cpp.
|
||||||
|
*
|
||||||
|
* Tools: grep/find/ls are *added* on entry, and only the ones this added are removed on exit,
|
||||||
|
* never a snapshot restore. That composes with plan-mode's own snapshot/restore in any toggle
|
||||||
|
* order without ever re-enabling edit/write behind plan mode's back.
|
||||||
|
*
|
||||||
|
* Plan mode wins. Chat mode won't switch on while plan mode is on, and goes quiet (no prompt
|
||||||
|
* text, no gate) if plan mode is switched on later. Plan state is read from plan-mode's own
|
||||||
|
* `plan-mode` session entries, because `pi.getFlag` only sees an extension's own flags, so a
|
||||||
|
* session started with `--plan` and never toggled reads as "off".
|
||||||
|
*
|
||||||
|
* A dangerous command in chat mode is asked about twice: here and in permission-gate.ts, in
|
||||||
|
* whichever order pi loaded the two. A No to either stops it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
// Read-only tools switched on for the mode, so the model can look things up without a prompt.
|
||||||
|
const READ_TOOLS = ["grep", "find", "ls"];
|
||||||
|
|
||||||
|
// Tools that only run after a Yes. Everything else (read, grep, questionnaire, …) is free.
|
||||||
|
const GATED_TOOLS = new Set(["bash", "edit", "write"]);
|
||||||
|
|
||||||
|
const DECLINED =
|
||||||
|
"The user declined this in chat mode. Do not retry it; answer from what you already have, or ask the user.";
|
||||||
|
|
||||||
|
const CHAT_PROMPT = `[CHAT MODE]
|
||||||
|
The user wants to ask questions and talk things through, not hand you a task.
|
||||||
|
- Answer the question directly and concisely.
|
||||||
|
- Do not write a plan or a list of steps to carry out, and do not start implementing anything.
|
||||||
|
- Read files (read, grep, find, ls) when the answer depends on the actual code or config.
|
||||||
|
- Every bash command and every file edit needs the user's approval. Use bash only when the answer needs it, and say in one line what you will run and why before calling it.
|
||||||
|
- If the user declines a tool call, do not retry it.
|
||||||
|
- If the question is ambiguous, ask a short clarifying question instead of guessing.`;
|
||||||
|
|
||||||
|
interface ChatModeState {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomEntry = { type: string; customType?: string; data?: unknown };
|
||||||
|
|
||||||
|
function lastEntry<T>(ctx: ExtensionContext, customType: string): T | undefined {
|
||||||
|
const entry = (ctx.sessionManager.getEntries() as CustomEntry[])
|
||||||
|
.filter((e) => e.type === "custom" && e.customType === customType)
|
||||||
|
.pop();
|
||||||
|
return entry?.data as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planModeOn(ctx: ExtensionContext): boolean {
|
||||||
|
return lastEntry<{ enabled?: boolean }>(ctx, "plan-mode")?.enabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function chatModeExtension(pi: ExtensionAPI): void {
|
||||||
|
let enabled = false;
|
||||||
|
// The READ_TOOLS this extension switched on, i.e. the only ones it may switch off again.
|
||||||
|
let addedTools: string[] = [];
|
||||||
|
|
||||||
|
pi.registerFlag("chat", {
|
||||||
|
description: "Start in chat mode (answers only; bash and edits ask first)",
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function addReadTools(): void {
|
||||||
|
const active = pi.getActiveTools();
|
||||||
|
addedTools = READ_TOOLS.filter((name) => !active.includes(name));
|
||||||
|
if (addedTools.length > 0) pi.setActiveTools([...active, ...addedTools]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeReadTools(ctx: ExtensionContext): void {
|
||||||
|
// Leaving while plan mode is on: plan mode owns the tool list now, and it wants these.
|
||||||
|
if (addedTools.length > 0 && !planModeOn(ctx)) {
|
||||||
|
const added = new Set(addedTools);
|
||||||
|
pi.setActiveTools(pi.getActiveTools().filter((name) => !added.has(name)));
|
||||||
|
}
|
||||||
|
addedTools = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(ctx: ExtensionContext): void {
|
||||||
|
ctx.ui.setStatus("chat-mode", enabled ? ctx.ui.theme.fg("accent", "💬 chat") : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChatMode(on: boolean, ctx: ExtensionContext): void {
|
||||||
|
if (on === enabled) {
|
||||||
|
ctx.ui.notify(`Chat mode is already ${on ? "on" : "off"}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (on && planModeOn(ctx)) {
|
||||||
|
ctx.ui.notify("Plan mode is on. Leave it first (Shift+Tab or /plan).", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled = on;
|
||||||
|
if (enabled) {
|
||||||
|
addReadTools();
|
||||||
|
ctx.ui.notify("Chat mode on: answers only, bash and edits ask first.");
|
||||||
|
} else {
|
||||||
|
removeReadTools(ctx);
|
||||||
|
ctx.ui.notify("Chat mode off.");
|
||||||
|
}
|
||||||
|
updateStatus(ctx);
|
||||||
|
pi.appendEntry("chat-mode", { enabled } satisfies ChatModeState);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerCommand("chat", {
|
||||||
|
description: "Toggle chat mode: answers only, bash and edits ask first (usage: /chat [on|off])",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const raw = args.trim().toLowerCase();
|
||||||
|
if (raw && raw !== "on" && raw !== "off") {
|
||||||
|
ctx.ui.notify("usage: /chat [on|off]", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChatMode(raw ? raw === "on" : !enabled, ctx);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerShortcut("alt+c", {
|
||||||
|
description: "Toggle chat mode",
|
||||||
|
handler: async (ctx) => setChatMode(!enabled, ctx),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
if (!enabled || !GATED_TOOLS.has(event.toolName) || planModeOn(ctx)) return undefined;
|
||||||
|
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
// Non-interactive (`pi -p --chat`): nothing can approve, so refuse.
|
||||||
|
return { block: true, reason: "Chat mode: blocked (no UI to approve it)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const what =
|
||||||
|
event.toolName === "bash"
|
||||||
|
? `run this command?\n\n ${event.input.command as string}`
|
||||||
|
: `${event.toolName} ${event.input.path as string}?`;
|
||||||
|
const choice = await ctx.ui.select(`💬 Chat mode: ${what}\n\nAllow?`, ["Yes", "No"]);
|
||||||
|
|
||||||
|
if (choice !== "Yes") {
|
||||||
|
return { block: true, reason: DECLINED };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async (event, ctx) => {
|
||||||
|
if (!enabled || planModeOn(ctx)) return undefined;
|
||||||
|
return { systemPrompt: `${event.systemPrompt}\n\n${CHAT_PROMPT}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore on start/resume. Each session gets a fresh instance, so this starts from scratch.
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
enabled = lastEntry<ChatModeState>(ctx, "chat-mode")?.enabled ?? pi.getFlag("chat") === true;
|
||||||
|
if (enabled) addReadTools();
|
||||||
|
updateStatus(ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -178,17 +178,35 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|||||||
handler: async (ctx) => togglePlanMode(ctx),
|
handler: async (ctx) => togglePlanMode(ctx),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Block destructive bash commands in plan mode
|
// LOCAL EDIT: upstream blocks every bash command outside the read-only allowlist. That
|
||||||
pi.on("tool_call", async (event) => {
|
// allowlist is coarse (any `>` counts as a write, so `free -h 2>/dev/null` is refused),
|
||||||
|
// and a refusal sends the model straight back to writing a plan around the gap. So ask
|
||||||
|
// instead: allowlisted commands still run unprompted, anything else needs a Yes. With no
|
||||||
|
// UI (`pi -p`) nothing can answer, so that case still blocks as upstream does. The
|
||||||
|
// "Bash runs read-only allowlisted commands freely…" line in the prompt below goes with it.
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
if (!planModeEnabled || event.toolName !== "bash") return;
|
if (!planModeEnabled || event.toolName !== "bash") return;
|
||||||
|
|
||||||
const command = event.input.command as string;
|
const command = event.input.command as string;
|
||||||
if (!isSafeCommand(command)) {
|
if (isSafeCommand(command)) return;
|
||||||
|
|
||||||
|
if (!ctx.hasUI) {
|
||||||
return {
|
return {
|
||||||
block: true,
|
block: true,
|
||||||
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
|
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const choice = await ctx.ui.select(
|
||||||
|
`⏸ Plan mode: this command is not on the read-only allowlist:\n\n ${command}\n\nAllow?`,
|
||||||
|
["Yes", "No"],
|
||||||
|
);
|
||||||
|
if (choice !== "Yes") {
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason: "Plan mode: the user declined this command. Do not retry it; continue with what you have, or ask the user.",
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter out stale plan mode context when not in plan mode
|
// Filter out stale plan mode context when not in plan mode
|
||||||
@@ -227,7 +245,7 @@ You are in plan mode - a read-only exploration mode for safe code analysis.
|
|||||||
Restrictions:
|
Restrictions:
|
||||||
- Built-in edit and write tools are disabled
|
- Built-in edit and write tools are disabled
|
||||||
- Other currently active tools remain available
|
- Other currently active tools remain available
|
||||||
- Bash is restricted to an allowlist of read-only commands
|
- Bash runs read-only allowlisted commands freely; any other command asks the user for approval first
|
||||||
|
|
||||||
Ask clarifying questions using the questionnaire tool.
|
Ask clarifying questions using the questionnaire tool.
|
||||||
|
|
||||||
@@ -295,16 +313,14 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|||||||
|
|
||||||
if (!planModeEnabled || !ctx.hasUI) return;
|
if (!planModeEnabled || !ctx.hasUI) return;
|
||||||
|
|
||||||
// Extract todos from last assistant message
|
// LOCAL EDIT: upstream kept the previous plan's steps whenever the latest reply had
|
||||||
|
// none, so once one plan had been written, every later answer in plan mode (a plain
|
||||||
|
// question, a command's output) re-opened "what next?" with that stale plan. Only
|
||||||
|
// offer to execute a plan that the reply which just finished actually wrote.
|
||||||
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
|
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
|
||||||
if (lastAssistant) {
|
const extracted = lastAssistant ? extractTodoItems(getTextContent(lastAssistant)) : [];
|
||||||
const extracted = extractTodoItems(getTextContent(lastAssistant));
|
if (extracted.length === 0) return;
|
||||||
if (extracted.length > 0) {
|
|
||||||
todoItems = extracted;
|
todoItems = extracted;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (todoItems.length === 0) return;
|
|
||||||
persistState();
|
persistState();
|
||||||
|
|
||||||
// Show plan steps and prompt for next action
|
// Show plan steps and prompt for next action
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
"ex": "mode"
|
"ex": "mode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lastChangelogVersion": "1.5.0",
|
"lastChangelogVersion": "1.5.2",
|
||||||
"packages": [
|
"packages": [
|
||||||
"npm:pi-vim"
|
"npm:pi-vim"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -208,33 +208,33 @@ view_zoom_in=Meta++,Meta++\tMeta+=,Zoom In
|
|||||||
view_zoom_out=Meta+_,Meta+-,Zoom Out
|
view_zoom_out=Meta+_,Meta+-,Zoom Out
|
||||||
|
|
||||||
[kwin][$InverseAction]
|
[kwin][$InverseAction]
|
||||||
Cycle Overview=Cycle Overview Opposite
|
Cycle Overview Opposite=Cycle Overview
|
||||||
Increase Opacity=Decrease Opacity
|
Increase Opacity=Decrease Opacity
|
||||||
MoveZoomDown=MoveZoomUp
|
MoveZoomDown=MoveZoomUp
|
||||||
MoveZoomLeft=MoveZoomRight
|
MoveZoomRight=MoveZoomLeft
|
||||||
Switch One Desktop Down=Switch One Desktop Up
|
Switch One Desktop Up=Switch One Desktop Down
|
||||||
Switch One Desktop to the Left=Switch One Desktop to the Right
|
Switch One Desktop to the Left=Switch One Desktop to the Right
|
||||||
Switch Window Right=Switch Window Left
|
Switch Window Down=Switch Window Up
|
||||||
Switch Window Up=Switch Window Down
|
Switch Window Left=Switch Window Right
|
||||||
Switch to Next Screen=Switch to Previous Screen
|
|
||||||
Switch to Previous Desktop=Switch to Next Desktop
|
Switch to Previous Desktop=Switch to Next Desktop
|
||||||
|
Switch to Previous Screen=Switch to Next Screen
|
||||||
Switch to Screen Below=Switch to Screen Above
|
Switch to Screen Below=Switch to Screen Above
|
||||||
Switch to Screen to the Right=Switch to Screen to the Left
|
Switch to Screen to the Left=Switch to Screen to the Right
|
||||||
Walk Through Windows (Reverse)=Walk Through Windows
|
Walk Through Windows (Reverse)=Walk Through Windows
|
||||||
Walk Through Windows Alternative=Walk Through Windows Alternative (Reverse)
|
Walk Through Windows Alternative (Reverse)=Walk Through Windows Alternative
|
||||||
Walk Through Windows of Current Application (Reverse)=Walk Through Windows of Current Application
|
Walk Through Windows of Current Application=Walk Through Windows of Current Application (Reverse)
|
||||||
Walk Through Windows of Current Application Alternative (Reverse)=Walk Through Windows of Current Application Alternative
|
Walk Through Windows of Current Application Alternative=Walk Through Windows of Current Application Alternative (Reverse)
|
||||||
Window Grow Horizontal=Window Shrink Horizontal
|
|
||||||
Window Grow Vertical=Window Shrink Vertical
|
Window Grow Vertical=Window Shrink Vertical
|
||||||
Window One Desktop Up=Window One Desktop Down
|
Window One Desktop Down=Window One Desktop Up
|
||||||
Window One Desktop to the Left=Window One Desktop to the Right
|
Window One Desktop to the Left=Window One Desktop to the Right
|
||||||
Window One Screen Up=Window One Screen Down
|
Window One Screen Up=Window One Screen Down
|
||||||
Window One Screen to the Right=Window One Screen to the Left
|
Window One Screen to the Left=Window One Screen to the Right
|
||||||
Window Pack Right=Window Pack Left
|
Window Pack Down=Window Pack Up
|
||||||
Window Pack Up=Window Pack Down
|
Window Pack Left=Window Pack Right
|
||||||
Window Raise=Window Lower
|
Window Raise=Window Lower
|
||||||
|
Window Shrink Horizontal=Window Grow Horizontal
|
||||||
|
Window to Next Desktop=Window to Previous Desktop
|
||||||
Window to Next Screen=Window to Previous Screen
|
Window to Next Screen=Window to Previous Screen
|
||||||
Window to Previous Desktop=Window to Next Desktop
|
|
||||||
view_zoom_in=view_zoom_out
|
view_zoom_in=view_zoom_out
|
||||||
|
|
||||||
[mediacontrol]
|
[mediacontrol]
|
||||||
|
|||||||
Reference in new issue
Block a user