pi
This commit is contained in:
@@ -27,7 +27,7 @@
|
|||||||
import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
|
import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
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 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 { Box, Container, matchesKey, Markdown, Spacer, Text, truncateToWidth, TUI, visibleWidth, type Component, type EditorTheme } from "@mariozechner/pi-tui";
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +43,7 @@ import { askSingleQuestionWithInlineNote } from "./pi-ask-tool/ask-inline-ui.js"
|
|||||||
// Orange styling
|
// Orange styling
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const ORANGE = "\x1b[38;5;208m"; // pumpkin / tangerine
|
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 RESET = "\x1b[0m";
|
||||||
const BOLD = "\x1b[1m";
|
const BOLD = "\x1b[1m";
|
||||||
const orange = (s: string) => ORANGE + s + RESET;
|
const orange = (s: string) => ORANGE + s + RESET;
|
||||||
@@ -184,6 +184,25 @@ const MODELS = ["haiku", "sonnet", "opus"] as const;
|
|||||||
type Model = (typeof MODELS)[number];
|
type Model = (typeof MODELS)[number];
|
||||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
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).
|
// Past-session discovery (used by /claude-resume).
|
||||||
//
|
//
|
||||||
@@ -418,8 +437,15 @@ function loadSessionTurns(sessionId: string, cwd: string, fallbackModel: Model):
|
|||||||
sawToolResult = true;
|
sawToolResult = true;
|
||||||
const { text } = tool_resultText(block.content);
|
const { text } = tool_resultText(block.content);
|
||||||
const isError = block.is_error === true;
|
const isError = block.is_error === true;
|
||||||
if (current) {
|
// TS 5.x loses narrowing of the `let current` that is
|
||||||
for (const tb of current.blocks) {
|
// 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) {
|
if (tb.type === "tool" && tb.id === block.tool_use_id) {
|
||||||
tb.result = { text, isError };
|
tb.result = { text, isError };
|
||||||
break;
|
break;
|
||||||
@@ -503,6 +529,120 @@ interface ChatSessionDetails {
|
|||||||
turns: ChatTurn[];
|
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
|
// Extension entry point
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -535,18 +675,29 @@ interface ChatClaudePersistedState {
|
|||||||
// so the user's choice survives the extension teardown the same way
|
// so the user's choice survives the extension teardown the same way
|
||||||
// resumable session ids do.
|
// resumable session ids do.
|
||||||
effort: Effort;
|
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__";
|
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 {
|
function getPersistedState(): ChatClaudePersistedState {
|
||||||
const g = globalThis as unknown as Record<string, ChatClaudePersistedState>;
|
const g = globalThis as unknown as Record<string, ChatClaudePersistedState>;
|
||||||
let state = g[CHAT_CLAUDE_STATE_KEY];
|
let state = g[CHAT_CLAUDE_STATE_KEY];
|
||||||
if (!state) {
|
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;
|
g[CHAT_CLAUDE_STATE_KEY] = state;
|
||||||
}
|
}
|
||||||
// Back-fill for any persisted state written by an older revision of
|
// Back-fill for any persisted state written by an older revision of
|
||||||
// the extension (pre-/claude-effort) that didn't carry an effort field.
|
// the extension (pre-/claude-effort) that didn't carry an effort field.
|
||||||
if (!state.effort) state.effort = DEFAULT_EFFORT;
|
if (!state.effort) state.effort = DEFAULT_EFFORT;
|
||||||
|
// Back-fill for pre-promptHistory revisions.
|
||||||
|
if (!state.promptHistory) state.promptHistory = [];
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +725,11 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// current chat-claude-session message.
|
// current chat-claude-session message.
|
||||||
let tuiRef: { requestRender: () => void } | null = null;
|
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
|
// The in-flight chat session's `details` object. Stored by reference so
|
||||||
// mutations here are reflected in the CustomMessage already displayed
|
// mutations here are reflected in the CustomMessage already displayed
|
||||||
// in pi's conversation. Null between chat-mode sessions.
|
// in pi's conversation. Null between chat-mode sessions.
|
||||||
@@ -625,7 +781,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const md = getMarkdownTheme();
|
const md = getMarkdownTheme();
|
||||||
|
|
||||||
if (turn.role === "user") {
|
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 Spacer(1));
|
||||||
container.addChild(new Markdown(turn.text.trim(), 1, 0, md));
|
container.addChild(new Markdown(turn.text.trim(), 1, 0, md));
|
||||||
return;
|
return;
|
||||||
@@ -635,24 +791,15 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const icon =
|
const icon =
|
||||||
turn.cancelled ? orange("◇ ")
|
turn.cancelled ? orange("◇ ")
|
||||||
: turn.error ? theme.fg("error", "✗ ")
|
: turn.error ? theme.fg("error", "✗ ")
|
||||||
: turn.isResume ? orange("↩ ")
|
: turn.isResume ? orange(" ")
|
||||||
: orange("◆ ");
|
: orange("◆ ");
|
||||||
const header =
|
const header =
|
||||||
icon + orangeBold(`Claude ${capitalize(turn.model)}`)
|
icon + orangeBold(`Claude ${capitalize(turn.model)}`)
|
||||||
+ (turn.sessionId ? theme.fg("dim", ` session:${turn.sessionId.slice(0, 8)}`) : "")
|
+ (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 Text(header, 1, 0));
|
||||||
container.addChild(new Spacer(1));
|
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
|
// 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
|
// keep a safety net here in case a future Claude CLI change re-orders
|
||||||
// events differently.
|
// 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);
|
const usage = formatUsage(turn as any);
|
||||||
if (usage) {
|
if (usage) {
|
||||||
container.addChild(new Spacer(1));
|
container.addChild(new Spacer(1));
|
||||||
@@ -816,17 +972,55 @@ export default function (pi: ExtensionAPI) {
|
|||||||
tuiRef = tui; // ← captured for live streaming re-renders
|
tuiRef = tui; // ← captured for live streaming re-renders
|
||||||
return {
|
return {
|
||||||
invalidate: () => {},
|
invalidate: () => {},
|
||||||
render: () => {
|
render: (width: number) => {
|
||||||
const rail = orange("▌ ");
|
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 title = orangeBold("◆ CLAUDE CHAT MODE");
|
||||||
const modelLabel = orangeBold(modelUp);
|
const modelLabel = orangeBold(modelUp);
|
||||||
const sessionTag = orangeDim("session:" + short);
|
const sessionTag = orangeDim("session:" + short);
|
||||||
const effortTag = orangeDim("effort:" + persisted.effort);
|
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 line1 = rail + title + " " + modelLabel + " " + sessionTag + " " + effortTag + running;
|
||||||
const line2 = rail + theme.fg("dim",
|
const line2 = rail + theme.fg("dim",
|
||||||
"Type to chat · /claude haiku|sonnet|opus · /claude-new · /claude-effort · /claude-end · /claude-abort");
|
"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" });
|
}, { placement: "aboveEditor" });
|
||||||
@@ -848,6 +1042,22 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// setCustomEditorComponent copies onto the custom editor at install time
|
// setCustomEditorComponent copies onto the custom editor at install time
|
||||||
// (see interactive-mode.js setCustomEditorComponent, ~line 1258).
|
// (see interactive-mode.js setCustomEditorComponent, ~line 1258).
|
||||||
class ChatEscEditor extends CustomEditor {
|
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 {
|
handleInput(data: string): void {
|
||||||
if (matchesKey(data, "escape") && isGenerating && currentAbort) {
|
if (matchesKey(data, "escape") && isGenerating && currentAbort) {
|
||||||
try { currentAbort.abort(); } catch { /* ok */ }
|
try { currentAbort.abort(); } catch { /* ok */ }
|
||||||
@@ -936,6 +1146,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
askBridge = null;
|
askBridge = null;
|
||||||
// Restore pi's default editor (undoes ChatEscEditor from enterChatMode).
|
// Restore pi's default editor (undoes ChatEscEditor from enterChatMode).
|
||||||
if (ctx?.hasUI) ctx.ui.setEditorComponent(undefined);
|
if (ctx?.hasUI) ctx.ui.setEditorComponent(undefined);
|
||||||
|
editorRef = null;
|
||||||
syncUI(ctx);
|
syncUI(ctx);
|
||||||
if (ctx?.hasUI) ctx.ui.notify("Exited chat mode — back to normal pi.", "info");
|
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 model = chatMode;
|
||||||
const details = ensureSessionMessage();
|
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
|
// Append user turn + placeholder assistant turn up front so the
|
||||||
// border extends as soon as the user hits enter.
|
// border extends as soon as the user hits enter.
|
||||||
details.turns.push({ role: "user", text: userText });
|
details.turns.push({ role: "user", text: userText });
|
||||||
@@ -986,7 +1212,9 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await runClaude(userText, {
|
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,
|
sessionId: existingSession,
|
||||||
cwd: ctx.cwd,
|
cwd: ctx.cwd,
|
||||||
signal: currentAbort.signal,
|
signal: currentAbort.signal,
|
||||||
@@ -1337,6 +1565,71 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// pi.registerShortcut("escape", …) is silently ignored. ESC-to-abort is
|
// pi.registerShortcut("escape", …) is silently ignored. ESC-to-abort is
|
||||||
// wired via the ChatEscEditor custom editor installed in enterChatMode.
|
// 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 ─────────────────────────────────────────────────────
|
// ── Message renderer ─────────────────────────────────────────────────────
|
||||||
// ONE custom message type holds the WHOLE chat-mode session. Returning a
|
// ONE custom message type holds the WHOLE chat-mode session. Returning a
|
||||||
// live component (render reads `details.turns` on every frame) lets
|
// live component (render reads `details.turns` on every frame) lets
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ function transformSpecialTags(text: string): string {
|
|||||||
* the "staircase" where the banners have different widths is an intentional
|
* the "staircase" where the banners have different widths is an intentional
|
||||||
* stylistic cue, not a rendering glitch.
|
* stylistic cue, not a rendering glitch.
|
||||||
*/
|
*/
|
||||||
const ORANGE_BG_FN = (s: string) => "\x1b[48;5;130m" + s + "\x1b[0m";
|
const ORANGE_BG_FN = (s: string) => "\x1b[48;5;94m" + s + "\x1b[0m";
|
||||||
|
|
||||||
export function renderToolBlock(block: ToolBlock, theme: Theme): Container {
|
export function renderToolBlock(block: ToolBlock, theme: Theme): Container {
|
||||||
const c = new Container();
|
const c = new Container();
|
||||||
@@ -438,7 +438,7 @@ export interface RunClaudeOptions {
|
|||||||
extraEnv?: NodeJS.ProcessEnv; // merged on top of process.env for the child
|
extraEnv?: NodeJS.ProcessEnv; // merged on top of process.env for the child
|
||||||
cwd: string;
|
cwd: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
timeoutMs?: number; // default: 15 min
|
timeoutMs?: number; // default: 30 min
|
||||||
onUpdate: (partial: { blocks: StreamBlock[]; finalText: string }) => void;
|
onUpdate: (partial: { blocks: StreamBlock[]; finalText: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ export interface RunClaudeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise<RunClaudeResult> {
|
export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise<RunClaudeResult> {
|
||||||
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -509,6 +509,18 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
|
|||||||
let buffer = "";
|
let buffer = "";
|
||||||
const blocks: StreamBlock[] = [];
|
const blocks: StreamBlock[] = [];
|
||||||
const pendingTools = new Map<number, { name: string; inputJson: string; id: string }>();
|
const pendingTools = new Map<number, { name: string; inputJson: string; id: string }>();
|
||||||
|
// Track active thinking content blocks by content-block index so
|
||||||
|
// thinking_delta events can be routed to the exact block that
|
||||||
|
// content_block_start opened, and content_block_stop can decide
|
||||||
|
// whether to inject a "redacted" placeholder when nothing streamed.
|
||||||
|
//
|
||||||
|
// This matters for Claude Opus: the API returns Opus's reasoning as
|
||||||
|
// an encrypted signature only — it emits `content_block_start` with
|
||||||
|
// `type: "thinking"`, a `signature_delta`, and `content_block_stop`,
|
||||||
|
// but NEVER any `thinking_delta`. Without special-casing, the user
|
||||||
|
// sees nothing where thinking should be; with the placeholder, they
|
||||||
|
// at least know the model thought (just off-record).
|
||||||
|
const pendingThinkings = new Map<number, ThinkingBlock>();
|
||||||
|
|
||||||
let sessionId = "";
|
let sessionId = "";
|
||||||
let costUsd = 0, inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
|
let costUsd = 0, inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
|
||||||
@@ -521,7 +533,14 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
|
|||||||
return b;
|
return b;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOrCreateThinkingBlock = (): ThinkingBlock => {
|
// Thinking block lookup: prefer the one registered by content_block_start
|
||||||
|
// for `index`; fall back to the tail-merge behaviour for CLIs / edge cases
|
||||||
|
// that never emit content_block_start for thinking.
|
||||||
|
const getOrCreateThinkingBlock = (index?: number): ThinkingBlock => {
|
||||||
|
if (index !== undefined) {
|
||||||
|
const b = pendingThinkings.get(index);
|
||||||
|
if (b) return b;
|
||||||
|
}
|
||||||
const last = blocks[blocks.length - 1];
|
const last = blocks[blocks.length - 1];
|
||||||
if (last?.type === "thinking") return last;
|
if (last?.type === "thinking") return last;
|
||||||
const b: ThinkingBlock = { type: "thinking", text: "" };
|
const b: ThinkingBlock = { type: "thinking", text: "" };
|
||||||
@@ -547,6 +566,17 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
|
|||||||
const cb = e.content_block;
|
const cb = e.content_block;
|
||||||
if (cb?.type === "tool_use") {
|
if (cb?.type === "tool_use") {
|
||||||
pendingTools.set(e.index as number, { name: cb.name, inputJson: "", id: cb.id });
|
pendingTools.set(e.index as number, { name: cb.name, inputJson: "", id: cb.id });
|
||||||
|
} else if (cb?.type === "thinking") {
|
||||||
|
// Eagerly push an empty thinking block so renderers can show
|
||||||
|
// *something* the moment Opus opens a thinking block — even
|
||||||
|
// if no thinking_delta ever arrives (it won't, for Opus).
|
||||||
|
// Initial text from content_block is usually "" but honour
|
||||||
|
// it if the CLI ever populates it.
|
||||||
|
const initial = typeof cb.thinking === "string" ? cb.thinking : "";
|
||||||
|
const b: ThinkingBlock = { type: "thinking", text: initial };
|
||||||
|
blocks.push(b);
|
||||||
|
pendingThinkings.set(e.index as number, b);
|
||||||
|
emit();
|
||||||
}
|
}
|
||||||
} else if (e.type === "content_block_delta") {
|
} else if (e.type === "content_block_delta") {
|
||||||
const d = e.delta as any;
|
const d = e.delta as any;
|
||||||
@@ -554,13 +584,24 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
|
|||||||
getOrCreateTextBlock().text += d.text as string;
|
getOrCreateTextBlock().text += d.text as string;
|
||||||
emit();
|
emit();
|
||||||
} else if (d?.type === "thinking_delta") {
|
} else if (d?.type === "thinking_delta") {
|
||||||
getOrCreateThinkingBlock().text += d.thinking as string;
|
getOrCreateThinkingBlock(e.index as number).text += d.thinking as string;
|
||||||
emit();
|
emit();
|
||||||
} else if (d?.type === "input_json_delta") {
|
} else if (d?.type === "input_json_delta") {
|
||||||
const tool = pendingTools.get(e.index as number);
|
const tool = pendingTools.get(e.index as number);
|
||||||
if (tool) tool.inputJson += d.partial_json as string ?? "";
|
if (tool) tool.inputJson += d.partial_json as string ?? "";
|
||||||
}
|
}
|
||||||
} else if (e.type === "content_block_stop") {
|
} else if (e.type === "content_block_stop") {
|
||||||
|
// Finalise a thinking block: if nothing streamed (Opus case),
|
||||||
|
// replace the empty text with a visible placeholder so the
|
||||||
|
// user knows the model DID think, just not on-record.
|
||||||
|
const think = pendingThinkings.get(e.index as number);
|
||||||
|
if (think) {
|
||||||
|
if (!think.text.trim()) {
|
||||||
|
think.text = "_(thinking privately — this model's reasoning isn't streamed as plaintext)_";
|
||||||
|
}
|
||||||
|
pendingThinkings.delete(e.index as number);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
const tool = pendingTools.get(e.index as number);
|
const tool = pendingTools.get(e.index as number);
|
||||||
if (tool) {
|
if (tool) {
|
||||||
// Claude CLI's --include-partial-messages can emit an `assistant`
|
// Claude CLI's --include-partial-messages can emit an `assistant`
|
||||||
@@ -635,7 +676,13 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
|
|||||||
text = "";
|
text = "";
|
||||||
}
|
}
|
||||||
const toolId = c.tool_use_id as string;
|
const toolId = c.tool_use_id as string;
|
||||||
const toolBlock = blocks.findLast((b): b is ToolBlock => b.type === "tool" && b.id === toolId);
|
// findLast is ES2023; use a backwards loop so the ES2022
|
||||||
|
// target compiles cleanly without a lib override.
|
||||||
|
let toolBlock: ToolBlock | undefined;
|
||||||
|
for (let _i = blocks.length - 1; _i >= 0; _i--) {
|
||||||
|
const _b = blocks[_i];
|
||||||
|
if (_b.type === "tool" && _b.id === toolId) { toolBlock = _b; break; }
|
||||||
|
}
|
||||||
if (toolBlock && toolBlock.name.toLowerCase() === "edit" && !c.is_error && opts.enrichEditDiffs) {
|
if (toolBlock && toolBlock.name.toLowerCase() === "edit" && !c.is_error && opts.enrichEditDiffs) {
|
||||||
try {
|
try {
|
||||||
const inp = JSON.parse(toolBlock.inputJson) as Record<string, unknown>;
|
const inp = JSON.parse(toolBlock.inputJson) as Record<string, unknown>;
|
||||||
@@ -692,7 +739,7 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
|
|||||||
if (timeoutFired) {
|
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.`));
|
reject(new Error(`Claude timed out after ${timeoutMs / 1000}s — the process was killed. Consider using a simpler prompt or a faster model.`));
|
||||||
} else {
|
} else {
|
||||||
const errMsg = formatAnthropicError(stderrOutput.trim(), code);
|
const errMsg = formatAnthropicError(stderrOutput.trim(), code ?? undefined);
|
||||||
const detail = finalText ? ` (partial output: ${finalText.slice(0, 100)})` : "";
|
const detail = finalText ? ` (partial output: ${finalText.slice(0, 100)})` : "";
|
||||||
reject(new Error(errMsg + detail));
|
reject(new Error(errMsg + detail));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||||
"name": "wezterm-sync-9a35138e",
|
"name": "wezterm-sync-ba8a76f5",
|
||||||
"vars": {
|
"vars": {
|
||||||
"bg": "#faf4ed",
|
"bg": "#1c2433",
|
||||||
"fg": "#2a2a2a",
|
"fg": "#afbbd2",
|
||||||
"accent": "#7b4fc4",
|
"accent": "#b78aff",
|
||||||
"accentAlt": "#c45a1c",
|
"accentAlt": "#ff955c",
|
||||||
"link": "#1a7db5",
|
"link": "#69c3ff",
|
||||||
"error": "#d1344f",
|
"error": "#ff738a",
|
||||||
"success": "#1e9b52",
|
"success": "#3cec85",
|
||||||
"warning": "#b8890f",
|
"warning": "#eacd61",
|
||||||
"muted": "#73716e",
|
"muted": "#7c869a",
|
||||||
"dim": "#9c9995",
|
"dim": "#5e687b",
|
||||||
"borderMuted": "#c6c2bc",
|
"borderMuted": "#414a5b",
|
||||||
"selectedBg": "#eee8e1",
|
"selectedBg": "#28303f",
|
||||||
"userMsgBg": "#f2ece5",
|
"userMsgBg": "#242c3b",
|
||||||
"toolPendingBg": "#f5efe8",
|
"toolPendingBg": "#212938",
|
||||||
"toolSuccessBg": "#e0e9da",
|
"toolSuccessBg": "#203c3d",
|
||||||
"toolErrorBg": "#f5ddda",
|
"toolErrorBg": "#372d3d",
|
||||||
"customMsgBg": "#f0e7ea"
|
"customMsgBg": "#282c43"
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"accent": "accent",
|
"accent": "accent",
|
||||||
@@ -74,8 +74,8 @@
|
|||||||
"bashMode": "success"
|
"bashMode": "success"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"pageBg": "#fffcf5",
|
"pageBg": "#141c2b",
|
||||||
"cardBg": "#faf4ed",
|
"cardBg": "#1c2433",
|
||||||
"infoBg": "#f2e7d2"
|
"infoBg": "#353839"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user