From 5ec497cbfdc0a273b0f7652db0ec8ea20e646429 Mon Sep 17 00:00:00 2001 From: Sylvain Gougouzian Date: Mon, 13 Jul 2026 22:49:54 +0200 Subject: [PATCH] feat(tui): copy response text selection to clipboard; keep openspec/obsidian internal Add mouse-driven text selection in the response pane. Enabling SGR mouse tracking (1002+1006) lets a drag select text, which is copied to the clipboard on release; the wheel scrolls the pane. Interception happens in the existing useInput handler since Ink surfaces the whole SGR report as a single event. Also stop advertising paikea's own plumbing to the model: the obsidian-cli skill (marked internal) and the openspec workflow skills are no longer listed in the system prompt, so a user's project work isn't warned about "conflicts" with them. The OpenSpec step guidance still drives the workflow under the hood. Fixes folded-YAML (`description: >`) skill descriptions. Release 0.0.1. --- package.json | 2 +- src/__tests__/dom-position.test.tsx | 54 +++++++++ src/__tests__/registries.test.ts | 63 ++++++---- src/__tests__/result-selection.test.ts | 157 +++++++++++++++++++++++++ src/services/clipboard.ts | 60 ++++++++++ src/skills/obsidian-cli/SKILL.md | 1 + src/skills/registry.ts | 64 ++++++---- src/tui/App.tsx | 148 +++++++++++++++++++++-- src/tui/components/ResultPane.tsx | 134 +++++++++++++++------ src/tui/components/StatusBar.tsx | 15 +++ src/tui/components/result-view.ts | 96 +++++++++++++++ src/tui/dom-position.ts | 22 ++++ src/tui/hooks/use-mouse-tracking.ts | 30 +++++ src/tui/mouse.ts | 29 +++++ 14 files changed, 776 insertions(+), 99 deletions(-) create mode 100644 src/__tests__/dom-position.test.tsx create mode 100644 src/__tests__/result-selection.test.ts create mode 100644 src/services/clipboard.ts create mode 100644 src/tui/components/result-view.ts create mode 100644 src/tui/dom-position.ts create mode 100644 src/tui/hooks/use-mouse-tracking.ts create mode 100644 src/tui/mouse.ts diff --git a/package.json b/package.json index 5efa181..27421c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paikea", - "version": "0.1.0", + "version": "0.0.1", "module": "src/cli/index.ts", "type": "module", "bin": { diff --git a/src/__tests__/dom-position.test.tsx b/src/__tests__/dom-position.test.tsx new file mode 100644 index 0000000..3f4b4d1 --- /dev/null +++ b/src/__tests__/dom-position.test.tsx @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { Box, type DOMElement, render, Text } from "ink"; +import { useEffect, useRef } from "react"; +import { absolutePosition } from "../tui/dom-position"; + +// Minimal fake TTY stdout so Ink computes a real layout without a terminal. +function fakeStdout(columns = 80, rows = 24) { + const stream = new EventEmitter() as unknown as NodeJS.WriteStream; + Object.assign(stream, { + columns, + rows, + isTTY: true, + write: () => true, + }); + return stream; +} + +test("absolutePosition resolves a nested content box to absolute cells", async () => { + const captured: Array<{ top: number; left: number }> = []; + + function Harness() { + const ref = useRef(null); + useEffect(() => { + if (ref.current) captured.push(absolutePosition(ref.current)); + }); + return ( + // Root column: a 1-row header, then a bordered pane whose content box + // sits below the border (1) and a title row (1). + + header + + ── title ── + + first response line + second response line + + + + ); + } + + const app = render(, { + stdout: fakeStdout(), + patchConsole: false, + }); + // Let the effect run after layout is committed. + await new Promise((resolve) => setTimeout(resolve, 20)); + app.unmount(); + + // header row 0; border row 1; title row 2; content starts at row 3. + // Left border occupies column 0, so content starts at column 1. + expect(captured.at(-1)).toEqual({ top: 3, left: 1 }); +}); diff --git a/src/__tests__/registries.test.ts b/src/__tests__/registries.test.ts index 740ae1c..dde242d 100644 --- a/src/__tests__/registries.test.ts +++ b/src/__tests__/registries.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; import { buildRulesPrompt, loadRules } from "../rules/registry"; import { + advertisedSkills, buildSkillsPrompt, - filterSkillsForStep, getSkillContent, loadSkills, } from "../skills/registry"; @@ -21,6 +21,7 @@ describe("Skills Registry", () => { description: "A test skill", triggers: ["test"], content: "Test content", + internal: false, }, ]; const prompt = buildSkillsPrompt(skills); @@ -44,6 +45,7 @@ describe("Skills Registry", () => { description: "A test skill", triggers: ["test"], content: "Test content", + internal: false, }, ]; const content = getSkillContent(skills, "test-skill"); @@ -51,49 +53,62 @@ describe("Skills Registry", () => { expect(content).toContain("Test content"); expect(getSkillContent(skills, "nope")).toBeNull(); }); + + it("does not advertise the internal obsidian-cli skill", () => { + const skills = loadSkills("/nonexistent/project"); + const obsidian = skills.find((s) => s.name === "obsidian-cli"); + // It is still loaded (so internal plumbing can reach it) … + expect(obsidian).toBeDefined(); + expect(obsidian?.internal).toBe(true); + // … but never surfaced to the model. + const names = advertisedSkills(skills).map((s) => s.name); + expect(names).not.toContain("obsidian-cli"); + const prompt = buildSkillsPrompt(advertisedSkills(skills)); + expect(prompt.toLowerCase()).not.toContain("obsidian"); + }); + + it("parses folded/literal block descriptions instead of the bare marker", () => { + const skills = loadSkills("/nonexistent/project"); + const obsidian = skills.find((s) => s.name === "obsidian-cli"); + // obsidian-cli uses `description: >` folded scalar frontmatter. + expect(obsidian?.description).not.toBe(">"); + expect(obsidian?.description?.length ?? 0).toBeGreaterThan(20); + }); }); -describe("filterSkillsForStep", () => { - const skill = (name: string) => ({ +describe("advertisedSkills", () => { + const skill = (name: string, internal = false) => ({ name, description: "", triggers: [], content: "", + internal, }); const skills = [ skill("code-review"), - skill("obsidian-cli"), + skill("obsidian-cli", true), // internal plumbing skill("openspec-propose"), skill("openspec-apply-change"), skill("openspec-archive-change"), ]; - const names = (stepId: string) => - filterSkillsForStep(skills, stepId).map((s) => s.name); + const names = advertisedSkills(skills).map((s) => s.name); - it("always keeps non-openspec skills", () => { - for (const step of ["discuss", "proposal", "apply", "archive", "unknown"]) { - expect(names(step)).toContain("code-review"); - expect(names(step)).toContain("obsidian-cli"); - } + it("keeps user-facing skills", () => { + expect(names).toContain("code-review"); }); - it("excludes all openspec skills during discuss", () => { - expect(names("discuss")).toEqual(["code-review", "obsidian-cli"]); + it("never advertises internal-flagged skills", () => { + expect(names).not.toContain("obsidian-cli"); }); - it("keeps only the matching openspec skill per step", () => { - expect(names("proposal")).toContain("openspec-propose"); - expect(names("proposal")).not.toContain("openspec-apply-change"); - expect(names("apply")).toContain("openspec-apply-change"); - expect(names("apply")).not.toContain("openspec-propose"); - expect(names("archive")).toContain("openspec-archive-change"); - expect(names("archive")).not.toContain("openspec-apply-change"); + it("never advertises openspec workflow skills (internal plumbing)", () => { + expect(names).not.toContain("openspec-propose"); + expect(names).not.toContain("openspec-apply-change"); + expect(names).not.toContain("openspec-archive-change"); }); - it("keeps the propose skill through design, specs and tasks steps", () => { - for (const step of ["design", "specs", "tasks"]) { - expect(names(step)).toContain("openspec-propose"); - } + it("advertises only the user-facing skills", () => { + expect(names).toEqual(["code-review"]); }); }); diff --git a/src/__tests__/result-selection.test.ts b/src/__tests__/result-selection.test.ts new file mode 100644 index 0000000..c9da81a --- /dev/null +++ b/src/__tests__/result-selection.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; +import { + computeVisible, + displayText, + extractSelection, + orderSelection, + type Selection, + selectionRangeForLine, +} from "../tui/components/result-view"; +import { parseMouseEvent } from "../tui/mouse"; + +describe("displayText", () => { + test("strips markdown markers", () => { + expect(displayText("# Title")).toBe("Title"); + expect(displayText("## Sub")).toBe("Sub"); + expect(displayText("### Sub")).toBe("Sub"); + expect(displayText("**bold**")).toBe("bold"); + expect(displayText("plain text")).toBe("plain text"); + }); + + test("renders bullets with the buoy symbol", () => { + expect(displayText("- item")).toBe(" ◦ item"); + }); +}); + +describe("computeVisible", () => { + test("follows the tail when scrollOffset is 0", () => { + const content = Array.from({ length: 20 }, (_, i) => `line ${i}`).join( + "\n", + ); + const view = computeVisible(content, 0, 7); // maxLines = 5 + expect(view.maxLines).toBe(5); + expect(view.start).toBe(15); + expect(view.end).toBe(20); + }); + + test("scrolls up from the tail", () => { + const content = Array.from({ length: 20 }, (_, i) => `line ${i}`).join( + "\n", + ); + const view = computeVisible(content, 3, 7); + expect(view.start).toBe(12); + }); +}); + +describe("selectionRangeForLine", () => { + const sel: Selection = { + anchor: { line: 2, col: 3 }, + head: { line: 4, col: 5 }, + }; + + test("null outside the selection", () => { + expect(selectionRangeForLine(sel, 1, 10)).toBeNull(); + expect(selectionRangeForLine(sel, 5, 10)).toBeNull(); + expect(selectionRangeForLine(null, 3, 10)).toBeNull(); + }); + + test("first line runs from anchor col to end", () => { + expect(selectionRangeForLine(sel, 2, 10)).toEqual([3, 10]); + }); + + test("middle line covers the whole line", () => { + expect(selectionRangeForLine(sel, 3, 10)).toEqual([0, 10]); + }); + + test("last line runs from start to head col", () => { + expect(selectionRangeForLine(sel, 4, 10)).toEqual([0, 5]); + }); +}); + +describe("orderSelection", () => { + test("normalizes a backwards selection", () => { + const [from, to] = orderSelection({ line: 4, col: 1 }, { line: 2, col: 9 }); + expect(from).toEqual({ line: 2, col: 9 }); + expect(to).toEqual({ line: 4, col: 1 }); + }); +}); + +describe("extractSelection", () => { + const content = "first line\nsecond line\nthird line"; + + test("single line, partial", () => { + const sel: Selection = { + anchor: { line: 0, col: 0 }, + head: { line: 0, col: 5 }, + }; + expect(extractSelection(content, sel)).toBe("first"); + }); + + test("multi-line spans first tail, middles, last head", () => { + const sel: Selection = { + anchor: { line: 0, col: 6 }, + head: { line: 2, col: 5 }, + }; + expect(extractSelection(content, sel)).toBe("line\nsecond line\nthird"); + }); + + test("works with a reversed drag", () => { + const sel: Selection = { + anchor: { line: 0, col: 5 }, + head: { line: 0, col: 0 }, + }; + expect(extractSelection(content, sel)).toBe("first"); + }); + + test("empty on a collapsed selection (plain click)", () => { + const sel: Selection = { + anchor: { line: 1, col: 3 }, + head: { line: 1, col: 3 }, + }; + expect(extractSelection(content, sel)).toBe(""); + }); + + test("copies displayed text, not raw markdown", () => { + const md = "# Heading\nbody"; + const sel: Selection = { + anchor: { line: 0, col: 0 }, + head: { line: 0, col: 7 }, + }; + expect(extractSelection(md, sel)).toBe("Heading"); + }); +}); + +describe("parseMouseEvent", () => { + test("left button press", () => { + expect(parseMouseEvent("[<0;10;5M")).toEqual({ + col: 10, + row: 5, + release: false, + motion: false, + wheel: false, + wheelDelta: 1, + }); + }); + + test("drag (motion with button held)", () => { + const e = parseMouseEvent("[<32;12;6M"); + expect(e?.motion).toBe(true); + expect(e?.release).toBe(false); + }); + + test("release", () => { + const e = parseMouseEvent("[<0;12;6m"); + expect(e?.release).toBe(true); + }); + + test("wheel up and down", () => { + expect(parseMouseEvent("[<64;1;1M")?.wheelDelta).toBe(1); + expect(parseMouseEvent("[<65;1;1M")?.wheelDelta).toBe(-1); + expect(parseMouseEvent("[<64;1;1M")?.wheel).toBe(true); + }); + + test("rejects non-mouse input", () => { + expect(parseMouseEvent("hello")).toBeNull(); + expect(parseMouseEvent("[<0;10M")).toBeNull(); + }); +}); diff --git a/src/services/clipboard.ts b/src/services/clipboard.ts new file mode 100644 index 0000000..9e6672d --- /dev/null +++ b/src/services/clipboard.ts @@ -0,0 +1,60 @@ +import { spawn } from "node:child_process"; + +// Ordered list of copy commands to try per platform. The first one that +// launches without an immediate error wins; the rest are fallbacks for the +// grab-bag of Linux clipboard tools a user might have installed. +function clipboardCommands(): Array<{ cmd: string; args: string[] }> { + switch (process.platform) { + case "darwin": + return [{ cmd: "pbcopy", args: [] }]; + case "win32": + return [{ cmd: "clip", args: [] }]; + default: + return [ + { cmd: "wl-copy", args: [] }, + { cmd: "xclip", args: ["-selection", "clipboard"] }, + { cmd: "xsel", args: ["--clipboard", "--input"] }, + ]; + } +} + +// Pipe `text` into the first available clipboard command. Best-effort: resolves +// true once a command accepts the write, false if every candidate is missing. +export function copyToClipboard(text: string): Promise { + const candidates = clipboardCommands(); + + return new Promise((resolve) => { + const tryNext = (index: number) => { + const candidate = candidates[index]; + if (!candidate) { + resolve(false); + return; + } + + let settled = false; + const child = spawn(candidate.cmd, candidate.args, { + stdio: ["pipe", "ignore", "ignore"], + }); + + // ENOENT (tool not installed) — move on to the next candidate. + child.on("error", () => { + if (settled) return; + settled = true; + tryNext(index + 1); + }); + + child.on("close", (code) => { + if (settled) return; + settled = true; + resolve(code === 0); + }); + + child.stdin.on("error", () => { + // The 'error'/'close' handlers above own resolution. + }); + child.stdin.end(text); + }; + + tryNext(0); + }); +} diff --git a/src/skills/obsidian-cli/SKILL.md b/src/skills/obsidian-cli/SKILL.md index 9cede33..3f7e973 100644 --- a/src/skills/obsidian-cli/SKILL.md +++ b/src/skills/obsidian-cli/SKILL.md @@ -1,6 +1,7 @@ --- name: obsidian-cli version: "1.3.0" +internal: true description: > Use this skill whenever the user wants Claude to directly interact with their Obsidian vault — reading a note or daily note, writing or appending content, diff --git a/src/skills/registry.ts b/src/skills/registry.ts index 85e4506..600901c 100644 --- a/src/skills/registry.ts +++ b/src/skills/registry.ts @@ -6,6 +6,10 @@ export interface Skill { description: string; triggers: string[]; content: string; + // Internal skills back paikea's own plumbing (e.g. the Obsidian vault used to + // store spec/decision docs). They are loaded but never advertised to the + // model, so the user's own project work isn't nudged toward them. + internal: boolean; } export function loadSkills(projectDir: string): Skill[] { @@ -78,8 +82,9 @@ function parseSkillFile(content: string): Skill | null { const frontmatter = frontmatterMatch[1] ?? ""; const body = frontmatterMatch[2] ?? ""; const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim(); - const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim(); + const description = parseDescription(frontmatter); const triggersRaw = frontmatter.match(/^triggers:\n((?:\s*-\s*.+\n?)+)/m); + const internal = /^internal:\s*true\b/m.test(frontmatter); if (!name || !description) return null; @@ -92,31 +97,42 @@ function parseSkillFile(content: string): Skill | null { } } - return { name, description, triggers, content: body.trim() }; + return { name, description, triggers, content: body.trim(), internal }; } -// Skills whose name contains "openspec" are step-specific: they are only -// injected when the active workflow step matches one of these keywords. -// All other skills are always available. -// The proposal → design → specs → tasks artifacts are all authored by the -// "propose" family of skills, so they stay available across those steps. -const OPENSPEC_STEP_KEYWORDS: Record = { - discuss: [], - proposal: ["propose", "proposal"], - design: ["propose", "proposal", "design"], - specs: ["propose", "proposal", "spec"], - tasks: ["propose", "proposal", "task"], - apply: ["apply"], - archive: ["archive"], -}; - -export function filterSkillsForStep(skills: Skill[], stepId: string): Skill[] { - const keywords = OPENSPEC_STEP_KEYWORDS[stepId] ?? []; - return skills.filter((skill) => { - const name = skill.name.toLowerCase(); - if (!name.includes("openspec")) return true; - return keywords.some((k) => name.includes(k)); - }); +// Read a frontmatter `description`, supporting both an inline value and YAML +// folded/literal block scalars (`description: >` followed by indented lines) — +// otherwise a folded description parses as the bare `>` marker. +function parseDescription(frontmatter: string): string | undefined { + const inline = frontmatter.match(/^description:[ \t]*([^\s>|][^\n]*)$/m); + if (inline?.[1]?.trim()) return inline[1].trim(); + + const block = frontmatter.match( + /^description:[ \t]*[>|][^\n]*\n((?:[ \t]+.*\n?)+)/m, + ); + if (block?.[1]) { + return block[1] + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .join(" "); + } + return undefined; +} + +// The skills advertised to the model. Only user-facing skills reach it: +// paikea's own plumbing stays under the hood so the user's project work isn't +// nudged toward it or warned about "conflicts" with it. That plumbing is +// - `internal`-flagged skills (e.g. the Obsidian vault backing spec/decision +// docs), and +// - the OpenSpec workflow skills (name contains "openspec"), which paikea +// drives itself via the per-step Workflow guidance, not by the model +// picking them as a capability. +export function advertisedSkills(skills: Skill[]): Skill[] { + return skills.filter( + (skill) => + !skill.internal && !skill.name.toLowerCase().includes("openspec"), + ); } // A manifest only: names + one-line descriptions. Injecting every SKILL.md diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 8ba4e85..5e58200 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -1,6 +1,7 @@ -import { Box, useApp, useInput } from "ink"; +import { Box, type DOMElement, useApp, useInput } from "ink"; import { useCallback, useEffect, useRef, useState } from "react"; import { buildRulesPrompt, loadRules } from "../rules/registry"; +import { copyToClipboard } from "../services/clipboard"; import { streamChat } from "../services/dmr-client"; import { fetchModels, nextModel, prevModel } from "../services/model-registry"; import { @@ -14,8 +15,8 @@ import { } from "../services/suggestions"; import { hasThinkingSupport } from "../services/thinking-parser"; import { + advertisedSkills, buildSkillsPrompt, - filterSkillsForStep, loadSkills, type Skill, } from "../skills/registry"; @@ -42,10 +43,20 @@ import { CommandPalette } from "./components/CommandPalette"; import { Header } from "./components/Header"; import { PromptInput } from "./components/PromptInput"; import { ResultPane } from "./components/ResultPane"; +import { + computeVisible, + displayText, + extractSelection, + type Selection, + type SelectionPos, +} from "./components/result-view"; import { StatusBar } from "./components/StatusBar"; import { ThinkingPane } from "./components/ThinkingPane"; import { Timeline } from "./components/Timeline"; +import { absolutePosition } from "./dom-position"; +import { useMouseTracking } from "./hooks/use-mouse-tracking"; import { useLayout } from "./hooks/use-terminal"; +import { parseMouseEvent } from "./mouse"; import { getThemeNames, setThemeByName } from "./theme"; const MAX_TOOL_ITERATIONS = 10; @@ -106,6 +117,8 @@ interface AppState { confirmQuit: boolean; selectedStepIndex: number; themeTick: number; + selection: Selection | null; + notice: string; } export function App() { @@ -132,11 +145,16 @@ export function App() { confirmQuit: false, selectedStepIndex: 0, themeTick: 0, + selection: null, + notice: "", }); const [prompt, setPrompt] = useState(""); const [cursorPos, setCursorPos] = useState(0); const streamAbortRef = useRef(null); + const contentBoxRef = useRef(null); + const draggingRef = useRef(false); + const noticeTimerRef = useRef | null>(null); const cwd = useRef(process.cwd()); const sessionId = useRef(createSessionId()); const skillsRef = useRef([]); @@ -191,6 +209,27 @@ export function App() { setState((s) => ({ ...s, ...partial })); }, []); + // Take over mouse events so a drag in the response pane can copy to the + // clipboard. This disables the terminal's own text selection. + useMouseTracking(); + + // Show a transient status message (e.g. "copied") that clears itself. + const showNotice = useCallback( + (message: string) => { + if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current); + update({ notice: message }); + noticeTimerRef.current = setTimeout(() => update({ notice: "" }), 2500); + }, + [update], + ); + + useEffect( + () => () => { + if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current); + }, + [], + ); + const handleSubmit = useCallback(async () => { if (!prompt.trim() || !state.model || state.streaming) return; @@ -230,6 +269,7 @@ export function App() { scrollOffset: 0, thinkingScrollOffset: 0, scrollFocus: "response", + selection: null, messages: [...state.messages, userMessage], }); @@ -240,10 +280,10 @@ export function App() { const disableThinking = modelSupportsThinking && !state.thinkingEnabled; // Build the system prompt for the active step: only matching openspec - // skills are injected, plus step-specific workflow guidance + // user-facing skills are advertised, plus step-specific workflow guidance const stepId = stepIdAt(state.steps, state.selectedStepIndex); const systemPrompt = - buildSkillsPrompt(filterSkillsForStep(skillsRef.current, stepId)) + + buildSkillsPrompt(advertisedSkills(skillsRef.current)) + rulesPromptRef.current + buildStepPrompt(stepId); @@ -444,6 +484,7 @@ export function App() { scrollOffset: 0, thinkingScrollOffset: 0, scrollFocus: "response", + selection: null, }), }, { @@ -463,6 +504,42 @@ export function App() { [update], ); + const thinkingActive = state.streaming && state.thinking.active; + const showThinking = isThinkingShown(state); + const showAgent = state.agentSteps.length > 0; + const scrollFocus = showThinking ? state.scrollFocus : "response"; + const layout = useLayout({ showThinking, showAgent }); + + // Translate a 1-based terminal cell (col, row) into a position inside the + // response content, or null when the cell isn't over rendered text. + const mapMouse = (col: number, row: number): SelectionPos | null => { + const node = contentBoxRef.current; + if (!node || !state.result) return null; + const { top, left } = absolutePosition(node); + const view = computeVisible( + state.result, + state.scrollOffset, + layout.resultHeight, + ); + const visibleCount = Math.max( + 0, + Math.min(view.maxLines, view.lines.length - view.start), + ); + if (visibleCount === 0) return null; + + 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 }; + } + const line = view.start + visIdx; + const text = displayText(view.lines[line] ?? ""); + return { line, col: clamp(col - 1 - left, 0, text.length) }; + }; + // Keyboard handler useInput((input, key) => { const s = state; @@ -474,6 +551,55 @@ export function App() { return; } + // Mouse events (SGR 1006): Ink strips the leading ESC, so a report arrives + // as "[ 0; - const scrollFocus = showThinking ? state.scrollFocus : "response"; - const layout = useLayout({ showThinking, showAgent }); - return (
@@ -696,6 +819,8 @@ export function App() { streaming={state.streaming} hasHistory={state.messages.length > 0} focused={scrollFocus === "response"} + selection={state.selection} + contentRef={contentBoxRef} /> {state.uiMode === "palette" && ( diff --git a/src/tui/components/ResultPane.tsx b/src/tui/components/ResultPane.tsx index 48a853a..0fca647 100644 --- a/src/tui/components/ResultPane.tsx +++ b/src/tui/components/ResultPane.tsx @@ -1,6 +1,13 @@ -import { Box, Text } from "ink"; +import { Box, type DOMElement, Text } from "ink"; +import type { Ref } from "react"; import { useSpinner } from "../hooks/use-spinner"; -import { symbols, t } from "../theme"; +import { t } from "../theme"; +import { + computeVisible, + displayText, + type Selection, + selectionRangeForLine, +} from "./result-view"; interface ResultPaneProps { content: string; @@ -9,6 +16,8 @@ interface ResultPaneProps { streaming: boolean; hasHistory: boolean; focused: boolean; + selection: Selection | null; + contentRef: Ref; } export function ResultPane({ @@ -18,19 +27,17 @@ export function ResultPane({ streaming, hasHistory, focused, + selection, + contentRef, }: ResultPaneProps) { const height = Math.max(3, maxHeight); const spinner = useSpinner(streaming && !content); - const maxLines = height - 2; - const lines = content ? content.split("\n") : []; - // scrollOffset counts from the bottom: 0 = follow the tail - const clampedOffset = Math.min( + const { lines, start, end, maxLines } = computeVisible( + content, scrollOffset, - Math.max(0, lines.length - maxLines), + maxHeight, ); - const start = Math.max(0, lines.length - maxLines - clampedOffset); - const end = start + maxLines; const overflow = lines.length > maxLines; const title = overflow @@ -49,12 +56,17 @@ export function ResultPane({ {title} - {overflow && clampedOffset > 0 && ( + {overflow && scrollOffset > 0 && ( ▼ shift+↓ latest )} {content ? ( - + ) : streaming ? ( {spinner} awaiting response... ) : ( @@ -95,47 +107,48 @@ function Welcome({ {" "}↑/↓{" "}scroll · ctrl+t switches thinking / response , - {" "}/step{" "}switch workflow step (/discuss, /proposal, /apply…) + {" "}drag{" "}select text in response → copied to clipboard , ]; return {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 } { + 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 }; +} + function ResultContent({ lines, startLine, + selection, + contentRef, }: { lines: string[]; startLine: number; + selection: Selection | null; + contentRef: Ref; }) { return ( - + {lines.map((line, idx) => { - let displayLine = line; - let color = t().fg.primary; - let bold = false; - - if (line.startsWith("# ")) { - displayLine = line.slice(2); - color = t().fg.accent; - bold = true; - } else if (line.startsWith("## ")) { - displayLine = line.slice(3); - color = t().fg.accent; - } else if (line.startsWith("### ")) { - displayLine = line.slice(4); - color = t().fg.warning; - } else if (line.startsWith("**") && line.endsWith("**")) { - displayLine = line.slice(2, -2); - bold = true; - } else if (line.startsWith("```")) { - color = t().fg.dim; - } else if (line.startsWith("- ")) { - displayLine = ` ${symbols.dot} ${line.slice(2)}`; - color = t().fg.secondary; - } else if (/^\d+\.\s/.test(line)) { - color = t().fg.secondary; - } + const displayLine = displayText(line); + const { color, bold } = lineStyle(line); + const globalLine = startLine + idx; + const range = selectionRangeForLine( + selection, + globalLine, + displayLine.length, + ); return ( - {displayLine} + {range + ? renderHighlighted(displayLine, range, color, bold) + : displayLine} ); })} ); } + +// 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, +) { + // 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 ( + + {" "} + + ); + } + const before = text.slice(0, from); + const selected = text.slice(from, to); + const after = text.slice(to); + return ( + <> + {before && ( + + {before} + + )} + {selected && ( + + {selected} + + )} + {after && ( + + {after} + + )} + + ); +} diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx index 803613b..a925a7a 100644 --- a/src/tui/components/StatusBar.tsx +++ b/src/tui/components/StatusBar.tsx @@ -10,6 +10,7 @@ interface StatusBarProps { streaming: boolean; confirmQuit: boolean; canFocusPanes: boolean; + notice: string; } // The whole bar is a single Text with truncate-end so it never wraps to a @@ -21,10 +22,24 @@ export function StatusBar({ streaming, confirmQuit, canFocusPanes, + notice, }: StatusBarProps) { const spinner = useSpinner(streaming); const elapsed = useElapsed(streaming); + if (notice && !streaming && !confirmQuit) { + return ( + + + + {" "} + ✓ {notice} + + + + ); + } + if (confirmQuit) { return ( diff --git a/src/tui/components/result-view.ts b/src/tui/components/result-view.ts new file mode 100644 index 0000000..99fa199 --- /dev/null +++ b/src/tui/components/result-view.ts @@ -0,0 +1,96 @@ +import { symbols } from "../theme"; + +// A selection anchor: a global line index into the full response plus a column +// into that line's displayed text. +export interface SelectionPos { + line: number; + col: number; +} + +export interface Selection { + anchor: SelectionPos; + head: SelectionPos; +} + +// The visible slice of the response, computed identically to how ResultPane +// renders it. Shared so mouse-coordinate mapping stays in lock-step with what +// is actually on screen. +export interface ResultView { + lines: string[]; + start: number; + end: number; + maxLines: number; +} + +export function computeVisible( + content: string, + scrollOffset: number, + maxHeight: number, +): ResultView { + const height = Math.max(3, maxHeight); + const maxLines = height - 2; + const lines = content ? content.split("\n") : []; + // scrollOffset counts from the bottom: 0 = follow the tail + const clampedOffset = Math.min( + scrollOffset, + Math.max(0, lines.length - maxLines), + ); + const start = Math.max(0, lines.length - maxLines - clampedOffset); + const end = start + maxLines; + 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 { + if (line.startsWith("# ")) return line.slice(2); + if (line.startsWith("## ")) return line.slice(3); + if (line.startsWith("### ")) return line.slice(4); + if (line.startsWith("**") && line.endsWith("**")) return line.slice(2, -2); + if (line.startsWith("- ")) return ` ${symbols.dot} ${line.slice(2)}`; + return line; +} + +// Order two positions so the returned pair reads top-to-bottom, left-to-right. +export function orderSelection( + a: SelectionPos, + b: SelectionPos, +): [SelectionPos, SelectionPos] { + if (a.line < b.line || (a.line === b.line && a.col <= b.col)) return [a, b]; + return [b, a]; +} + +// The highlighted column span [start, end) for a given global line, or null +// when the line falls outside the selection. +export function selectionRangeForLine( + selection: Selection | null, + line: number, + length: number, +): [number, number] | null { + if (!selection) return null; + const [from, to] = orderSelection(selection.anchor, selection.head); + if (line < from.line || line > to.line) return null; + const start = line === from.line ? from.col : 0; + const end = line === to.line ? to.col : length; + return [Math.max(0, start), Math.min(length, end)]; +} + +// The displayed text covered by a selection, joined with newlines. Columns are +// in displayed-text space, so the result matches exactly what is highlighted. +export function extractSelection( + content: string, + selection: Selection, +): string { + const lines = content.split("\n"); + const dt = (i: number) => displayText(lines[i] ?? ""); + const [from, to] = orderSelection(selection.anchor, selection.head); + if (from.line === to.line) { + return dt(from.line).slice(from.col, to.col); + } + const out: string[] = [dt(from.line).slice(from.col)]; + for (let i = from.line + 1; i < to.line; i++) out.push(dt(i)); + out.push(dt(to.line).slice(0, to.col)); + return out.join("\n"); +} diff --git a/src/tui/dom-position.ts b/src/tui/dom-position.ts new file mode 100644 index 0000000..7b09a8d --- /dev/null +++ b/src/tui/dom-position.ts @@ -0,0 +1,22 @@ +import type { DOMElement } from "ink"; + +// Absolute top/left (0-based screen cells) of an Ink node, by summing each +// ancestor's yoga-computed offset up to the root. Used to translate terminal +// mouse coordinates into a position inside the response content box. +export function absolutePosition(node: DOMElement): { + top: number; + left: number; +} { + let top = 0; + let left = 0; + let cur: DOMElement | undefined = node; + while (cur) { + const yoga = cur.yogaNode; + if (yoga) { + top += yoga.getComputedTop(); + left += yoga.getComputedLeft(); + } + cur = cur.parentNode; + } + return { top, left }; +} diff --git a/src/tui/hooks/use-mouse-tracking.ts b/src/tui/hooks/use-mouse-tracking.ts new file mode 100644 index 0000000..ba5f674 --- /dev/null +++ b/src/tui/hooks/use-mouse-tracking.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; + +// SGR mouse tracking: 1002 = report button press/release plus motion while a +// button is held (drag), 1006 = extended SGR encoding so coordinates aren't +// capped at 223. Enabling this hands mouse events to the app and disables the +// terminal's own text selection, which is why we own copy-to-clipboard. +const ESC = "\x1b"; +const ENABLE = `${ESC}[?1002h${ESC}[?1006h`; +const DISABLE = `${ESC}[?1002l${ESC}[?1006l`; + +export function useMouseTracking(): void { + useEffect(() => { + if (!process.stdout.isTTY) return; + + process.stdout.write(ENABLE); + + // Belt-and-suspenders: if the app is killed hard (crash, SIGINT before + // Ink unmounts) the escape codes above would otherwise stay latched on the + // user's terminal, breaking normal mouse selection until they reset it. + const restore = () => { + process.stdout.write(DISABLE); + }; + process.on("exit", restore); + + return () => { + process.off("exit", restore); + restore(); + }; + }, []); +} diff --git a/src/tui/mouse.ts b/src/tui/mouse.ts new file mode 100644 index 0000000..22100bb --- /dev/null +++ b/src/tui/mouse.ts @@ -0,0 +1,29 @@ +// A decoded SGR mouse report. After Ink strips the leading ESC, a report looks +// like "[