[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
+255
-41
No files matched your search
@@ -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),
|
||||
});
|
||||
|
||||
// Block destructive bash commands in plan mode
|
||||
pi.on("tool_call", async (event) => {
|
||||
// LOCAL EDIT: upstream blocks every bash command outside the read-only allowlist. That
|
||||
// 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;
|
||||
|
||||
const command = event.input.command as string;
|
||||
if (!isSafeCommand(command)) {
|
||||
if (isSafeCommand(command)) return;
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
return {
|
||||
block: true,
|
||||
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
|
||||
@@ -227,7 +245,7 @@ You are in plan mode - a read-only exploration mode for safe code analysis.
|
||||
Restrictions:
|
||||
- Built-in edit and write tools are disabled
|
||||
- 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.
|
||||
|
||||
@@ -295,16 +313,14 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
||||
|
||||
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);
|
||||
if (lastAssistant) {
|
||||
const extracted = extractTodoItems(getTextContent(lastAssistant));
|
||||
if (extracted.length > 0) {
|
||||
todoItems = extracted;
|
||||
}
|
||||
}
|
||||
|
||||
if (todoItems.length === 0) return;
|
||||
const extracted = lastAssistant ? extractTodoItems(getTextContent(lastAssistant)) : [];
|
||||
if (extracted.length === 0) return;
|
||||
todoItems = extracted;
|
||||
persistState();
|
||||
|
||||
// Show plan steps and prompt for next action
|
||||
|
||||
Reference in new issue
Block a user