diff --git a/src/__tests__/result-selection.test.ts b/src/__tests__/result-selection.test.ts index c9da81a..7921757 100644 --- a/src/__tests__/result-selection.test.ts +++ b/src/__tests__/result-selection.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import { + codeBlockFlags, computeVisible, displayText, extractSelection, + inlineSpans, orderSelection, + parseInline, type Selection, selectionRangeForLine, } from "../tui/components/result-view"; @@ -21,6 +24,68 @@ describe("displayText", () => { test("renders bullets with the buoy symbol", () => { expect(displayText("- item")).toBe(" ◦ item"); }); + + test("strips inline emphasis markers", () => { + expect(displayText("say **hi** to *you* and `me`")).toBe( + "say hi to you and me", + ); + expect(displayText("- **done** now")).toBe(" ◦ done now"); + }); + + test("leaves code lines verbatim", () => { + expect(displayText("const a_b = *ptr;", true)).toBe("const a_b = *ptr;"); + expect(displayText("# not a heading in code", true)).toBe( + "# not a heading in code", + ); + }); +}); + +describe("parseInline", () => { + test("splits bold, italic and code spans", () => { + expect(parseInline("a **b** c")).toEqual([ + { text: "a " }, + { text: "b", bold: true }, + { text: " c" }, + ]); + expect(parseInline("a *b* c")).toEqual([ + { text: "a " }, + { text: "b", italic: true }, + { text: " c" }, + ]); + expect(parseInline("a `b` c")).toEqual([ + { text: "a " }, + { text: "b", code: true }, + { text: " c" }, + ]); + }); + + test("leaves snake_case and spaced asterisks alone", () => { + expect(parseInline("my_var_name")).toEqual([{ text: "my_var_name" }]); + expect(parseInline("2 * 3 * 4")).toEqual([{ text: "2 * 3 * 4" }]); + }); + + test("keeps unmatched markers literal", () => { + expect(parseInline("just ** stuff")).toEqual([{ text: "just ** stuff" }]); + }); + + test("emphasis spans concatenate back to the display text", () => { + const line = "mix **bold** and *italic* and `code`"; + const joined = parseInline(line) + .map((s) => s.text) + .join(""); + expect(joined).toBe(displayText(line)); + }); +}); + +describe("codeBlockFlags", () => { + test("marks fenced lines and their contents as code", () => { + const lines = ["intro", "```ts", "const a = 1;", "```", "outro"]; + expect(codeBlockFlags(lines)).toEqual([false, true, true, true, false]); + }); + + test("inlineSpans passes code lines through untouched", () => { + expect(inlineSpans("a_b *c*", true)).toEqual([{ text: "a_b *c*" }]); + }); }); describe("computeVisible", () => { diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 1363bbc..e3bb528 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -45,6 +45,7 @@ import { Header } from "./components/Header"; import { PromptInput } from "./components/PromptInput"; import { ResultPane } from "./components/ResultPane"; import { + codeBlockFlags, computeVisible, displayText, extractSelection, @@ -523,16 +524,21 @@ export function App() { ); if (visibleCount === 0) return null; + const codeFlags = codeBlockFlags(view.lines); const visIdx = row - 1 - top; // Clamp drags above/below the pane to the first/last visible line so a // selection can be extended past the edges. if (visIdx < 0) return { line: view.start, col: 0 }; if (visIdx >= visibleCount) { const line = view.start + visibleCount - 1; - return { line, col: displayText(view.lines[line] ?? "").length }; + return { + line, + col: displayText(view.lines[line] ?? "", codeFlags[line] ?? false) + .length, + }; } const line = view.start + visIdx; - const text = displayText(view.lines[line] ?? ""); + const text = displayText(view.lines[line] ?? "", codeFlags[line] ?? false); return { line, col: clamp(col - 1 - left, 0, text.length) }; }; diff --git a/src/tui/components/ResultPane.tsx b/src/tui/components/ResultPane.tsx index 0fca647..0f2ab19 100644 --- a/src/tui/components/ResultPane.tsx +++ b/src/tui/components/ResultPane.tsx @@ -1,10 +1,13 @@ import { Box, type DOMElement, Text } from "ink"; -import type { Ref } from "react"; +import type { ReactNode, Ref } from "react"; import { useSpinner } from "../hooks/use-spinner"; import { t } from "../theme"; import { + codeBlockFlags, computeVisible, displayText, + type InlineSpan, + inlineSpans, type Selection, selectionRangeForLine, } from "./result-view"; @@ -38,6 +41,7 @@ export function ResultPane({ scrollOffset, maxHeight, ); + const codeFlags = codeBlockFlags(lines); const overflow = lines.length > maxLines; const title = overflow @@ -63,6 +67,7 @@ export function ResultPane({ {content ? ( {lines.slice(0, maxLines)}; } -// Styling role for a raw response line, mirroring the lightweight markdown in -// displayText(). Kept alongside the text transform so the two never drift. -function lineStyle(line: string): { color: string; bold: boolean } { +// Block-level styling for a raw response line (heading, bullet, code). Inline +// emphasis is layered on top per-span by renderSpans; this is the base every +// span inherits. +function lineStyle( + line: string, + isCode: boolean, +): { color: string; bold: boolean } { + if (isCode) return { color: t().fg.dim, bold: false }; if (line.startsWith("# ")) return { color: t().fg.accent, bold: true }; if (line.startsWith("## ")) return { color: t().fg.accent, bold: false }; if (line.startsWith("### ")) return { color: t().fg.warning, bold: false }; if (line.startsWith("**") && line.endsWith("**")) return { color: t().fg.primary, bold: true }; - if (line.startsWith("```")) return { color: t().fg.dim, bold: false }; if (line.startsWith("- ")) return { color: t().fg.secondary, bold: false }; if (/^\d+\.\s/.test(line)) return { color: t().fg.secondary, bold: false }; return { color: t().fg.primary, bold: false }; @@ -129,11 +138,13 @@ function lineStyle(line: string): { color: string; bold: boolean } { function ResultContent({ lines, + codeFlags, startLine, selection, contentRef, }: { lines: string[]; + codeFlags: boolean[]; startLine: number; selection: Selection | null; contentRef: Ref; @@ -141,26 +152,24 @@ function ResultContent({ return ( {lines.map((line, idx) => { - const displayLine = displayText(line); - const { color, bold } = lineStyle(line); + const isCode = codeFlags[idx] ?? false; + const spans = inlineSpans(line, isCode); + const base = lineStyle(line, isCode); const globalLine = startLine + idx; + const displayLength = displayText(line, isCode).length; const range = selectionRangeForLine( selection, globalLine, - displayLine.length, + displayLength, ); return ( - {range - ? renderHighlighted(displayLine, range, color, bold) - : displayLine} + {renderSpans(spans, base, range)} ); })} @@ -168,43 +177,64 @@ function ResultContent({ ); } -// Split a line into before / selected / after spans, styling the selected span -// with an inverted swatch so the copied region is visible. -function renderHighlighted( - text: string, - [from, to]: [number, number], - color: string, - bold: boolean, +// Render a line's inline spans, layering each span's emphasis over the line's +// base style and painting the selected columns with an inverted swatch so the +// copied region is visible. Column ranges are in displayed-text space, matching +// the spans' concatenated text. +function renderSpans( + spans: InlineSpan[], + base: { color: string; bold: boolean }, + range: [number, number] | null, ) { + const total = spans.reduce((n, s) => n + s.text.length, 0); + // A fully-selected blank line has nothing to paint; show a single swatch so // the user can tell the empty line is part of the selection. - if (text.length === 0) { - return ( + if (total === 0) { + return range ? ( {" "} - ); + ) : null; } - const before = text.slice(0, from); - const selected = text.slice(from, to); - const after = text.slice(to); - return ( - <> - {before && ( - - {before} - - )} - {selected && ( - - {selected} - - )} - {after && ( - - {after} - - )} - - ); + + const out: ReactNode[] = []; + let col = 0; + let key = 0; + + for (const span of spans) { + const color = span.code ? t().fg.warning : base.color; + const bold = base.bold || !!span.bold; + const italic = !!span.italic; + + const emit = (text: string, selected: boolean) => { + if (!text) return; + out.push( + + {text} + , + ); + }; + + if (!range) { + emit(span.text, false); + } else { + const [from, to] = range; + const len = span.text.length; + const lo = Math.max(0, Math.min(len, from - col)); + const hi = Math.max(0, Math.min(len, to - col)); + emit(span.text.slice(0, lo), false); + emit(span.text.slice(lo, hi), true); + emit(span.text.slice(hi), false); + } + col += span.text.length; + } + + return out; } diff --git a/src/tui/components/result-view.ts b/src/tui/components/result-view.ts index 99fa199..098b4ae 100644 --- a/src/tui/components/result-view.ts +++ b/src/tui/components/result-view.ts @@ -40,11 +40,20 @@ export function computeVisible( return { lines, start, end, maxLines }; } -// The text a raw response line renders as, after the lightweight markdown -// styling ResultPane applies. Selection columns and clipboard output are -// expressed against this displayed text so "what you highlight is what you -// copy". -export function displayText(line: string): string { +// A styled slice of a line. Concatenating every span's `text` reproduces the +// line's displayed text exactly, so selection columns stay marker-free and in +// lock-step with what is rendered. +export interface InlineSpan { + text: string; + bold?: boolean; + italic?: boolean; + code?: boolean; +} + +// Strip the block-level markdown marker a line opens with (heading, whole-line +// bold, bullet). The inline emphasis inside the remainder is handled by +// parseInline. +function stripBlock(line: string): string { if (line.startsWith("# ")) return line.slice(2); if (line.startsWith("## ")) return line.slice(3); if (line.startsWith("### ")) return line.slice(4); @@ -53,6 +62,107 @@ export function displayText(line: string): string { return line; } +// A char that can sit next to an `_` emphasis marker without it being part of a +// word (so snake_case identifiers are left alone). +const isBoundary = (ch: string | undefined): boolean => + ch === undefined || /[\s([{<'"]/.test(ch); + +// Parse inline markdown emphasis (`**bold**`, `*italic*`, `_italic_`, +// `` `code` ``) in a block-stripped line into styled spans. Unmatched markers +// are kept as literal text. Concatenating the spans' text yields the plain +// display string. +export function parseInline(text: string): InlineSpan[] { + const spans: InlineSpan[] = []; + let plain = ""; + const push = (span: InlineSpan) => { + if (plain) { + spans.push({ text: plain }); + plain = ""; + } + if (span.text) spans.push(span); + }; + + let i = 0; + while (i < text.length) { + const ch = text[i]; + + // Inline code: `...` — highest precedence, no nested parsing. + if (ch === "`") { + const end = text.indexOf("`", i + 1); + if (end > i + 1) { + push({ text: text.slice(i + 1, end), code: true }); + i = end + 1; + continue; + } + } + + // Bold: **...** + if (ch === "*" && text[i + 1] === "*") { + const end = text.indexOf("**", i + 2); + if (end > i + 2) { + push({ text: text.slice(i + 2, end), bold: true }); + i = end + 2; + continue; + } + } + + // Italic: *...* (no space just inside the markers). + if (ch === "*" && text[i + 1] !== "*" && text[i + 1] !== " ") { + const end = text.indexOf("*", i + 1); + if (end > i + 1 && text[end - 1] !== " ") { + push({ text: text.slice(i + 1, end), italic: true }); + i = end + 1; + continue; + } + } + + // Italic: _..._ only at word boundaries. + if (ch === "_" && isBoundary(text[i - 1]) && text[i + 1] !== " ") { + const end = text.indexOf("_", i + 1); + if (end > i + 1 && text[end - 1] !== " " && isBoundary(text[end + 1])) { + push({ text: text.slice(i + 1, end), italic: true }); + i = end + 1; + continue; + } + } + + plain += ch; + i++; + } + if (plain) spans.push({ text: plain }); + return spans; +} + +// The styled spans a raw response line renders as. Code lines (inside a ``` +// fence) are passed through verbatim so their contents are never mistaken for +// markdown. +export function inlineSpans(line: string, isCode = false): InlineSpan[] { + if (isCode) return [{ text: line }]; + return parseInline(stripBlock(line)); +} + +// Which lines fall inside a fenced ``` code block. The fence lines themselves +// count as code so their backticks aren't treated as inline markers. +export function codeBlockFlags(lines: string[]): boolean[] { + const flags: boolean[] = []; + let inFence = false; + for (const line of lines) { + const isFence = line.trimStart().startsWith("```"); + flags.push(inFence || isFence); + if (isFence) inFence = !inFence; + } + return flags; +} + +// The text a raw response line renders as, after the markdown styling ResultPane +// applies. Selection columns and clipboard output are expressed against this +// displayed text so "what you highlight is what you copy". +export function displayText(line: string, isCode = false): string { + return inlineSpans(line, isCode) + .map((s) => s.text) + .join(""); +} + // Order two positions so the returned pair reads top-to-bottom, left-to-right. export function orderSelection( a: SelectionPos, @@ -84,7 +194,8 @@ export function extractSelection( selection: Selection, ): string { const lines = content.split("\n"); - const dt = (i: number) => displayText(lines[i] ?? ""); + const codeFlags = codeBlockFlags(lines); + const dt = (i: number) => displayText(lines[i] ?? "", codeFlags[i] ?? false); const [from, to] = orderSelection(selection.anchor, selection.head); if (from.line === to.line) { return dt(from.line).slice(from.col, to.col);