This commit is contained in:
Jonas H
2026-05-10 09:34:33 +02:00
parent ab93172f38
commit c7edbb1be0
3 changed files with 391 additions and 51 deletions

View File

@@ -27,7 +27,7 @@
import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { CustomEditor, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { copyToClipboard, CustomEditor, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import type { ExtensionAPI, KeybindingsManager } from "@mariozechner/pi-coding-agent";
import { Box, Container, matchesKey, Markdown, Spacer, Text, truncateToWidth, TUI, visibleWidth, type Component, type EditorTheme } from "@mariozechner/pi-tui";
import {
@@ -43,7 +43,7 @@ import { askSingleQuestionWithInlineNote } from "./pi-ask-tool/ask-inline-ui.js"
// Orange styling
// ---------------------------------------------------------------------------
const ORANGE = "\x1b[38;5;208m"; // pumpkin / tangerine
const ORANGE_DIM = "\x1b[38;5;130m";
const ORANGE_DIM = "\x1b[38;5;94m";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const orange = (s: string) => ORANGE + s + RESET;
@@ -184,6 +184,25 @@ const MODELS = ["haiku", "sonnet", "opus"] as const;
type Model = (typeof MODELS)[number];
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
// UI-facing model slot → actual `claude --model <id>` argument.
//
// `opus` is pinned to claude-opus-4-6 on purpose: Opus 4.7 (what the plain
// `opus` alias currently resolves to) returns thinking as an encrypted
// signature only — no `thinking_delta` events ever stream, so the italic
// thinking-block rendering stays blank the entire turn. 4.6 streams
// plaintext thinking normally, so pinning here restores the feature for
// the `opus` slot. Haiku/Sonnet use the plain alias (newest).
//
// We also pin haiku/sonnet to their CLI aliases for symmetry — if a
// future CLI alias bump lands on a model with the same redacted-thinking
// behaviour, we can downgrade the pin here without touching the rest of
// the extension.
const CLI_MODEL: Record<Model, string> = {
haiku: "haiku",
sonnet: "sonnet",
opus: "claude-opus-4-6",
};
// ---------------------------------------------------------------------------
// Past-session discovery (used by /claude-resume).
//
@@ -418,8 +437,15 @@ function loadSessionTurns(sessionId: string, cwd: string, fallbackModel: Model):
sawToolResult = true;
const { text } = tool_resultText(block.content);
const isError = block.is_error === true;
if (current) {
for (const tb of current.blocks) {
// TS 5.x loses narrowing of the `let current` that is
// reassigned by the `flush` closure — even a `const cur
// = current` annotation doesn't survive the for-of
// header re-evaluation. A direct cast on the `.blocks`
// access is the minimal escape hatch confirmed to work
// in isolation tests with TS 5.9.
if (current !== null) {
const curBlocks = (current as AssistantTurn).blocks;
for (const tb of curBlocks) {
if (tb.type === "tool" && tb.id === block.tool_use_id) {
tb.result = { text, isError };
break;
@@ -503,6 +529,120 @@ interface ChatSessionDetails {
turns: ChatTurn[];
}
// ---------------------------------------------------------------------------
// Todo extraction — scan the session for the most recent TodoWrite tool call
// and return its todos array. Rendered BETWEEN the orange-bordered
// conversation and the mode banner by the chat-claude widget so the
// current task list is always visible without scrolling through history.
//
// Only the latest TodoWrite wins (earlier ones are superseded); empty or
// malformed inputs are treated as "no todos" and suppress the section.
// ---------------------------------------------------------------------------
type TodoStatus = "completed" | "in_progress" | "pending";
interface Todo {
content: string;
status: TodoStatus;
activeForm: string;
}
function getLatestTodos(details: ChatSessionDetails | null): Todo[] | null {
if (!details) return null;
for (let i = details.turns.length - 1; i >= 0; i--) {
const turn = details.turns[i];
if (turn.role !== "assistant") continue;
for (let j = turn.blocks.length - 1; j >= 0; j--) {
const block = turn.blocks[j];
if (block.type !== "tool") continue;
if (block.name !== "TodoWrite") continue;
try {
const input = JSON.parse(block.inputJson);
if (Array.isArray(input?.todos) && input.todos.length > 0) {
return input.todos as Todo[];
}
// Hit the latest TodoWrite but it's empty/malformed — stop,
// don't fall through to an older one (the user cleared it).
return null;
} catch {
return null;
}
}
}
return null;
}
// Cap so a runaway todo list can't push the editor off-screen. In practice
// lists stay well under this; when they don't, we render the first N-1 items
// plus a "… X more" notice. Non-completed items are prioritised over
// completed ones in the visible slice, since the point of surfacing todos
// on-screen is to show what's left to do.
const MAX_TODO_LINES = 12;
function sliceTodosForDisplay(todos: Todo[]): { shown: Todo[]; hidden: number } {
if (todos.length <= MAX_TODO_LINES) return { shown: todos, hidden: 0 };
const budget = MAX_TODO_LINES - 1; // reserve one line for the "… more" notice
const nonCompleted = todos.filter((t) => t.status !== "completed");
const completed = todos.filter((t) => t.status === "completed");
const shown: Todo[] = [];
// Non-completed items come first so in-flight / pending work is always
// visible; any leftover budget is filled with completed items (for
// context) in original order.
for (const t of nonCompleted) {
if (shown.length >= budget) break;
shown.push(t);
}
for (const t of completed) {
if (shown.length >= budget) break;
shown.push(t);
}
return { shown, hidden: todos.length - shown.length };
}
// ---------------------------------------------------------------------------
// Code block extraction — raw fenced code from the session's text blocks.
//
// Used by the Ctrl+Shift+C shortcut to copy clean, unrendered code directly
// from the parsed JSON stream, avoiding the ANSI escape sequences, stray
// indentation, and line-continuation artefacts that terminal selection gives.
//
// Blocks are returned newest-first (last assistant turn first; within a turn,
// last code fence first) so the most recent snippet is always at index 0.
// ---------------------------------------------------------------------------
interface ExtractedCodeBlock {
lang: string; // language tag after the opening fence ("" when absent)
code: string; // raw content between the fences (no surrounding ```)
label: string; // compact one-line description for the picker UI
}
function extractCodeBlocksFromSession(details: ChatSessionDetails): ExtractedCodeBlock[] {
const out: ExtractedCodeBlock[] = [];
for (let ti = details.turns.length - 1; ti >= 0; ti--) {
const turn = details.turns[ti];
if (turn.role !== "assistant") continue;
const turnBlocks: ExtractedCodeBlock[] = [];
for (const block of turn.blocks) {
if (block.type !== "text") continue;
// Match fenced code: ```lang\n…content…``` (lang optional)
// \r? handles CRLF transcripts; [\s\S]*? is non-greedy so nested
// fences (rare but possible in prose) are handled correctly.
const fence = /```(\w*)\r?\n([\s\S]*?)```/g;
let m: RegExpExecArray | null;
while ((m = fence.exec(block.text)) !== null) {
const lang = m[1] ?? "";
const code = m[2] ?? "";
if (!code.trim()) continue; // skip empty fences
// Build a compact one-line label: [lang] first-non-blank-line
const firstLine = code.split("\n").find((l) => l.trim()) ?? "";
const preview = firstLine.length > 55
? firstLine.slice(0, 52).trimEnd() + "…"
: firstLine;
const langTag = lang ? `[${lang}] ` : "";
turnBlocks.push({ lang, code, label: `${langTag}${preview}` });
}
}
// Reverse within the turn so the last fence in that turn comes first.
for (let i = turnBlocks.length - 1; i >= 0; i--) out.push(turnBlocks[i]!);
}
return out;
}
// =============================================================================
// Extension entry point
// =============================================================================
@@ -535,18 +675,29 @@ interface ChatClaudePersistedState {
// so the user's choice survives the extension teardown the same way
// resumable session ids do.
effort: Effort;
// Prompts typed in chat mode, oldest-first. Capped at MAX_PROMPT_HISTORY.
// Replayed into the editor on every ChatEscEditor creation so up-arrow
// history is available immediately in any new chat session.
promptHistory: string[];
}
const CHAT_CLAUDE_STATE_KEY = "__pi_chat_claude_persisted__";
// Maximum number of prompts to persist. The Editor caps its own in-memory
// list at 100; we persist more so the most recent 100 are always available
// even after many reloads without hitting the per-instance limit.
const MAX_PROMPT_HISTORY = 200;
function getPersistedState(): ChatClaudePersistedState {
const g = globalThis as unknown as Record<string, ChatClaudePersistedState>;
let state = g[CHAT_CLAUDE_STATE_KEY];
if (!state) {
state = { sessions: new Map<Model, string>(), effort: DEFAULT_EFFORT };
state = { sessions: new Map<Model, string>(), effort: DEFAULT_EFFORT, promptHistory: [] };
g[CHAT_CLAUDE_STATE_KEY] = state;
}
// Back-fill for any persisted state written by an older revision of
// the extension (pre-/claude-effort) that didn't carry an effort field.
if (!state.effort) state.effort = DEFAULT_EFFORT;
// Back-fill for pre-promptHistory revisions.
if (!state.promptHistory) state.promptHistory = [];
return state;
}
@@ -574,6 +725,11 @@ export default function (pi: ExtensionAPI) {
// current chat-claude-session message.
let tuiRef: { requestRender: () => void } | null = null;
// Reference to the active ChatEscEditor instance so we can call
// addToHistory() on it after each prompt submission, making the new entry
// immediately navigable with the up-arrow inside the same session.
let editorRef: ChatEscEditor | null = null;
// The in-flight chat session's `details` object. Stored by reference so
// mutations here are reflected in the CustomMessage already displayed
// in pi's conversation. Null between chat-mode sessions.
@@ -625,7 +781,7 @@ export default function (pi: ExtensionAPI) {
const md = getMarkdownTheme();
if (turn.role === "user") {
container.addChild(new Text(orangeBold(" you"), 1, 0));
container.addChild(new Text(orangeBold(" you"), 1, 0));
container.addChild(new Spacer(1));
container.addChild(new Markdown(turn.text.trim(), 1, 0, md));
return;
@@ -635,24 +791,15 @@ export default function (pi: ExtensionAPI) {
const icon =
turn.cancelled ? orange("◇ ")
: turn.error ? theme.fg("error", "✗ ")
: turn.isResume ? orange(" ")
: turn.isResume ? orange(" ")
: orange("◆ ");
const header =
icon + orangeBold(`Claude ${capitalize(turn.model)}`)
+ (turn.sessionId ? theme.fg("dim", ` session:${turn.sessionId.slice(0, 8)}`) : "")
+ (!turn.done ? theme.fg("warning", " ") : "");
+ (!turn.done ? theme.fg("warning", " ") : "");
container.addChild(new Text(header, 1, 0));
container.addChild(new Spacer(1));
if (turn.cancelled) {
container.addChild(new Text(orange("(Cancelled)"), 1, 0));
return;
}
if (turn.error) {
container.addChild(new Text(theme.fg("error", `Error: ${turn.error}`), 1, 0));
return;
}
// Defensive dedup — see claude-stream.ts for the root-cause fix, but
// keep a safety net here in case a future Claude CLI change re-orders
// events differently.
@@ -688,7 +835,16 @@ export default function (pi: ExtensionAPI) {
}
}
if (turn.done) {
// Render the terminal notice AFTER any partial blocks so streamed
// output accumulated before a timeout / abort / error is preserved
// and visible rather than being silently discarded.
if (turn.cancelled) {
if (addedAny) container.addChild(new Spacer(1));
container.addChild(new Text(orange("(Cancelled)"), 1, 0));
} else if (turn.error) {
if (addedAny) container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("error", `Error: ${turn.error}`), 1, 0));
} else if (turn.done) {
const usage = formatUsage(turn as any);
if (usage) {
container.addChild(new Spacer(1));
@@ -816,17 +972,55 @@ export default function (pi: ExtensionAPI) {
tuiRef = tui; // ← captured for live streaming re-renders
return {
invalidate: () => {},
render: () => {
const rail = orange("▌ ");
render: (width: number) => {
const rail = orange("▌ ");
const out: string[] = [];
// ── Todos (if any) ────────────────────────────────────
// Sourced from the most recent TodoWrite tool call in
// this chat session. Rendered BEFORE the mode banner so
// the layout reads, top→bottom:
// orange-bordered conversation
// ▌ ☒ completed todo
// ▌ ▸ current in-progress todo (activeForm)
// ▌ ☐ pending todo
// ▌ ◆ CLAUDE CHAT MODE …
// ▌ Type to chat · …
const todos = getLatestTodos(currentDetails);
if (todos && todos.length > 0) {
const { shown, hidden } = sliceTodosForDisplay(todos);
for (const todo of shown) {
let marker: string;
let text: string;
if (todo.status === "completed") {
marker = theme.fg("success", "☒");
text = theme.fg("dim", todo.content);
} else if (todo.status === "in_progress") {
marker = orangeBold("▸");
text = orangeBold(todo.activeForm || todo.content);
} else {
marker = orangeDim("☐");
text = todo.content;
}
out.push(truncateToWidth(rail + marker + " " + text, width, "…", false));
}
if (hidden > 0) {
const notice = `${hidden} more todo${hidden === 1 ? "" : "s"} hidden`;
out.push(truncateToWidth(rail + theme.fg("dim", notice), width, "…", false));
}
}
// ── Mode banner ──────────────────────────────────────
const title = orangeBold("◆ CLAUDE CHAT MODE");
const modelLabel = orangeBold(modelUp);
const sessionTag = orangeDim("session:" + short);
const effortTag = orangeDim("effort:" + persisted.effort);
const running = isGenerating ? " " + orange(" streaming…") : "";
const running = isGenerating ? " " + orange(" streaming…") : "";
const line1 = rail + title + " " + modelLabel + " " + sessionTag + " " + effortTag + running;
const line2 = rail + theme.fg("dim",
"Type to chat · /claude haiku|sonnet|opus · /claude-new · /claude-effort · /claude-end · /claude-abort");
return [line1, line2];
out.push(line1, line2);
return out;
},
};
}, { placement: "aboveEditor" });
@@ -848,6 +1042,22 @@ export default function (pi: ExtensionAPI) {
// setCustomEditorComponent copies onto the custom editor at install time
// (see interactive-mode.js setCustomEditorComponent, ~line 1258).
class ChatEscEditor extends CustomEditor {
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
super(tui, theme, keybindings);
// Store a module-level reference so runChatTurn can feed the new
// prompt into the editor's history after each successful submission.
editorRef = this;
// Replay persisted history oldest-first: addToHistory() unshifts each
// entry, so the last call's text lands at index 0 (most recent) and
// up-arrow shows it first — exactly the expected shell-history UX.
// We cap the replay at 100 (the Editor's own internal limit) so the
// unshift loop doesn't silently discard entries mid-way.
const toReplay = persisted.promptHistory.slice(-100);
for (const text of toReplay) {
this.addToHistory(text);
}
}
handleInput(data: string): void {
if (matchesKey(data, "escape") && isGenerating && currentAbort) {
try { currentAbort.abort(); } catch { /* ok */ }
@@ -936,6 +1146,7 @@ export default function (pi: ExtensionAPI) {
askBridge = null;
// Restore pi's default editor (undoes ChatEscEditor from enterChatMode).
if (ctx?.hasUI) ctx.ui.setEditorComponent(undefined);
editorRef = null;
syncUI(ctx);
if (ctx?.hasUI) ctx.ui.notify("Exited chat mode — back to normal pi.", "info");
}
@@ -964,6 +1175,21 @@ export default function (pi: ExtensionAPI) {
const model = chatMode;
const details = ensureSessionMessage();
// Persist the prompt so it survives /reload and is available in future
// chat sessions. We record it here — before the async Claude call —
// so cancellations and errors still land in history.
// Deduplicate: skip if identical to the most recent persisted entry.
const trimmedPrompt = userText.trim();
if (trimmedPrompt && persisted.promptHistory.at(-1) !== trimmedPrompt) {
persisted.promptHistory.push(trimmedPrompt);
if (persisted.promptHistory.length > MAX_PROMPT_HISTORY) {
persisted.promptHistory = persisted.promptHistory.slice(-MAX_PROMPT_HISTORY);
}
}
// Also push into the live editor so the entry is navigable immediately
// (without requiring a reload to replay from persisted state).
if (trimmedPrompt) editorRef?.addToHistory(trimmedPrompt);
// Append user turn + placeholder assistant turn up front so the
// border extends as soon as the user hits enter.
details.turns.push({ role: "user", text: userText });
@@ -986,7 +1212,9 @@ export default function (pi: ExtensionAPI) {
try {
const r = await runClaude(userText, {
model,
// Resolve UI slot ("opus") → CLI model id ("claude-opus-4-6")
// so Opus streams plaintext thinking (4.7 redacts it).
model: CLI_MODEL[model],
sessionId: existingSession,
cwd: ctx.cwd,
signal: currentAbort.signal,
@@ -1337,6 +1565,71 @@ export default function (pi: ExtensionAPI) {
// pi.registerShortcut("escape", …) is silently ignored. ESC-to-abort is
// wired via the ChatEscEditor custom editor installed in enterChatMode.
// ── Raw code copy shortcut ───────────────────────────────────────────────
// Ctrl+Shift+C copies the raw, unrendered content of a fenced code block
// from the current chat-claude session by reading directly from the parsed
// JSON stream — bypassing ANSI sequences, stray indentation, and
// line-continuation garbage that normal terminal selection produces.
//
// 0 blocks found → notify; nothing copied
// 1 block found → copy immediately + notify
// N blocks found → inline picker (newest first) → copy selected + notify
//
// Note: most terminal emulators handle Ctrl+Shift+C at the VTE layer
// (before the app sees it) so this shortcut is only reachable when
// Kitty keyboard protocol is active and the terminal forwards the combo.
// It does NOT intercept the terminal's own clipboard mechanism when pi
// is not the foreground process receiving extended key events.
pi.registerShortcut("ctrl+shift+c", {
description: "Copy a raw fenced code block from the current Claude chat session (bypasses ANSI rendering).",
handler: async (ctx) => {
if (!currentDetails) {
ctx.ui.notify(
"No active chat-claude session — start one with /claude first.",
"info",
);
return;
}
const blocks = extractCodeBlocksFromSession(currentDetails);
if (blocks.length === 0) {
ctx.ui.notify(
"No fenced code blocks found in the current chat-claude session.",
"info",
);
return;
}
let chosen: ExtractedCodeBlock;
if (blocks.length === 1 || !ctx.hasUI) {
// Single block or no UI — copy the newest (index 0) directly.
chosen = blocks[0]!;
} else {
// Multiple blocks — present a picker, numbered for uniqueness.
// Number prefix guarantees distinct labels even when two blocks
// share the same first line.
const labels = blocks.map((b, i) => `${i + 1}. ${b.label}`);
const pick = await askSingleQuestionWithInlineNote(ctx.ui, {
question: `${blocks.length} code blocks in this session — pick one to copy:`,
options: labels.map((label) => ({ label })),
recommended: 0, // default: newest block
});
if (pick.selectedOptions.length === 0) return; // user cancelled
const idx = labels.indexOf(pick.selectedOptions[0] ?? "");
if (idx < 0) return;
chosen = blocks[idx]!;
}
copyToClipboard(chosen.code);
const lines = chosen.code.split("\n").length;
const langNote = chosen.lang ? ` (${chosen.lang})` : "";
ctx.ui.notify(
`Copied${langNote} · ${lines} line${lines === 1 ? "" : "s"}`,
"success",
);
},
});
// ── Message renderer ─────────────────────────────────────────────────────
// ONE custom message type holds the WHOLE chat-mode session. Returning a
// live component (render reads `details.turns` on every frame) lets