pi
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user