/** * pi-clip — Copy assistant output to clipboard, granularly. * * pi already ships `/copy` and `Ctrl+X` to copy the *whole* last assistant * message. pi-clip complements that by letting you pick a single structural * block — a code block, a table (cell/row/column/whole), a list, a blockquote, * or the full raw markdown — or, for prose that doesn't fit neat block * boundaries, select an arbitrary line range with `/clip lines`. * * Commands: * /clip Searchable picker over the last response's blocks * /clip code Pick a code block (copies directly if there's only one) * /clip list Pick a list (copies directly if there's only one) * /clip table Pick a table, then open the cell grid * /clip lines Visual line-range selector (or `/clip lines N-M` to copy directly) * /clip all Copy the full conversation (user + assistant) as markdown * * Shortcut: * ctrl+shift+c Same as /clip (override via PI_CLIP_SHORTCUT env var) * * Clipboard uses pi's built-in `copyToClipboard`, which handles the native OS * clipboard addon, OSC 52 (SSH/mosh), Wayland, Termux, and WSL. */ import { copyToClipboard, DynamicBorder, type ExtensionAPI, type ExtensionContext, type SessionEntry, type Theme, } from "@earendil-works/pi-coding-agent"; import { Container, Key, type KeyId, matchesKey, Text, truncateToWidth, visibleWidth, type AutocompleteItem, } from "@earendil-works/pi-tui"; // ── Clipboard ──────────────────────────────────────────────────────────────── async function doCopy(text: string, label: string, ctx: ExtensionContext): Promise { try { await copyToClipboard(text); const lines = text.split("\n").length; ctx.ui.notify( `Copied ${label} (${lines} line${lines === 1 ? "" : "s"}, ${text.length} chars)`, "info", ); } catch (e) { ctx.ui.notify(`Failed to copy: ${e instanceof Error ? e.message : String(e)}`, "error"); } } // ── Content Extraction ─────────────────────────────────────────────────────── interface TextPart { type: "text"; text: string; } function extractTextContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .filter( (p): p is TextPart => p !== null && typeof p === "object" && (p as { type?: unknown }).type === "text" && typeof (p as { text?: unknown }).text === "string", ) .map((p) => p.text) .join("\n"); } /** Text of the last assistant message on the branch (skips aborted/empty). */ function getLastAssistantText(entries: SessionEntry[]): string | null { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "assistant") continue; // Match pi core's getLastAssistantText(): skip aborted messages with no content. if (msg.stopReason === "aborted" && msg.content.length === 0) continue; const text = extractTextContent(msg.content); if (text.trim()) return text; } return null; } /** Full conversation (user + assistant) as markdown, separated by horizontal rules. */ function getAllConversationText(entries: SessionEntry[]): string { const sections: string[] = []; for (const entry of entries) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "user" && msg.role !== "assistant") continue; const text = extractTextContent(msg.content); if (!text.trim()) continue; const label = msg.role === "user" ? "User" : "Assistant"; sections.push(`## ${label}\n\n${text}`); } return sections.join("\n\n---\n\n"); } // ── Block Extraction ───────────────────────────────────────────────────────── // // A single state-machine pass over the lines classifies regions into one of: // code, table, list, quote. Prose, headings, and horizontal rules are // intentionally NOT captured as blocks — their boundaries are too ambiguous to // guess, so that granularity is handed to the user via `/clip lines`. type BlockKind = "code" | "table" | "list" | "quote"; export interface Block { kind: BlockKind; content: string; // raw markdown for the block lang?: string; // code blocks only headers?: string[]; // tables only rows?: string[][]; // tables only } function isTableLine(line: string): boolean { const t = line.trim(); return t.startsWith("|") && t.endsWith("|") && t.length >= 2; } function isTableSeparator(line: string): boolean { return parseTableRow(line).every((c) => /^:?-+:?$/.test(c)); } function isListItemStart(line: string): boolean { return /^\s*([-*+]\s+|\d+[.)]\s+)/.test(line); } function isBlank(line: string): boolean { return line.trim() === ""; } /** A loose list allows blank lines between items (and indented continuation). */ function looksLikeListContinuation(line: string): boolean { // Indented (nested item or continuation paragraph) OR another list marker. return /^\s+\S/.test(line) || isListItemStart(line); } function parseTableRow(line: string): string[] { return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()); } interface ParsedTable { headers: string[]; rows: string[][]; raw: string; } /** Promote a `table` Block into a ParsedTable for the cell grid. */ function toParsedTable(block: Block): ParsedTable { const lines = block.content.split("\n"); const headers = parseTableRow(lines[0] ?? ""); let dataStart = 1; if (lines.length > 1 && isTableSeparator(lines[1])) dataStart = 2; const rows: string[][] = []; for (let i = dataStart; i < lines.length; i++) { if (!isTableSeparator(lines[i])) rows.push(parseTableRow(lines[i])); } return { headers, rows, raw: block.content }; } export function extractBlocks(text: string): Block[] { const lines = text.split("\n"); const blocks: Block[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; // Fenced code block: ``` or ~~~ const fenceMatch = /^(\s*)(```+|~~~+)(.*)$/.exec(line ?? ""); if (fenceMatch) { const fence = fenceMatch[2]; const fenceChar = fence[0]; const fenceLen = fence.length; const lang = fenceMatch[3].trim(); const start = i + 1; let end = start; // CommonMark: the closing fence is a run of the *same* fence char *at // least as long* as the opening fence. The previous `` `${fence}.*` `` // matched the opening fence as a literal prefix of the *exact* opening // length, so a *longer* valid close (e.g. ```` closing a ``` block — // common when models escape inner fences) was rejected and the block // ran to EOF, swallowing the rest of the response. We keep the tail // lenient (`.*`) on purpose: a malformed close that repeats the info // string (```lang``` on the closing line) still terminates the block // rather than over-eating to EOF. const closeRe = new RegExp( `^\\s*${fenceChar.replace(/[~`]/g, "\\$&")}{${fenceLen},}.*`, ); while (end < lines.length && !closeRe.test(lines[end])) { end++; } const code = lines.slice(start, end).join("\n"); blocks.push({ kind: "code", content: code, lang: lang || "text" }); i = end + 1; // skip closing fence (at end-of-doc this overshoots and exits) continue; } // Table: consecutive table lines (at least header + separator + one row) if (isTableLine(line)) { const start = i; let end = i; while (end < lines.length && isTableLine(lines[end])) end++; if (end - start >= 2 && isTableSeparator(lines[start + 1] ?? "")) { const raw = lines.slice(start, end).join("\n"); const parsed = toParsedTable({ kind: "table", content: raw }); blocks.push({ kind: "table", content: raw, headers: parsed.headers, rows: parsed.rows, }); i = end; continue; } // Not a real table (no separator) — falls through to the catch-all. } // List: consecutive list items, allowing nested indented lines and // blank lines between items (loose lists). if (isListItemStart(line)) { const start = i; let end = i + 1; while (end < lines.length) { const cur = lines[end]; if (isListItemStart(cur) || looksLikeListContinuation(cur)) { end++; continue; } if (isBlank(cur)) { // Peek: a blank line continues the list only if a list item or // indented continuation follows. Otherwise the list ends. const next = lines[end + 1]; if (next !== undefined && (isListItemStart(next) || /^\s+\S/.test(next))) { end++; continue; } break; } break; } blocks.push({ kind: "list", content: lines.slice(start, end).join("\n").trimEnd() }); i = end; continue; } // Blockquote: consecutive `>` lines (blank lines break it). if (/^\s{0,3}>/.test(line)) { const start = i; let end = i + 1; while (end < lines.length && /^\s{0,3}>/.test(lines[end])) end++; blocks.push({ kind: "quote", content: lines.slice(start, end).join("\n") }); i = end; continue; } // Anything else (prose, headings, blank lines, horizontal rules) is left // for the `/clip lines` visual selector — paragraph/heading boundaries are // too ambiguous to guess, so we hand that granularity to the user. i++; } return blocks; } // ── Inline cleaning (for table cells) ──────────────────────────────────────── function stripMarkdownInline(text: string): string { return text .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/`([^`]+)`/g, "$1") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); } // ── Shared UI helpers (borders) ────────────────────────────────────────────── function borderTop(title: string, innerW: number, theme: Theme): string { const tw = visibleWidth(title); const lp = Math.floor((innerW - tw) / 2); const rp = Math.max(0, innerW - tw - lp); return ( theme.fg("border", "╭" + "─".repeat(lp)) + theme.fg("accent", theme.bold(title)) + theme.fg("border", "─".repeat(rp) + "╮") ); } function borderMid(innerW: number, theme: Theme): string { return theme.fg("border", "├" + "─".repeat(innerW) + "┤"); } /** A mid border with a centered title (used as a section header), CJK-safe. */ function borderMidTitle(title: string, innerW: number, theme: Theme): string { const tw = visibleWidth(title); if (tw >= innerW) return theme.fg("border", "├" + "─".repeat(innerW) + "┤"); const lp = Math.floor((innerW - tw) / 2); const rp = Math.max(0, innerW - tw - lp); return ( theme.fg("border", "├" + "─".repeat(lp)) + theme.fg("dim", theme.bold(title)) + theme.fg("border", "─".repeat(rp) + "┤") ); } function borderBot(innerW: number, theme: Theme): string { return theme.fg("border", "╰" + "─".repeat(innerW) + "╯"); } /** Wrap a content line with side borders, truncating/padding to `width`. */ function padLine(content: string, width: number, theme: Theme): string { return ( theme.fg("border", "│") + truncateToWidth(content, width - 2, "…", true) + theme.fg("border", "│") ); } /** Pad/truncate text to an exact display width (CJK-safe). */ function padToWidth(text: string, width: number): string { return truncateToWidth(text, width, "", true); } // ── Table Grid Dialog ──────────────────────────────────────────────────────── async function openTableGrid(table: ParsedTable, ctx: ExtensionContext): Promise { const result = await ctx.ui.custom( (tui, theme, _kb, done) => { let cursorRow = 0; // -1 == header row let cursorCol = 0; const clean = (r: number, c: number): string => { if (r === -1) return stripMarkdownInline(table.headers[c] ?? ""); return stripMarkdownInline(table.rows[r]?.[c] ?? ""); }; const getCellText = () => clean(cursorRow, cursorCol); const getRowText = () => { const src = cursorRow === -1 ? table.headers : (table.rows[cursorRow] ?? []); return src.map((_, ci) => clean(cursorRow, ci)).join("\t"); }; const getColumnText = () => { const vals = [stripMarkdownInline(table.headers[cursorCol] ?? "")]; for (let ri = 0; ri < table.rows.length; ri++) vals.push(clean(ri, cursorCol)); return vals.join("\n"); }; const getAllText = () => table.raw; const colWidths = table.headers.map((h, ci) => { let max = visibleWidth(stripMarkdownInline(h)); for (const row of table.rows) { const w = visibleWidth(stripMarkdownInline(row[ci] ?? "")); if (w > max) max = w; } return Math.min(Math.max(max, 4), 40); }); const maxVisibleRows = 14; return { render(width: number): string[] { const innerW = Math.max(1, width - 2); const lines: string[] = []; lines.push( borderTop( ` Table (${table.rows.length} rows × ${table.headers.length} cols) `, innerW, theme, ), ); lines.push(padLine("", width, theme)); const headerCells = table.headers.map((h, ci) => { const padded = padToWidth(stripMarkdownInline(h), colWidths[ci]); const highlighted = cursorRow === -1 && cursorCol === ci; return highlighted ? theme.bg("selectedBg", theme.fg("accent", padded)) : theme.fg("text", theme.bold(padded)); }); lines.push(padLine(` ${headerCells.join(theme.fg("border", " │ "))}`, width, theme)); const sep = colWidths.map((w) => "─".repeat(w)).join("─┼─"); lines.push(padLine(` ${theme.fg("border", sep)}`, width, theme)); const startRow = Math.max(0, cursorRow - Math.floor(maxVisibleRows / 2)); const endRow = Math.min(table.rows.length, startRow + maxVisibleRows); for (let ri = startRow; ri < endRow; ri++) { const row = table.rows[ri] ?? []; const cells = table.headers.map((_, ci) => { const padded = padToWidth(stripMarkdownInline(row[ci] ?? ""), colWidths[ci]); const highlighted = cursorRow === ri && cursorCol === ci; return highlighted ? theme.bg("selectedBg", theme.fg("accent", padded)) : padded; }); lines.push(padLine(` ${cells.join(theme.fg("border", " │ "))}`, width, theme)); } if (table.rows.length > maxVisibleRows) { lines.push( padLine( ` ${theme.fg("dim", `${startRow + 1}–${endRow} of ${table.rows.length}`)}`, width, theme, ), ); } lines.push(borderMid(innerW, theme)); const cell = getCellText(); const preview = visibleWidth(cell) > 50 ? truncateToWidth(cell, 50, "…") : cell; lines.push(padLine(` ${theme.fg("muted", "Cell:")} ${theme.fg("text", preview)}`, width, theme)); const actions = [ `${theme.fg("accent", "enter")} copy cell`, `${theme.fg("accent", "r")} copy row`, `${theme.fg("accent", "c")} copy column`, `${theme.fg("accent", "a")} copy all`, `${theme.fg("accent", "esc")} back`, ].join(theme.fg("dim", " · ")); lines.push(padLine(` ${actions}`, width, theme)); lines.push(borderBot(innerW, theme)); return lines; }, invalidate() {}, handleInput(data: string) { if (matchesKey(data, Key.escape)) { done(null); return; } if (matchesKey(data, Key.up)) { if (cursorRow > -1) { cursorRow--; tui.requestRender(); } return; } if (matchesKey(data, Key.down)) { if (cursorRow < table.rows.length - 1) { cursorRow++; tui.requestRender(); } return; } if (matchesKey(data, Key.left)) { if (cursorCol > 0) { cursorCol--; tui.requestRender(); } return; } if (matchesKey(data, Key.right)) { if (cursorCol < table.headers.length - 1) { cursorCol++; tui.requestRender(); } return; } if (matchesKey(data, Key.enter)) { done(getCellText()); return; } if (matchesKey(data, "r")) { done(getRowText()); return; } if (matchesKey(data, "c")) { done(getColumnText()); return; } if (matchesKey(data, "a")) { done(getAllText()); return; } }, }; }, { overlay: true, overlayOptions: { anchor: "center", width: "85%", minWidth: 60, maxHeight: "85%" } }, ); if (result !== null) await doCopy(result, "table selection", ctx); } // ── Picker Items ───────────────────────────────────────────────────────────── type PickerKind = BlockKind | "full"; interface PickerItem { kind: PickerKind; label: string; // shown after the type badge description: string; // first-line preview content: string; tableBlock?: Block; // for table items -> grid } const KIND_BADGE: Record = { code: "code", table: "table", list: "list", quote: "quote", full: "full", }; function firstLine(text: string, max = 64): string { return text.split("\n")[0]?.slice(0, max) ?? ""; } function countListItems(content: string): number { return content.split("\n").filter((l) => isListItemStart(l)).length; } function blockToItem(block: Block, index: number, totalOfType: number): PickerItem { const badge = KIND_BADGE[block.kind]; const suffix = totalOfType > 1 ? ` ${index + 1}` : ""; switch (block.kind) { case "code": return { kind: "code", label: `Code block${suffix} [${block.lang ?? "text"}]`, description: firstLine(block.content), content: block.content, }; case "table": { const headers = block.headers ?? []; const rows = block.rows ?? []; return { kind: "table", label: `Table${suffix} (${rows.length} rows × ${headers.length} cols)`, description: headers.map((h) => stripMarkdownInline(h)).join(", ").slice(0, 64), content: block.content, tableBlock: block, }; } case "list": return { kind: "list", label: `List${suffix} (${countListItems(block.content)} items)`, description: firstLine(block.content), content: block.content, }; case "quote": return { kind: "quote", label: `Quote${suffix}`, description: firstLine(block.content.replace(/^\s{0,3}>\s?/gm, "")), content: block.content, }; } } export function buildPickerItems(text: string, blocks: Block[]): PickerItem[] { const items: PickerItem[] = []; // Count totals per kind first so per-block suffixes only appear when >1 of a // kind. (A previous version pushed once with total=0 then rewrote every // entry — that was a wasteful double pass with no benefit.) const totals: Record = { code: 0, table: 0, list: 0, quote: 0 }; for (const b of blocks) totals[b.kind]++; const perType: Record = { code: 0, table: 0, list: 0, quote: 0 }; for (const block of blocks) { const idx = perType[block.kind]++; items.push(blockToItem(block, idx, totals[block.kind])); } // Full raw markdown as the final fallback entry. items.push({ kind: "full", label: "Full response (raw md)", description: `${text.split("\n").length} lines, ${text.length} chars`, content: text, }); return items; } // ── Searchable Picker ──────────────────────────────────────────────────────── // // SelectList's built-in filter is prefix-only on `value` and doesn't capture // printable input, so we render a lightweight list ourselves: type to filter // by inclusive match across badge + label + description, arrow keys to move. function isPrintableFilterInput(data: string): boolean { if (data.length === 0) return false; // Reject control sequences (ESC-prefix, CSI, C0 controls) and DEL. return [...data].every((ch) => { const code = ch.charCodeAt(0); return code >= 32 && code !== 127; }); } function itemMatchesSearch(item: PickerItem, terms: string[]): boolean { if (terms.length === 0) return true; const haystack = `${KIND_BADGE[item.kind]} ${item.label} ${item.description}`.toLowerCase(); return terms.every((t) => haystack.includes(t)); } interface PickerState { items: PickerItem[]; filter: string; filtered: PickerItem[]; selected: number; maxVisible: number; } function makePickerState(items: PickerItem[], maxVisible: number): PickerState { const filtered = items.slice(); return { items, filter: "", filtered, selected: 0, maxVisible }; } function applyFilter(state: PickerState): void { const terms = state.filter.trim().toLowerCase().split(/\s+/).filter(Boolean); state.filtered = state.items.filter((it) => itemMatchesSearch(it, terms)); state.selected = state.filtered.length > 0 ? Math.min(state.selected, state.filtered.length - 1) : 0; } function moveSelection(state: PickerState, delta: number): void { if (state.filtered.length === 0) return; state.selected = (state.selected + delta + state.filtered.length) % state.filtered.length; } function renderPickerLine(item: PickerItem, selected: boolean, width: number, theme: Theme): string { const arrow = selected ? theme.fg("accent", "→") : " "; const badge = selected ? theme.fg("accent", theme.bold(`[${KIND_BADGE[item.kind]}]`)) : theme.fg("dim", `[${KIND_BADGE[item.kind]}]`); const labelColor = selected ? theme.fg("accent", theme.bold(item.label)) : theme.fg("text", item.label); const meta = `${arrow} ${badge} ${labelColor}`; const gap = " "; const remaining = Math.max(0, width - visibleWidth(meta) - visibleWidth(gap) - 1); const desc = remaining > 8 ? truncateToWidth(item.description, remaining, "…") : ""; const descStyled = desc ? `${gap}${theme.fg("muted", desc)}` : ""; return `${meta}${descStyled}`; } async function showPicker( items: PickerItem[], title: string, ctx: ExtensionContext, ): Promise { return ctx.ui.custom( (tui, theme, _kb, done) => { const maxVisible = 8; const previewMaxLines = 6; const state = makePickerState(items, maxVisible); return { render(width: number): string[] { const innerW = Math.max(1, width - 2); const lines: string[] = []; lines.push(borderTop(` ${title} `, innerW, theme)); lines.push(padLine("", width, theme)); if (state.filtered.length === 0) { lines.push( padLine(` ${theme.fg("warning", `No blocks match “${state.filter}”`)}`, width, theme), ); } else { const maxVis = Math.min(state.filtered.length, state.maxVisible); const start = maxVis === 0 ? 0 : Math.max( 0, Math.min(state.selected - Math.floor(maxVis / 2), state.filtered.length - maxVis), ); const end = Math.min(state.filtered.length, start + maxVis); for (let i = start; i < end; i++) { lines.push(padLine(renderPickerLine(state.filtered[i], i === state.selected, width - 2, theme), width, theme)); } if (state.filtered.length > state.maxVisible) { lines.push( padLine( ` ${theme.fg("dim", `${state.selected + 1}/${state.filtered.length}`)}`, width, theme, ), ); } } // Preview pane: first lines of the selected item's content, so the // one-line badges above don't get confused for each other. if (state.filtered.length > 0) { const sel = state.filtered[state.selected]; if (sel) { const contentLines = sel.content.split("\n"); const shown = contentLines.slice(0, previewMaxLines); lines.push( borderMidTitle( ` Preview · ${contentLines.length} line${contentLines.length === 1 ? "" : "s"} `, innerW, theme, ), ); for (const ln of shown) { lines.push(padLine(` ${theme.fg("muted", ln)}`, width, theme)); } if (contentLines.length > previewMaxLines) { const more = contentLines.length - previewMaxLines; lines.push( padLine( ` ${theme.fg("dim", `… ${more} more line${more === 1 ? "" : "s"}`)}`, width, theme, ), ); } } } lines.push(borderMid(innerW, theme)); const filterLine = state.filter ? `${theme.fg("muted", "filter:")} ${theme.fg("accent", state.filter)}` : theme.fg("dim", "type to filter · e.g. list, code, install"); lines.push(padLine(` ${filterLine}`, width, theme)); const help = `${theme.fg("accent", "↑↓")} navigate · ${theme.fg( "accent", "enter", )} copy · ${theme.fg("accent", "esc")} cancel · ${theme.fg("accent", "⌫")} clear filter`; lines.push(padLine(` ${theme.fg("dim", help)}`, width, theme)); lines.push(borderBot(innerW, theme)); return lines; }, invalidate() {}, handleInput(data: string) { // Navigation if (matchesKey(data, Key.up)) { moveSelection(state, -1); tui.requestRender(); return; } if (matchesKey(data, Key.down)) { moveSelection(state, 1); tui.requestRender(); return; } if (matchesKey(data, Key.home)) { if (state.filtered.length) state.selected = 0; tui.requestRender(); return; } if (matchesKey(data, Key.end)) { if (state.filtered.length) state.selected = state.filtered.length - 1; tui.requestRender(); return; } if (matchesKey(data, Key.enter)) { const sel = state.filtered[state.selected]; done(sel ?? null); return; } if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { done(null); return; } // Backspace / Ctrl+H: delete one filter char. if (matchesKey(data, Key.backspace) || matchesKey(data, "ctrl+h")) { if (state.filter.length > 0) { state.filter = state.filter.slice(0, -1); applyFilter(state); tui.requestRender(); } return; } // Ctrl+U: clear the whole filter. if (matchesKey(data, "ctrl+u")) { if (state.filter.length > 0) { state.filter = ""; applyFilter(state); tui.requestRender(); } return; } // Printable: append to filter. if (isPrintableFilterInput(data)) { state.filter += data; applyFilter(state); tui.requestRender(); return; } }, }; }, { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 54, maxHeight: "85%" } }, ); } // ── Command Handlers ───────────────────────────────────────────────────────── function blocksOfKind(blocks: Block[], kind: BlockKind): Block[] { return blocks.filter((b) => b.kind === kind); } /** /clip — searchable picker over the last response; copies directly if unstructured. */ async function clipPicker(ctx: ExtensionContext): Promise { const text = getLastAssistantText(ctx.sessionManager.getBranch()); if (!text) { ctx.ui.notify("No assistant response to copy", "warning"); return; } const blocks = extractBlocks(text); const items = buildPickerItems(text, blocks); // "Full response" is always the last item; structuralCount = everything else. const structuralCount = items.length - 1; if (structuralCount <= 0) { // Nothing structural to pick — copy the whole response directly. await doCopy(text, "response", ctx); return; } if (ctx.mode !== "tui") { ctx.ui.notify("/clip picker requires interactive mode; use /clip all or built-in /copy", "error"); return; } const selected = await showPicker( items, `Clip (${structuralCount} block${structuralCount === 1 ? "" : "s"})`, ctx, ); if (!selected) return; if (selected.kind === "table" && selected.tableBlock) { await openTableGrid(toParsedTable(selected.tableBlock), ctx); return; } await doCopy(selected.content, selected.label.toLowerCase(), ctx); } /** /clip code — pick a code block (copies directly if there's only one). */ async function clipCode(ctx: ExtensionContext): Promise { const text = getLastAssistantText(ctx.sessionManager.getBranch()); if (!text) { ctx.ui.notify("No assistant response to copy", "warning"); return; } const codeBlocks = blocksOfKind(extractBlocks(text), "code"); if (codeBlocks.length === 0) { ctx.ui.notify("No code blocks in last response", "warning"); return; } if (codeBlocks.length === 1) { await doCopy(codeBlocks[0].content, "code block", ctx); return; } if (ctx.mode !== "tui") { ctx.ui.notify("/clip code picker requires interactive mode", "error"); return; } const items = codeBlocks.map((b, i) => blockToItem(b, i, codeBlocks.length)); const selected = await showPicker(items, "Clip code", ctx); if (selected) await doCopy(selected.content, selected.label.toLowerCase(), ctx); } /** /clip list — pick a list (copies directly if there's only one). */ async function clipList(ctx: ExtensionContext): Promise { const text = getLastAssistantText(ctx.sessionManager.getBranch()); if (!text) { ctx.ui.notify("No assistant response to copy", "warning"); return; } const lists = blocksOfKind(extractBlocks(text), "list"); if (lists.length === 0) { ctx.ui.notify("No lists in last response", "warning"); return; } if (lists.length === 1) { await doCopy(lists[0].content, "list", ctx); return; } if (ctx.mode !== "tui") { ctx.ui.notify("/clip list picker requires interactive mode", "error"); return; } const items = lists.map((b, i) => blockToItem(b, i, lists.length)); const selected = await showPicker(items, "Clip list", ctx); if (selected) await doCopy(selected.content, selected.label.toLowerCase(), ctx); } /** /clip table — pick a table, then open the cell grid. */ async function clipTable(ctx: ExtensionContext): Promise { const text = getLastAssistantText(ctx.sessionManager.getBranch()); if (!text) { ctx.ui.notify("No assistant response to copy", "warning"); return; } const tables = blocksOfKind(extractBlocks(text), "table"); if (tables.length === 0) { ctx.ui.notify("No tables in last response", "warning"); return; } if (ctx.mode !== "tui") { if (tables.length === 1) await doCopy(tables[0].content, "table", ctx); else ctx.ui.notify("/clip table picker requires interactive mode", "error"); return; } if (tables.length === 1) { await openTableGrid(toParsedTable(tables[0]), ctx); return; } const items = tables.map((b, i) => blockToItem(b, i, tables.length)); const selected = await showPicker(items, "Clip table", ctx); if (selected && selected.tableBlock) await openTableGrid(toParsedTable(selected.tableBlock), ctx); } /** /clip all — copy the full conversation (user + assistant) as markdown. */ async function clipAll(ctx: ExtensionContext): Promise { const text = getAllConversationText(ctx.sessionManager.getBranch()); if (!text.trim()) { ctx.ui.notify("No conversation to copy", "warning"); return; } await doCopy(text, "full conversation", ctx); } // ── /clip lines: visual line-range selector ────────────────────────────────── // // For prose that doesn't fit neat block boundaries (headings, paragraphs split // by blank lines, mixed prose+code), hand the granularity to the user: show the // last response's lines with numbers, let them mark a start and end with space, // and copy that exact line range. With a selection active, the first ESC clears // the selection; a second ESC (with no selection) exits the window. Also accepts // `/clip lines N-M` / `N,M` / `N` to copy directly without opening the window. interface LineRange { start: number; // 0-based, inclusive end: number; // 0-based, inclusive } /** Parse `/clip lines` args: "", "N", "N-M", "N,M" (1-based, inclusive). Returns * null to open the visual selector, a LineRange for direct copy, or an error. */ export function parseLineRangeArgs( args: string, totalLines: number, ): LineRange | null | { error: string } { const trimmed = args.trim(); if (trimmed === "") return null; // no range -> open the visual selector const single = /^(\d+)$/.exec(trimmed); if (single) { const n = parseInt(single[1], 10); if (n < 1 || n > totalLines) return { error: `Line ${n} out of range (1–${totalLines})` }; return { start: n - 1, end: n - 1 }; } const range = /^(\d+)\s*[-,]\s*(\d+)$/.exec(trimmed); if (range) { const a = parseInt(range[1], 10); const b = parseInt(range[2], 10); const lo = Math.min(a, b); const hi = Math.max(a, b); if (lo < 1 || hi > totalLines) return { error: `Range ${lo}–${hi} out of range (1–${totalLines})` }; return { start: lo - 1, end: hi - 1 }; } return { error: `Bad range "${trimmed}". Use N, N-M, or N,M` }; } async function openLineSelector(text: string, ctx: ExtensionContext): Promise { const lines = text.split("\n"); const total = lines.length; const result = await ctx.ui.custom( (tui, theme, _kb, done) => { let cursor = 0; let selStart: number | null = null; const maxVisible = 18; const selectionRange = (): LineRange | null => { if (selStart === null) return null; // The cursor acts as the live end — the highlight follows the cursor as // you move (vim-style visual selection). Enter copies start..cursor. return { start: Math.min(selStart, cursor), end: Math.max(selStart, cursor) }; }; const selectedLines = (): string => { const r = selectionRange(); if (!r) return lines[cursor] ?? ""; return lines.slice(r.start, r.end + 1).join("\n"); }; return { render(width: number): string[] { const innerW = Math.max(1, width - 2); const out: string[] = []; out.push(borderTop(` Lines (last response, ${total} lines) `, innerW, theme)); const numW = String(total).length; const startIdx = Math.max( 0, Math.min(cursor - Math.floor(maxVisible / 2), total - maxVisible), ); const endIdx = Math.min(total, startIdx + maxVisible); const r = selectionRange(); for (let li = startIdx; li < endIdx; li++) { const num = String(li + 1).padStart(numW); const inSel = r !== null && li >= r.start && li <= r.end; const isCursor = li === cursor; const marker = isCursor ? theme.fg("accent", "→") : " "; const numStr = isCursor ? theme.fg("accent", theme.bold(num)) : inSel ? theme.fg("accent", num) : theme.fg("dim", num); const contentW = Math.max(1, width - 2 - numW - 4); const lineText = truncateToWidth(lines[li] || "", contentW, "…"); const body = inSel ? theme.bg("selectedBg", theme.fg("text", lineText)) : theme.fg("text", lineText); out.push( padLine(`${marker} ${numStr} ${theme.fg("border", "│")} ${body}`, width, theme), ); } if (total > maxVisible) { out.push(padLine(` ${theme.fg("dim", `${cursor + 1}/${total}`)}`, width, theme)); } out.push(borderMid(innerW, theme)); let status: string; if (selStart === null) { status = `${theme.fg("muted", "no selection")} ${theme.fg("dim", `· cursor line ${cursor + 1}`)}`; } else { const rr = selectionRange()!; const count = rr.end - rr.start + 1; status = `${theme.fg("accent", `selecting ${rr.start + 1}–${rr.end + 1}`)} ${theme.fg("dim", `· ${count} line${count === 1 ? "" : "s"} · move to extend · enter to copy · esc to clear`)}`; } out.push(padLine(` ${status}`, width, theme)); const help = [ `${theme.fg("accent", "↑↓")} move`, `${theme.fg("accent", "space")} set anchor`, `${theme.fg("accent", "enter")} copy`, selStart === null ? `${theme.fg("accent", "esc")} exit` : `${theme.fg("accent", "esc")} clear selection`, ].join(theme.fg("dim", " · ")); out.push(padLine(` ${theme.fg("dim", help)}`, width, theme)); out.push(borderBot(innerW, theme)); return out; }, invalidate() {}, handleInput(data: string) { if (matchesKey(data, Key.up)) { cursor = (cursor - 1 + total) % total; tui.requestRender(); return; } if (matchesKey(data, Key.down)) { cursor = (cursor + 1) % total; tui.requestRender(); return; } if (matchesKey(data, Key.home)) { cursor = 0; tui.requestRender(); return; } if (matchesKey(data, Key.end)) { cursor = total - 1; tui.requestRender(); return; } if (matchesKey(data, Key.space)) { if (selStart === null) { selStart = cursor; // begin selection on this line } else if (cursor === selStart) { selStart = null; // same line again — cancel } else { selStart = cursor; // move the anchor to the cursor } tui.requestRender(); return; } if (matchesKey(data, Key.enter)) { done(selectedLines() || null); return; } if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { // If a selection is active, the first ESC clears the selection // instead of closing the window; a second ESC (with no // selection) then exits. This avoids accidentally quitting // while still mid-selection. (Ctrl+C always exits immediately.) if (matchesKey(data, Key.escape) && selStart !== null) { selStart = null; tui.requestRender(); return; } done(null); return; } }, }; }, { overlay: true, overlayOptions: { anchor: "center", width: "80%", minWidth: 60, maxHeight: "90%" } }, ); if (result !== null) await doCopy(result, "line range", ctx); } async function clipLines(args: string, ctx: ExtensionContext): Promise { const text = getLastAssistantText(ctx.sessionManager.getBranch()); if (!text) { ctx.ui.notify("No assistant response to copy", "warning"); return; } const lines = text.split("\n"); const total = lines.length; const parsed = parseLineRangeArgs(args, total); if (parsed && "error" in parsed) { ctx.ui.notify(parsed.error, "warning"); return; } if (parsed) { const range = lines.slice(parsed.start, parsed.end + 1).join("\n"); await doCopy(range, `lines ${parsed.start + 1}–${parsed.end + 1}`, ctx); return; } if (ctx.mode !== "tui") { ctx.ui.notify("/clip lines selector requires interactive mode; use /clip lines N-M", "error"); return; } await openLineSelector(text, ctx); } // ── Main Extension ─────────────────────────────────────────────────────────── const DEFAULT_SHORTCUT = "ctrl+shift+c"; const SUBCOMMANDS: AutocompleteItem[] = [ { value: "code", label: "code", description: "Pick a code block" }, { value: "list", label: "list", description: "Pick a list" }, { value: "table", label: "table", description: "Pick a table (cell grid)" }, { value: "lines", label: "lines", description: "Select a line range (or N-M)" }, { value: "all", label: "all", description: "Copy full conversation" }, ]; export default function clipExtension(pi: ExtensionAPI): void { pi.registerCommand("clip", { description: "Copy assistant output to clipboard (code, tables, lists, quotes, line ranges)", getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { const filtered = SUBCOMMANDS.filter((s) => s.value.startsWith(prefix.toLowerCase())); return filtered.length > 0 ? filtered : null; }, handler: async (args, ctx) => { const trimmed = args.trim(); const lower = trimmed.toLowerCase(); // `/clip lines` may carry a range argument (e.g. "lines 3-5"); split the // subcommand keyword from its argument before dispatching. if (lower === "lines" || lower.startsWith("lines ")) { await clipLines(trimmed.slice(5).trim(), ctx); return; } switch (lower) { case "code": await clipCode(ctx); break; case "list": await clipList(ctx); break; case "table": await clipTable(ctx); break; case "all": await clipAll(ctx); break; case "": await clipPicker(ctx); break; default: ctx.ui.notify( `Unknown: "${trimmed}". Try /clip, /clip code, /clip list, /clip table, /clip lines, or /clip all`, "warning", ); } }, }); // Shortcut key: override with the PI_CLIP_SHORTCUT env var. pi's keybindings.json // remaps built-in action ids, not extension-registered shortcuts, so an env var is // the zero-dependency way to make this configurable. const shortcut = (process.env.PI_CLIP_SHORTCUT?.trim() || DEFAULT_SHORTCUT) as KeyId; pi.registerShortcut(shortcut, { description: "Copy assistant output to clipboard (pi-clip)", handler: async (ctx) => { if (ctx.mode !== "tui") return; await clipPicker(ctx); }, }); }