From 818eb4e6a16c34b4855d13b740fb2cb233c3cede Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:05:27 +0000 Subject: [PATCH 1/2] Add clipboard copy/cut/paste (Issue #91) Adds a tecode.clipboard API namespace (read/write) backed by an internal buffer with write-through OSC 52 sync to the terminal's system clipboard when supported and clipboard.useSystemClipboard is enabled. Bracketed-paste terminal input is decoded to UTF-8 and routed through a new EditorInputRouter.insertText method that applies a paste as one multi-cursor edit batch (one applyEdits call, one undo step), bypassing the single-code-point restriction plain keystrokes go through. editor-core gains editor.action.clipboardCopy/Cut/Paste commands, with ctrl+x/ctrl+v default keybindings; clipboardCopy intentionally has no default keybinding since ctrl+c is intercepted by OpenTUI's own exitOnCtrlC handling before it ever reaches the keymap, and is currently the only way to quit the editor. Also fixes a latent bug in positionTransform.ts's transformPosition: it mis-placed the cursor after a multi-line insert/replace whose edit didn't start at column 0 (unexercised until this task's paste path, the first multi-line caller in packages/core) by porting the same-line-aware algorithm editor-core's own copy of this function already carries. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- packages/api/src/index.ts | 1 + packages/api/src/namespaces.ts | 58 +++++- .../builtin/command-palette/index.test.ts | 1 + .../builtin/editor-core/clipboard.test.ts | 129 +++++++++++++ packages/builtin/editor-core/clipboard.ts | 114 ++++++++++++ packages/builtin/editor-core/index.test.ts | 175 ++++++++++++++++++ packages/builtin/editor-core/index.ts | 58 ++++++ packages/builtin/editor-core/manifest.ts | 54 ++++++ packages/builtin/explorer/index.test.tsx | 1 + .../builtin/keybindings-editor/index.test.ts | 1 + packages/builtin/statusbar/index.test.ts | 1 + packages/cli/src/keyRouting.test.ts | 32 +++- packages/cli/src/keyRouting.ts | 24 +++ packages/cli/src/main.test.ts | 1 + packages/cli/src/main.ts | 95 ++++++++++ packages/cli/src/renderShell.tsx | 55 ++++++ packages/cli/src/shutdownOnDestroy.test.ts | 7 +- .../core/src/api/create.clipboard.test.ts | 65 +++++++ packages/core/src/api/create.ts | 23 +++ packages/core/src/api/index.ts | 1 + packages/core/src/api/stubs.ts | 23 +++ packages/core/src/clipboard/clipboard.test.ts | 100 ++++++++++ packages/core/src/clipboard/clipboard.ts | 148 +++++++++++++++ packages/core/src/clipboard/index.ts | 4 + packages/core/src/editor/inputRouter.test.ts | 136 ++++++++++++++ packages/core/src/editor/inputRouter.ts | 120 +++++++++++- .../core/src/editor/positionTransform.test.ts | 40 ++++ packages/core/src/editor/positionTransform.ts | 78 +++++--- packages/core/src/index.ts | 2 + samples/settings.json | 10 +- 30 files changed, 1529 insertions(+), 28 deletions(-) create mode 100644 packages/builtin/editor-core/clipboard.test.ts create mode 100644 packages/builtin/editor-core/clipboard.ts create mode 100644 packages/core/src/api/create.clipboard.test.ts create mode 100644 packages/core/src/clipboard/clipboard.test.ts create mode 100644 packages/core/src/clipboard/clipboard.ts create mode 100644 packages/core/src/clipboard/index.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d6ba937..9508024 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -74,6 +74,7 @@ export type { ContextNamespace, LanguagesNamespace, ThemesNamespace, + ClipboardNamespace, Tecode, } from "./namespaces"; diff --git a/packages/api/src/namespaces.ts b/packages/api/src/namespaces.ts index 9667805..14fa895 100644 --- a/packages/api/src/namespaces.ts +++ b/packages/api/src/namespaces.ts @@ -1,5 +1,5 @@ /** - * The nine `tecode.*` namespaces (Req 10.1, design.md §12) and the + * The ten `tecode.*` namespaces (Req 10.1, design.md §12) and the * aggregate {@link Tecode} interface that bundles them into the single * frozen object handed to every extension. */ @@ -451,6 +451,61 @@ export interface ThemesNamespace { onDidChange: Event; } +/* ------------------------------------------------------------------ */ +/* tecode.clipboard */ +/* ------------------------------------------------------------------ */ + +/** + * The clipboard (Issue #91): an internal buffer holding the last text + * copied or cut, write-through synced to the terminal's OWN system + * clipboard via OSC 52 (`@opentui/core`'s `CliRenderer. + * copyToClipboardOSC52`, `packages/cli/src/renderShell.tsx`'s + * `onClipboardWriterReady`) when the host terminal supports it and + * `clipboard.useSystemClipboard` (`editor-core`'s own configuration + * contribution) is enabled. Backs `editor-core`'s + * `editor.action.clipboardCopy`/`clipboardCut`/`clipboardPaste` commands + * (Issue #91), and is available to any other extension that wants + * programmatic access to the same buffer. + * + * **Never throws — matches `FileSystem`'s never-crash discipline, NOT its + * reject-on-failure one**: unlike {@link FileSystem}'s `read`/`write` + * (which reject the returned promise on a real I/O failure — a caller is + * expected to handle that), a clipboard write's OSC 52 half is a + * best-effort terminal escape sequence with no reliable failure signal at + * all — a terminal that ignores it produces neither an error nor any + * other observable difference from success. {@link write} therefore always + * resolves once the INTERNAL buffer is updated (the part every terminal + * supports unconditionally); an OSC 52 write that the host reports failing + * is logged (`HostLog`, design.md §14) and otherwise swallowed, never + * surfaced as a rejection. + * + * **OSC 52 is write-only here, deliberately**: reading a terminal's system + * clipboard back via OSC 52 is not portable across terminals (many either + * don't implement the query form at all or gate it behind a user prompt), + * so {@link read} only ever reports this namespace's OWN internal buffer — + * never attempts a live OSC 52 query. This means `read()` sees exactly + * what THIS process (or another `tecode.clipboard.write` caller) most + * recently wrote, not necessarily whatever the OS clipboard currently + * holds if something else changed it in between. + */ +export interface ClipboardNamespace { + /** + * The clipboard's current internal buffer contents (this namespace's + * TSDoc's "OSC 52 is write-only" note) — `""` when nothing has been + * copied/cut yet this session. Always resolves; never rejects. + */ + read(): Promise; + /** + * Store `text` as the clipboard's new internal buffer contents, and + * (when system-clipboard sync is enabled and the host terminal + * supports it) write it through to the terminal's OWN clipboard via OSC + * 52. Always resolves once the internal buffer is updated — an OSC 52 + * write failure is logged and swallowed, never surfaced as a rejection + * (this namespace's TSDoc). + */ + write(text: string): Promise; +} + /* ------------------------------------------------------------------ */ /* Tecode — the aggregate namespace object */ /* ------------------------------------------------------------------ */ @@ -472,4 +527,5 @@ export interface Tecode { context: ContextNamespace; languages: LanguagesNamespace; themes: ThemesNamespace; + clipboard: ClipboardNamespace; } diff --git a/packages/builtin/command-palette/index.test.ts b/packages/builtin/command-palette/index.test.ts index 71be135..fc8ca6c 100644 --- a/packages/builtin/command-palette/index.test.ts +++ b/packages/builtin/command-palette/index.test.ts @@ -136,6 +136,7 @@ function createFakeApi(tree: FakeTree, rootUri: Uri | undefined = "file:///works }, languages: undefined as never, themes: undefined as never, + clipboard: undefined as never, }; return { diff --git a/packages/builtin/editor-core/clipboard.test.ts b/packages/builtin/editor-core/clipboard.test.ts new file mode 100644 index 0000000..0fc1a29 --- /dev/null +++ b/packages/builtin/editor-core/clipboard.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import type { Position, Selection } from "@tecode/api"; +import type { LineReader } from "./movement"; +import { buildClipboardText, buildCutResult, buildPasteResult } from "./clipboard"; +import { collapsedSelection } from "./selectionMerge"; + +function pos(line: number, character: number): Position { + return { line, character }; +} + +function cursorAt(line: number, character: number): Selection { + return collapsedSelection(pos(line, character)); +} + +function selectionOf(startLine: number, startChar: number, endLine: number, endChar: number): Selection { + const start = pos(startLine, startChar); + const end = pos(endLine, endChar); + return { start, end, anchor: start, active: end }; +} + +function reversedSelectionOf(startLine: number, startChar: number, endLine: number, endChar: number): Selection { + const start = pos(startLine, startChar); + const end = pos(endLine, endChar); + return { start, end, anchor: end, active: start }; +} + +function reader(lines: string[]): LineReader { + return { getLine: (n) => lines[n] ?? "", lineCount: lines.length }; +} + +describe("buildClipboardText (Issue #91)", () => { + test("empty selections array: \"\"", () => { + expect(buildClipboardText(reader(["abc"]), [])).toBe(""); + }); + + test("a single collapsed cursor: \"\" (nothing selected)", () => { + expect(buildClipboardText(reader(["abc"]), [cursorAt(0, 1)])).toBe(""); + }); + + test("a single non-collapsed selection: its own text", () => { + const text = buildClipboardText(reader(["hello world"]), [selectionOf(0, 6, 0, 11)]); + expect(text).toBe("world"); + }); + + test("a selection spanning multiple lines includes the real newline(s) in between", () => { + const text = buildClipboardText(reader(["abc", "def", "ghi"]), [selectionOf(0, 1, 2, 2)]); + expect(text).toBe("bc\ndef\ngh"); + }); + + test("multiple cursors: each selection's own text, joined by \\n, in selection order", () => { + const text = buildClipboardText(reader(["foo bar", "baz qux"]), [ + selectionOf(0, 0, 0, 3), + selectionOf(1, 4, 1, 7), + ]); + expect(text).toBe("foo\nqux"); + }); + + test("direction (forward vs backward selection) does not change the copied text", () => { + const forward = buildClipboardText(reader(["abcdef"]), [selectionOf(0, 1, 0, 4)]); + const backward = buildClipboardText(reader(["abcdef"]), [reversedSelectionOf(0, 1, 0, 4)]); + expect(forward).toBe("bcd"); + expect(backward).toBe("bcd"); + }); +}); + +describe("buildCutResult (Issue #91)", () => { + test("a collapsed cursor: no edit, unchanged position, empty clipboard text", () => { + const result = buildCutResult(reader(["abc"]), [cursorAt(0, 1)]); + expect(result.text).toBe(""); + expect(result.edits).toHaveLength(0); + expect(result.selections).toEqual([cursorAt(0, 1)]); + }); + + test("a single non-collapsed selection: deletes the range, collapses the cursor to the start, copies the cut text", () => { + const result = buildCutResult(reader(["hello world"]), [selectionOf(0, 6, 0, 11)]); + expect(result.text).toBe("world"); + expect(result.edits).toEqual([{ range: { start: pos(0, 6), end: pos(0, 11) }, newText: "" }]); + expect(result.selections).toEqual([cursorAt(0, 6)]); + }); + + test("multiple cursors: only non-collapsed selections produce edits; the batch is ONE edits array, both cursors' text is still copied", () => { + const result = buildCutResult(reader(["aaa bbb ccc"]), [ + selectionOf(0, 0, 0, 3), // "aaa" + cursorAt(0, 5), // collapsed — nothing to delete + selectionOf(0, 8, 0, 11), // "ccc" + ]); + expect(result.text).toBe("aaa\n\nccc"); + expect(result.edits).toHaveLength(2); // only the two non-collapsed selections + }); + + test("a backward (reversed anchor/active) selection still cuts correctly and collapses to its start", () => { + const result = buildCutResult(reader(["abcdef"]), [reversedSelectionOf(0, 1, 0, 4)]); + expect(result.text).toBe("bcd"); + expect(result.selections).toEqual([cursorAt(0, 1)]); + }); +}); + +describe("buildPasteResult (Issue #91)", () => { + test("a single collapsed cursor: inserts the pasted text, caret lands after it", () => { + const result = buildPasteResult([cursorAt(0, 1)], "XY"); + expect(result.edits).toEqual([{ range: { start: pos(0, 1), end: pos(0, 1) }, newText: "XY" }]); + expect(result.selections).toEqual([cursorAt(0, 3)]); + }); + + test("a non-collapsed selection is replaced wholesale, caret lands at the end of the pasted text", () => { + const result = buildPasteResult([selectionOf(0, 1, 0, 4)], "Z"); + expect(result.edits).toEqual([{ range: { start: pos(0, 1), end: pos(0, 4) }, newText: "Z" }]); + expect(result.selections).toEqual([cursorAt(0, 2)]); + }); + + test("a backward selection is still replaced correctly, not from the wrong end", () => { + const result = buildPasteResult([reversedSelectionOf(0, 1, 0, 4)], "Z"); + expect(result.selections).toEqual([cursorAt(0, 2)]); + }); + + test("multi-line pasted text at a single cursor lands the caret on the pasted text's own last line", () => { + // Regression coverage for the exact class of bug `positionTransform.ts` + // (both `@tecode/core`'s and this package's own copy) had to get right + // for a multi-line replacement whose edit does not start at column 0. + const result = buildPasteResult([cursorAt(0, 1)], "line1\nline2\nline3"); + expect(result.selections).toEqual([cursorAt(2, 5)]); // "line3".length + }); + + test("two cursors on the same line: the second cursor's position accounts for the first's own paste", () => { + const result = buildPasteResult([cursorAt(0, 2), cursorAt(0, 5)], "ab"); + expect(result.edits).toHaveLength(2); + expect(result.selections).toEqual([cursorAt(0, 4), cursorAt(0, 9)]); + }); +}); diff --git a/packages/builtin/editor-core/clipboard.ts b/packages/builtin/editor-core/clipboard.ts new file mode 100644 index 0000000..4ed4635 --- /dev/null +++ b/packages/builtin/editor-core/clipboard.ts @@ -0,0 +1,114 @@ +/** + * Pure per-selection logic for `editor-core`'s clipboard commands (Issue + * #91): `editor.action.clipboardCopy`/`clipboardCut`/`clipboardPaste`. + * Copy/cut read the text under every selection (joined by `"\n"` across + * multiple cursors — a multi-cursor copy/cut's clipboard text has one line + * per cursor, in selection order); cut also builds the delete-the-selection + * edit batch, reusing `editing.ts`'s `buildEditBatch` exactly like + * `deleteLeft`/`deleteRight` do. Paste reuses `editing.ts`'s own + * `buildInsertEdit` — a paste and a Tab/Enter keystroke both "insert this + * text, replacing any active selection" (that function's own TSDoc). + * + * No `@tecode/core` import (the ESLint layering rule); reads through a + * {@link LineReader}, matching `movement.ts`/`multiCursor.ts`. + */ + +import type { Position, Selection, TextEdit } from "@tecode/api"; +import { buildEditBatch, buildInsertEdit, type EditBatch } from "./editing"; +import type { LineReader } from "./movement"; +import { comparePositions } from "./positionTransform"; + +/** Whether `selection` is a plain collapsed cursor (no selected range) — + * duplicated locally (`editing.ts`'s own private `isCollapsed`, `movement. + * ts`'s `multiCursor.ts`'s own copies) rather than exported/shared, matching + * this package's existing convention for this one-line check. */ +function isCollapsed(selection: Selection): boolean { + return comparePositions(selection.start, selection.end) === 0; +} + +/** Join every line of the document into one string with the offset each + * line starts at, and the inverse offset→position lookup — duplicated from + * `multiCursor.ts`'s own private `readBuffer`/`toOffset` (not exported + * there) rather than imported, for the same "no cross-module coupling + * beyond what's actually shared" reasoning `multiCursor.ts`'s own TSDoc + * gives for not depending on `movement.ts`. */ +function readBuffer(reader: LineReader): { text: string; lineStarts: number[] } { + const lines: string[] = []; + const lineStarts: number[] = []; + let offset = 0; + for (let i = 0; i < reader.lineCount; i++) { + const line = reader.getLine(i); + lineStarts.push(offset); + lines.push(line); + offset += line.length + 1; + } + return { text: lines.join("\n"), lineStarts }; +} + +/** Convert `position` to an offset into {@link readBuffer}'s `text`. */ +function toOffset(position: Position, lineStarts: readonly number[]): number { + return lineStarts[position.line]! + position.character; +} + +/** + * The text `editor.action.clipboardCopy`/`clipboardCut` write to the + * clipboard (Issue #91): each selection's own text (`""` for a collapsed + * cursor with nothing selected), joined with `"\n"` across multiple + * cursors, in `selections`' own order. `""` for an empty `selections` + * array (the no-active-editor/no-op case — `index.ts`'s command handlers + * already guard on this before ever calling here, matching every other + * command in this package's convention). + */ +export function buildClipboardText(reader: LineReader, selections: readonly Selection[]): string { + if (selections.length === 0) return ""; + const { text, lineStarts } = readBuffer(reader); + return selections + .map((selection) => text.slice(toOffset(selection.start, lineStarts), toOffset(selection.end, lineStarts))) + .join("\n"); +} + +/** Build the edit that deletes `selection`'s own range for + * `editor.action.clipboardCut` — `undefined` for a collapsed selection + * (nothing to delete for that cursor, matching `buildBackspaceEdit`/ + * `buildDeleteEdit`'s own "boundary no-op" convention in `editing.ts`, + * rather than deleting a whole line the way some editors do with nothing + * selected — Issue #91 does not ask for that). */ +function buildCutRangeEdit(selection: Selection): TextEdit | undefined { + if (isCollapsed(selection)) return undefined; + return { range: { start: selection.start, end: selection.end }, newText: "" }; +} + +/** What {@link buildCutResult} produces: the clipboard text (this module's + * {@link buildClipboardText}) plus the delete edit batch (`editing.ts`'s + * `EditBatch` shape — `edits` to apply, `selections` the resulting + * collapsed cursors). */ +export interface CutResult extends EditBatch { + text: string; +} + +/** + * Build `editor.action.clipboardCut`'s full result (Issue #91): the text to + * write to the clipboard (every selection's own text, even a collapsed + * one's `""`, so a cut always copies exactly what a copy of the same + * selections would have), and the edit batch that deletes each + * NON-collapsed selection's range — reusing `editing.ts`'s `buildEditBatch` + * exactly like `deleteLeft`/`deleteRight` do (this module's TSDoc). + */ +export function buildCutResult(reader: LineReader, selections: readonly Selection[]): CutResult { + const text = buildClipboardText(reader, selections); + const { edits, selections: newSelections } = buildEditBatch(selections, buildCutRangeEdit); + return { text, edits, selections: newSelections }; +} + +/** + * Build `editor.action.clipboardPaste`'s edit batch (Issue #91): insert + * `text` at every selection, replacing its range if it has one — reusing + * `editing.ts`'s own `buildInsertEdit(selection, text)` (the exact + * "insert, replacing any active selection" shape Tab/Enter already use) + * through `buildEditBatch`, so a multi-line/multi-cursor paste is one + * batch, one `applyEdits` call, one undo step, exactly like every other + * `editor-core` editing command. + */ +export function buildPasteResult(selections: readonly Selection[], text: string): EditBatch { + return buildEditBatch(selections, (selection) => buildInsertEdit(selection, text)); +} diff --git a/packages/builtin/editor-core/index.test.ts b/packages/builtin/editor-core/index.test.ts index f775a4a..b854913 100644 --- a/packages/builtin/editor-core/index.test.ts +++ b/packages/builtin/editor-core/index.test.ts @@ -48,6 +48,7 @@ function createFakeApi(initialLines: string[]) { const configListeners = new Set<(e: ConfigChangeEvent) => void>(); const savedUris: string[] = []; const languageContributions = new Map(); + let clipboardBuffer = ""; function applyEditsToLines(edits: TextEdit[]): void { // Apply in reverse document order so earlier edits' ranges stay valid @@ -206,6 +207,12 @@ function createFakeApi(initialLines: string[]) { getLanguage: (id: string) => languageContributions.get(id), }, themes: undefined as never, + clipboard: { + read: async () => clipboardBuffer, + write: async (text: string) => { + clipboardBuffer = text; + }, + }, } as unknown as Tecode; function setConfig(key: string, value: unknown): void { @@ -217,6 +224,10 @@ function createFakeApi(initialLines: string[]) { return { api, lines, + getClipboardBuffer: () => clipboardBuffer, + setClipboardBuffer: (text: string) => { + clipboardBuffer = text; + }, appliedEdits, savedUris, setConfig, @@ -520,3 +531,167 @@ describe("editor-core activate() — bracket auto-close (Req 11.1, Task 2.4)", ( expect(lines[0]).toBe("("); }); }); + +describe("editor-core activate() — clipboard copy/cut/paste (Issue #91)", () => { + test("copy writes the selected text to the clipboard and does not touch the buffer", async () => { + const { api, lines, appliedEdits, getClipboardBuffer } = activateFixture(["hello world"]); + api.editor.setSelections([ + { start: pos(0, 6), end: pos(0, 11), anchor: pos(0, 6), active: pos(0, 11) }, + ]); + + await api.commands.execute("editor.action.clipboardCopy"); + + expect(getClipboardBuffer()).toBe("world"); + expect(lines[0]).toBe("hello world"); + expect(appliedEdits).toHaveLength(0); + }); + + test("multi-cursor copy joins each selection's text with \\n, in selection order", async () => { + const { api, getClipboardBuffer } = activateFixture(["foo bar", "baz qux"]); + api.editor.setSelections([ + { start: pos(0, 0), end: pos(0, 3), anchor: pos(0, 0), active: pos(0, 3) }, + { start: pos(1, 4), end: pos(1, 7), anchor: pos(1, 4), active: pos(1, 7) }, + ]); + + await api.commands.execute("editor.action.clipboardCopy"); + + expect(getClipboardBuffer()).toBe("foo\nqux"); + }); + + test("cut writes the selected text to the clipboard AND deletes it, as one undo step", async () => { + const { api, lines, appliedEdits, getSelections, getClipboardBuffer } = activateFixture(["hello world"]); + api.editor.setSelections([ + { start: pos(0, 5), end: pos(0, 11), anchor: pos(0, 5), active: pos(0, 11) }, + ]); + + await api.commands.execute("editor.action.clipboardCut"); + + expect(getClipboardBuffer()).toBe(" world"); + expect(lines[0]).toBe("hello"); + expect(getSelections()).toEqual([cursorAt(0, 5)]); + expect(appliedEdits).toHaveLength(1); // ONE applyEdits call — one undo step + }); + + test("a multi-cursor cut is a SINGLE undo entry, not one per cursor", async () => { + const { api, lines } = activateFixture(["aaa bbb ccc"]); + api.editor.setSelections([ + { start: pos(0, 0), end: pos(0, 3), anchor: pos(0, 0), active: pos(0, 3) }, + { start: pos(0, 8), end: pos(0, 11), anchor: pos(0, 8), active: pos(0, 11) }, + ]); + + await api.commands.execute("editor.action.clipboardCut"); + expect(lines[0]).toBe(" bbb "); + + await api.commands.execute("editor.action.undo"); + expect(lines[0]).toBe("aaa bbb ccc"); + }); + + test("cut with a collapsed (empty) selection copies \"\" and does not call applyEdits", async () => { + const { api, appliedEdits, getClipboardBuffer, setClipboardBuffer } = activateFixture(["abc"]); + setClipboardBuffer("previous"); + api.editor.setSelections([cursorAt(0, 1)]); + + await api.commands.execute("editor.action.clipboardCut"); + + expect(getClipboardBuffer()).toBe(""); + expect(appliedEdits).toHaveLength(0); + }); + + test("paste inserts the clipboard's current text at the cursor, replacing any selection", async () => { + const { api, lines, getSelections, setClipboardBuffer } = activateFixture(["hello world"]); + setClipboardBuffer("there"); + api.editor.setSelections([ + { start: pos(0, 6), end: pos(0, 11), anchor: pos(0, 6), active: pos(0, 11) }, + ]); + + await api.commands.execute("editor.action.clipboardPaste"); + + expect(lines[0]).toBe("hello there"); + expect(getSelections()).toEqual([cursorAt(0, 11)]); + }); + + test("a multi-line paste across multiple cursors is a SINGLE applyEdits call (one undo step), not one per line", async () => { + const { api, lines, appliedEdits, setClipboardBuffer } = activateFixture(["a", "c"]); + setClipboardBuffer("X\nY"); + api.editor.setSelections([cursorAt(0, 1), cursorAt(1, 1)]); + + await api.commands.execute("editor.action.clipboardPaste"); + + expect(appliedEdits).toHaveLength(1); // the mutation this test is built to catch: a loop over lines + expect(appliedEdits[0]).toHaveLength(2); // one TextEdit per cursor, batched together + expect(lines).toEqual(["aX", "Y", "cX", "Y"]); + }); + + test("paste with an empty clipboard buffer (nothing copied/cut yet) leaves the buffer unchanged", async () => { + const { api, lines } = activateFixture(["abc"]); + api.editor.setSelections([cursorAt(0, 1)]); + // Default clipboard buffer is "" — nothing has been copied/cut yet. + // `buildInsertEdit` still produces a real (zero-width, empty-text) + // `TextEdit` for this case — `editor.action.clipboardPaste` applies it + // like any other edit rather than special-casing an empty clipboard — + // so the observable outcome is simply "the buffer is unchanged", not + // "applyEdits was never called". + await api.commands.execute("editor.action.clipboardPaste"); + expect(lines[0]).toBe("abc"); + }); + + test("empty selections array (no active editor): copy/cut/paste never call applyEdits or touch the clipboard", async () => { + const commandHandlers = new Map Promise | void>(); + let applyEditsCalls = 0; + let clipboardReadCalls = 0; + let clipboardWriteCalls = 0; + const fakeApi = { + commands: { + register: (id: string, handler: () => Promise | void) => { + commandHandlers.set(id, handler); + return { dispose() {} }; + }, + execute: async (id: string) => commandHandlers.get(id)?.(), + list: () => [], + }, + workspace: { save: async () => {} }, + window: { + get activeEditor() { + return { document: { applyEdits: () => applyEditsCalls++, transaction: (fn: () => void) => fn() } }; + }, + }, + editor: { + get selections() { + return []; + }, + getLine: () => "", + get lineCount() { + return 0; + }, + setSelections: () => {}, + }, + config: { get: () => undefined, onDidChange: () => ({ dispose() {} }) }, + languages: { register: () => ({ dispose() {} }), getLanguageId: () => "plaintext", getLanguage: () => undefined }, + clipboard: { + read: async () => { + clipboardReadCalls++; + return "should never be read"; + }, + write: async () => { + clipboardWriteCalls++; + }, + }, + } as unknown as Tecode; + + const ctx: ExtensionContext = { + api: fakeApi, + extensionUri: "file:///fake-ext", + subscriptions: [], + storagePath: "/tmp/fake-ext-storage", + }; + activate(ctx); + + await fakeApi.commands.execute("editor.action.clipboardPaste"); + await fakeApi.commands.execute("editor.action.clipboardCut"); + await fakeApi.commands.execute("editor.action.clipboardCopy"); + + expect(applyEditsCalls).toBe(0); + expect(clipboardReadCalls).toBe(0); + expect(clipboardWriteCalls).toBe(0); + }); +}); diff --git a/packages/builtin/editor-core/index.ts b/packages/builtin/editor-core/index.ts index f6c550f..6f72852 100644 --- a/packages/builtin/editor-core/index.ts +++ b/packages/builtin/editor-core/index.ts @@ -98,6 +98,7 @@ import type { TextEdit, } from "@tecode/api"; import { buildBracketEditBatch } from "./brackets"; +import { buildClipboardText, buildCutResult, buildPasteResult } from "./clipboard"; import { buildToggleLineCommentResult } from "./comments"; import { buildBackspaceEdit, @@ -425,6 +426,63 @@ export function activate(ctx: ExtensionContext): void { ), ); ctx.subscriptions.push(api.commands.register("editor.action.closeFind", () => api.editor.find.close())); + + // Issue #91: clipboard copy/cut/paste — pure builders in `clipboard.ts`, + // wired to `api.clipboard` for the actual buffer/OSC-52 read-write. No + // `clipboard.useSystemClipboard` reading happens here: that setting's + // schema is declared by THIS manifest (`manifest.ts`'s `contributes. + // configuration`), but the flag it controls lives on the host-only + // `Clipboard` service `@tecode/api`'s `ClipboardNamespace` deliberately + // does not expose (only `read`/`write` are extension-visible) — wiring + // config to that flag is `packages/cli/src/main.ts`'s job + // (`AssemblyRoot.applyClipboardSystemSetting`'s TSDoc explains why). + // + // Empty `selections` (`[]`, no active editor) makes each handler a + // documented no-op — no `applyEdits` call, no `api.clipboard.write`/ + // `read` either — matching every other command's own guard in this file. + ctx.subscriptions.push( + api.commands.register("editor.action.clipboardCopy", async () => { + const selections = api.editor.selections; + if (selections.length === 0) return; + await api.clipboard.write(buildClipboardText(reader(), selections)); + }), + ); + + ctx.subscriptions.push( + api.commands.register("editor.action.clipboardCut", async () => { + const editor = api.window.activeEditor; + if (!editor) return; + const selections = api.editor.selections; + if (selections.length === 0) return; + const { text, edits, selections: newSelections } = buildCutResult(reader(), selections); + await api.clipboard.write(text); + if (edits.length > 0) { + const document: Document = editor.document; + // Same "one transaction = one undo step" shape as every other + // editing command in this file (`registerEditing`'s TSDoc). + document.transaction(() => document.applyEdits(edits)); + } + api.editor.setSelections(newSelections); + }), + ); + + ctx.subscriptions.push( + api.commands.register("editor.action.clipboardPaste", async () => { + const editor = api.window.activeEditor; + if (!editor) return; + const selections = api.editor.selections; + if (selections.length === 0) return; + const text = await api.clipboard.read(); + const { edits, selections: newSelections } = buildPasteResult(selections, text); + if (edits.length === 0) return; + const document: Document = editor.document; + // ONE transaction, ONE `applyEdits` call for the WHOLE batch (Req + // 6.6) — a multi-cursor/multi-line paste is a single undo step, + // exactly like `buildPasteResult`'s own TSDoc documents. + document.transaction(() => document.applyEdits(edits)); + api.editor.setSelections(newSelections); + }), + ); } export function deactivate(): void { diff --git a/packages/builtin/editor-core/manifest.ts b/packages/builtin/editor-core/manifest.ts index aebae86..b7227ca 100644 --- a/packages/builtin/editor-core/manifest.ts +++ b/packages/builtin/editor-core/manifest.ts @@ -160,6 +160,30 @@ * Plain `ctrl+letter` combos (`ctrl+s`, `ctrl+z`, `ctrl+y`, `ctrl+d`) are * unaffected by any of this — a single modifier with no shift ambiguity * decodes identically and unambiguously in every mode. + * + * **Clipboard commands (Issue #91)**: `editor.action.clipboardCopy`/ + * `clipboardCut`/`clipboardPaste` are declared like every other command + * above — reachable from the command palette and `tecode.commands.execute` + * — but `clipboardCopy` is bound to NO default keybinding, deliberately, + * even though `ctrl+c` is not claimed by anything else in this manifest + * (`ctrl+x`/`ctrl+v` ARE bound below, for cut/paste). `ctrl+c` is NOT + * usable as a keybinding at all today: `packages/cli/src/renderShell.tsx`'s + * `renderShellToTerminal` calls `@opentui/core`'s `createCliRenderer()` + * with no `exitOnCtrlC` override, which defaults to `true` — OpenTUI + * itself intercepts the raw `\x03` byte and calls `CliRenderer.destroy()` + * directly, BEFORE it ever reaches this manifest's keymap layer (the same + * "raw mode disables signal generation" mechanism `renderShell.tsx`'s + * `ShellRenderDeps.onDestroy` TSDoc documents for why Ctrl+C never becomes + * a real `SIGINT` either). Worse, Ctrl+C is currently the ONLY way to quit + * tecode at all (Issue #84, Req 12.3) — no `workbench.action.quit` (or + * equivalent) command exists anywhere in any manifest yet, core or + * built-in. Adding a `ctrl+c` binding here would therefore be silently + * unreachable in practice (OpenTUI's own handling wins the race every + * time) while ALSO reading as if a real alternative to Ctrl+C-to-quit + * existed, which it does not. Whether/how to free up Ctrl+C for copy (via + * `exitOnCtrlC: false` plus a real quit command) is a product decision for + * the app owner, out of scope here — do not "fix" this by adding a + * `ctrl+c` binding without that decision being made first. */ import type { Manifest } from "@tecode/api"; @@ -170,6 +194,13 @@ const WHEN_EDITOR_TEXT_FOCUS = "editorTextFocus"; * the exact same mechanism `WHEN_EDITOR_TEXT_FOCUS` uses for the buffer. */ const WHEN_FIND_WIDGET_FOCUS = "findWidgetFocus"; +/** Issue #91's `clipboard.useSystemClipboard` setting's key — named, + * exported constant, matching `explorer/manifest.ts`'s + * `EXPLORER_SHOW_HIDDEN_CONFIG_KEY` precedent (`index.ts` and this + * manifest's own `contributes.configuration` block below both reference + * this same string). */ +export const CLIPBOARD_USE_SYSTEM_CONFIG_KEY = "clipboard.useSystemClipboard"; + export default { id: "tecode.editor-core", version: "0.1.0", @@ -237,6 +268,13 @@ export default { category: "Editor", }, { id: "editor.action.closeFind", title: "Close Find", category: "Editor" }, + // Issue #91: clipboard copy/cut/paste. See this file's TSDoc's + // "Clipboard commands (Issue #91)" section, just below the + // keybindings table, for why `clipboardCopy` alone has no default + // keybinding. + { id: "editor.action.clipboardCopy", title: "Copy", category: "Editor" }, + { id: "editor.action.clipboardCut", title: "Cut", category: "Editor" }, + { id: "editor.action.clipboardPaste", title: "Paste", category: "Editor" }, ], keybindings: [ { key: "left", command: "editor.action.cursorLeft", when: WHEN_EDITOR_TEXT_FOCUS }, @@ -307,6 +345,22 @@ export default { { key: "return", command: "editor.action.findNext", when: WHEN_FIND_WIDGET_FOCUS }, { key: "shift+return", command: "editor.action.findPrevious", when: WHEN_FIND_WIDGET_FOCUS }, { key: "escape", command: "editor.action.closeFind", when: WHEN_FIND_WIDGET_FOCUS }, + // Issue #91: clipboard cut/paste. See this file's TSDoc's "Clipboard + // commands (Issue #91)" section for why `clipboardCopy` has no + // keybinding entry here at all. + { key: "ctrl+x", command: "editor.action.clipboardCut", when: WHEN_EDITOR_TEXT_FOCUS }, + { key: "ctrl+v", command: "editor.action.clipboardPaste", when: WHEN_EDITOR_TEXT_FOCUS }, ], + configuration: { + title: "Clipboard", + properties: { + [CLIPBOARD_USE_SYSTEM_CONFIG_KEY]: { + type: "boolean", + default: true, + description: + "Sync copy/cut to the terminal's system clipboard via OSC 52, when the terminal supports it.", + }, + }, + }, }, } satisfies Manifest; diff --git a/packages/builtin/explorer/index.test.tsx b/packages/builtin/explorer/index.test.tsx index bf5fa5d..89da4ef 100644 --- a/packages/builtin/explorer/index.test.tsx +++ b/packages/builtin/explorer/index.test.tsx @@ -252,6 +252,7 @@ function createFakeApi(rootUri: Uri | undefined) { }, languages: undefined as never, themes: undefined as never, + clipboard: undefined as never, }; return { diff --git a/packages/builtin/keybindings-editor/index.test.ts b/packages/builtin/keybindings-editor/index.test.ts index e29d72f..e6966bd 100644 --- a/packages/builtin/keybindings-editor/index.test.ts +++ b/packages/builtin/keybindings-editor/index.test.ts @@ -64,6 +64,7 @@ function createFakeApi() { config: undefined as never, languages: undefined as never, themes: undefined as never, + clipboard: undefined as never, context: undefined as never, window: { showMessage(message: string, kind?: MessageKind) { diff --git a/packages/builtin/statusbar/index.test.ts b/packages/builtin/statusbar/index.test.ts index 3a278dc..50cfabb 100644 --- a/packages/builtin/statusbar/index.test.ts +++ b/packages/builtin/statusbar/index.test.ts @@ -153,6 +153,7 @@ function createFakeApi() { }, onDidChange: themesChange.on, }, + clipboard: undefined as never, }; return { diff --git a/packages/cli/src/keyRouting.test.ts b/packages/cli/src/keyRouting.test.ts index 193f845..dfdeec5 100644 --- a/packages/cli/src/keyRouting.test.ts +++ b/packages/cli/src/keyRouting.test.ts @@ -11,7 +11,13 @@ import { type KeymapLayers, } from "@tecode/core"; import editorCoreManifest from "@tecode/builtin/editor-core/manifest"; -import { handleKeyEvent, type KeyRoutingDeps, type RoutableKeyEvent } from "./keyRouting"; +import { + handleKeyEvent, + handlePasteEvent, + type KeyRoutingDeps, + type PasteRoutingDeps, + type RoutableKeyEvent, +} from "./keyRouting"; function keyOf(partial: Partial & { name: string }): RoutableKeyEvent { return { @@ -409,6 +415,30 @@ describe("editor-core's Task 2.5 find/replace keybindings (Req 11.1, manifest.ts }); }); +describe("handlePasteEvent (Issue #91's paste path)", () => { + test("delegates the decoded text straight to editorInputRouter.insertText", () => { + let received: string | undefined; + const deps: PasteRoutingDeps = { + editorInputRouter: { insertText: (text) => (received = text) }, + }; + + handlePasteEvent(deps, "pasted\ntext"); + + expect(received).toBe("pasted\ntext"); + }); + + test("goes through no chord machine at all — an empty paste is still forwarded", () => { + let calls = 0; + const deps: PasteRoutingDeps = { + editorInputRouter: { insertText: () => calls++ }, + }; + + handlePasteEvent(deps, ""); + + expect(calls).toBe(1); + }); +}); + /** * End-to-end version of the same pipeline, wired against the REAL * `ChordStateMachine`/`BindingTable`/`createEditorInputRouter`/`CoreDocument` diff --git a/packages/cli/src/keyRouting.ts b/packages/cli/src/keyRouting.ts index 9605cd5..c9a1822 100644 --- a/packages/cli/src/keyRouting.ts +++ b/packages/cli/src/keyRouting.ts @@ -53,3 +53,27 @@ export function handleKeyEvent(deps: KeyRoutingDeps, event: RoutableKeyEvent): v } deps.editorInputRouter.routeKeyEvent(event); } + +/** Dependencies for {@link handlePasteEvent} — narrowed to the one method + * it needs (matches {@link KeyRoutingDeps}'s own `Pick<...>` convention). */ +export interface PasteRoutingDeps { + editorInputRouter: Pick; +} + +/** + * Route one decoded bracketed-paste string (Issue #91, design.md §6.1's + * pipeline extended to terminal paste input): straight to {@link + * EditorInputRouter.insertText}, with no chord-machine step at all — + * unlike {@link handleKeyEvent}'s ordinary keystrokes, a paste never has a + * keybinding to match against; it always means "insert this text" at + * whatever the current selections are. `renderShell.tsx`'s + * `renderShellToTerminal` calls this from its `renderer.keyInput.on( + * "paste", ...)` listener, already having decoded `PasteEvent.bytes` (a + * `Uint8Array`) to a UTF-8 string (`ShellRenderDeps.onPaste`'s TSDoc) — + * this function itself never touches raw bytes, matching {@link + * handleKeyEvent}'s own "pulled out for direct, `@opentui/core`-free + * testability" shape (this module's TSDoc). + */ +export function handlePasteEvent(deps: PasteRoutingDeps, text: string): void { + deps.editorInputRouter.insertText(text); +} diff --git a/packages/cli/src/main.test.ts b/packages/cli/src/main.test.ts index efe3a1a..5717bd0 100644 --- a/packages/cli/src/main.test.ts +++ b/packages/cli/src/main.test.ts @@ -106,6 +106,7 @@ test("buildAssemblyRoot wires every core service and registers the 'tecode' modu "context", "languages", "themes", + "clipboard", ]); expect(root.api.workspace.rootUri).toBe(pathToUri(dir)); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 941d7f8..084cfbe 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -17,6 +17,7 @@ import { createConfigService, createContextService, createDocumentManager, + createClipboard, createEditorInputRouter, createEditorSessionService, createExtensionHost, @@ -58,6 +59,7 @@ import { type ContextService, type DiscoveryFs, type DocumentManager, + type Clipboard, type EditorInputRouter, type EditorSessionService, type ExtensionHost, @@ -89,6 +91,7 @@ import { applyConfiguredKeybindingPreset as applyConfiguredKeybindingPresetImpl, wireKeybindingPresetConfigSync, } from "./keybindingPresetConfigSync"; +import { handlePasteEvent } from "./keyRouting"; import { createKeymapState, type KeymapState } from "./keymapState"; import { createBuiltinLanguageAssetsFs } from "./languageAssetsFs"; import { renderShellHeadless, renderShellToTerminal, type RenderShell } from "./renderShell"; @@ -173,6 +176,13 @@ export interface AssemblyRoot { commands: CommandRegistry; documents: DocumentManager; fs: FileSystem; + /** The clipboard service (Issue #91, `@tecode/core`'s `clipboard/ + * clipboard.ts`) — backs `tecode.clipboard` (via `api` below), and + * receives the terminal's OSC 52 write function from `renderShell.tsx`'s + * `onClipboardWriterReady` once the render seam resolves one + * (`runTecode`, this module's TSDoc). Built once here, exactly like + * `fs` above. */ + clipboard: Clipboard; config: ConfigService; context: ContextService; api: Tecode; @@ -319,6 +329,37 @@ export interface AssemblyRoot { * the active theme. Disposed alongside every other startup-owned * subscription in {@link wireProcessExit}. */ keybindingPresetConfigSync: Disposable; + /** + * Apply the CURRENT `clipboard.useSystemClipboard` setting (Issue #91) + * to {@link clipboard}'s system-clipboard-sync flag. Lives here, in the + * composition root, rather than in `editor-core` (the extension that + * DECLARES this setting's schema) because `@tecode/api`'s + * `ClipboardNamespace` — all an extension can ever see — exposes only + * `read`/`write`, never the host-only `setSystemClipboardEnabled` this + * needs (`create.ts`'s "narrowing, not re-implementing" boundary + * extended to this one setting): only `main.ts` holds the real + * `Clipboard` instance. Mirrors `applyConfiguredKeybindingPreset`'s own + * shape (a core-owned setting wired to a core-owned service) even though + * THIS setting's schema is extension-owned, not `config/coreDefaults. + * ts`'s. `runTecode` calls this once after `config.ready` settles; + * {@link clipboardConfigSync} (below) calls it again on every live + * change to that one key. Falls back to `true` — matching both this + * setting's own schema default and {@link Clipboard}'s own internal + * default — when `config.get(...)` reports nothing yet (e.g. before + * `editor-core`'s manifest has been discovered/registered; an explicit + * `false` in the user's `settings.json` still applies immediately + * regardless of registration, since JSONC settings are read independent + * of schema registration). Synchronous and never throws: + * `Clipboard.setSystemClipboardEnabled` itself never throws + * (`clipboard/clipboard.ts`'s TSDoc). + */ + applyClipboardSystemSetting(): void; + /** Live `clipboard.useSystemClipboard` config-change subscription (Issue + * #91) — mirrors {@link keybindingPresetConfigSync}'s own shape, just for + * {@link applyClipboardSystemSetting} instead of the keybinding preset + * layer. Disposed alongside every other startup-owned subscription in + * {@link wireProcessExit}. */ + clipboardConfigSync: Disposable; /** The live two-stroke chord state machine (Req 4.4, design.md §6.1, * §6.3), built once here against a small forwarding view over `keymap` * (see this function's TSDoc's "Live keymap table view") so it always @@ -617,6 +658,11 @@ export function buildAssemblyRoot( sink, }); const fs = createFileSystem({ log }); + // Issue #91's clipboard service — built once, exactly like `fs` above. + // Its OSC 52 system writer arrives later (`runTecode`'s `renderShell(...)` + // call, via `deps.onClipboardWriterReady`) since it needs a real + // `CliRenderer` that does not exist yet at this point in the sync phase. + const clipboard = createClipboard({ log }); // `MODAL_DEFAULT_KEYBINDINGS` (Task 3.1, `ui/modalCommands.ts`) is this // codebase's first real occupant of the `defaults` layer @@ -723,6 +769,28 @@ export function buildAssemblyRoot( applyConfiguredKeybindingPresetImpl({ config, keymap, log }); const keybindingPresetConfigSync = wireKeybindingPresetConfigSync({ config, keymap, log }); + // `AssemblyRoot.applyClipboardSystemSetting`/`clipboardConfigSync` (Issue + // #91) — see that field's TSDoc for why this wiring lives here rather + // than in `editor-core`, despite that extension owning the setting's + // schema. The key string is duplicated (not imported from `editor-core/ + // manifest.ts`'s `CLIPBOARD_USE_SYSTEM_CONFIG_KEY`) — matching `stubs. + // ts`'s `createThemesStub`'s own precedent for this exact kind of small + // cross-boundary string duplication — since this composition root + // reading an extension's manifest CONSTANT for host-side wiring would + // invert the "extensions are data the host reads, not code the host + // imports internals from" boundary this repo otherwise keeps clean. Must + // stay in sync with `editor-core/manifest.ts`'s own + // `CLIPBOARD_USE_SYSTEM_CONFIG_KEY`. + const CLIPBOARD_USE_SYSTEM_CONFIG_KEY = "clipboard.useSystemClipboard"; + const applyClipboardSystemSetting = () => { + clipboard.setSystemClipboardEnabled( + config.get(CLIPBOARD_USE_SYSTEM_CONFIG_KEY) ?? true, + ); + }; + const clipboardConfigSync = config.onDidChange((event) => { + if (event.affectsConfiguration(CLIPBOARD_USE_SYSTEM_CONFIG_KEY)) applyClipboardSystemSetting(); + }); + const context = createContextService(); const layoutState = createLayoutStateService({ log, sink }); @@ -823,6 +891,7 @@ export function buildAssemblyRoot( languageRegistry, modalService, windowMessageService, + clipboard, }); // Must run before any extension module is imported (see this function's @@ -938,6 +1007,7 @@ export function buildAssemblyRoot( commands, documents, fs, + clipboard, config, context, api, @@ -958,6 +1028,8 @@ export function buildAssemblyRoot( applyKittyKeyboardVerdict, applyConfiguredKeybindingPreset, keybindingPresetConfigSync, + applyClipboardSystemSetting, + clipboardConfigSync, chordMachine, chordPendingIndicator, editorSession, @@ -1208,6 +1280,7 @@ export interface ShutdownRoot { hostErrorSink: Pick; highlightService: Pick; languageRegistry: Pick; + clipboardConfigSync: Pick; hostRef: { current?: Pick }; } @@ -1286,6 +1359,7 @@ export function createShutdown(root: ShutdownRoot, deps: ShutdownDeps = {}): () root.editorLangIdSync.dispose(); root.themeConfigSync.dispose(); root.keybindingPresetConfigSync.dispose(); + root.clipboardConfigSync.dispose(); root.themeSelectCommand.dispose(); root.openFileCommand.dispose(); root.tabCommands.dispose(); @@ -1484,6 +1558,12 @@ export async function runTecode( // initial application. root.applyConfiguredKeybindingPreset(); + // Apply the ACTUAL configured `clipboard.useSystemClipboard` now that + // `config.ready` has settled (Issue #91) — same "schema default only, + // until ready" reasoning as `keybindings.preset` above + // (`AssemblyRoot.applyClipboardSystemSetting`'s TSDoc). + root.applyClipboardSystemSetting(); + const { shutdown } = wireProcessExit(root); const renderShell = options.renderShell ?? (headless ? renderShellHeadless : renderShellToTerminal); @@ -1502,6 +1582,21 @@ export async function runTecode( chordMachine: root.chordMachine, editorInputRouter: root.editorInputRouter, modalService: root.modalService, + // Issue #91: the terminal's OSC 52 write function is handed to + // `root.clipboard` exactly once, as soon as the render seam resolves + // one (`ShellRenderDeps.onClipboardWriterReady`'s TSDoc) — + // `renderShellHeadless` never calls this, leaving `root.clipboard`'s + // system-clipboard sync permanently inert for a headless run, which is + // correct: there is no real terminal to write an OSC 52 escape + // sequence to. + onClipboardWriterReady: (write) => root.clipboard.setSystemWriter(write), + // Issue #91: bracketed-paste text, already decoded to UTF-8 by + // `renderShellToTerminal` (`ShellRenderDeps.onPaste`'s TSDoc), routed + // through `keyRouting.ts`'s `handlePasteEvent` into + // `root.editorInputRouter.insertText` — the same "pulled out for + // testability, wired here" shape `handleKeyEvent`'s own `renderer. + // keyInput.on("keypress", ...)` registration above already uses. + onPaste: (text) => handlePasteEvent({ editorInputRouter: root.editorInputRouter }, text), // Task 4.2's Kitty Keyboard Protocol wiring (Req 4.7, 13.3, design.md // §6.5): `renderShell.tsx`'s `onCapabilitiesResolved` delivers the raw // `@opentui/core` capabilities value (at most twice — synchronously, diff --git a/packages/cli/src/renderShell.tsx b/packages/cli/src/renderShell.tsx index 07ca06f..d969b78 100644 --- a/packages/cli/src/renderShell.tsx +++ b/packages/cli/src/renderShell.tsx @@ -176,6 +176,40 @@ export interface ShellRenderDeps { * every other optional dependency in this module. */ onDestroy?: () => void; + /** + * Delivers the terminal's OSC 52 write function exactly ONCE (Issue #91), + * without exposing the `CliRenderer` itself — the same "hand over a + * value/callback, not the renderer" convention as {@link + * onCapabilitiesResolved}/{@link onDestroy} above. {@link + * renderShellToTerminal} calls this synchronously, right after the + * renderer is created, with `renderer.copyToClipboardOSC52` bound to that + * renderer instance — the returned function's own boolean return value + * (`true`/`false` for accepted/not) is `@opentui/core`'s only per-call + * feedback; there is no separate "supported" query this callback also + * needs to report, since a terminal that does not support OSC 52 simply + * reports `false` (or is silently ignored) on every call, which the + * clipboard service (`@tecode/core`'s `clipboard/clipboard.ts`) already + * logs and swallows. Optional and never required: {@link + * renderShellHeadless} never calls this (no real `CliRenderer`/terminal + * exists to write an OSC 52 escape sequence to), matching every other + * optional terminal-seam callback in this module. + */ + onClipboardWriterReady?: (write: (text: string) => boolean) => void; + /** + * Delivers bracketed-paste text as it arrives (Issue #91): {@link + * renderShellToTerminal} listens for `renderer.keyInput`'s `"paste"` + * event (`@opentui/core`'s `PasteEvent`, `lib/KeyHandler.d.ts`) and + * decodes its `bytes` (a `Uint8Array`) as UTF-8 before calling this with + * the resulting string — extension/router code never sees raw bytes. + * Wired ONLY when this callback is supplied, the same "register nothing + * unless the caller actually wants it" pattern the `chordMachine`/ + * `editorInputRouter` pairing above already uses for `"keypress"` — a + * caller/test that omits this leaves paste entirely unhandled, exactly + * like every other optional callback here. {@link renderShellHeadless} + * never calls this either (this module's TSDoc's "First frame for a + * headless run" — there is no real terminal to receive a paste from). + */ + onPaste?: (text: string) => void; } /** The render seam's shape: resolves once "first frame" has happened (see @@ -241,6 +275,27 @@ export const renderShellToTerminal: RenderShell = async (deps) => { }); } + // OSC 52 system-clipboard write (Issue #91, `ShellRenderDeps. + // onClipboardWriterReady`'s TSDoc): delivered exactly once, bound to + // THIS renderer instance — never exposes `renderer` itself, only the + // bound write function. + if (deps.onClipboardWriterReady) { + deps.onClipboardWriterReady((text) => renderer.copyToClipboardOSC52(text)); + } + + // Bracketed-paste terminal input (Issue #91, `ShellRenderDeps.onPaste`'s + // TSDoc): `PasteEvent.bytes` is decoded to a UTF-8 string before ever + // reaching `deps.onPaste` — registered only when the caller actually + // wants it, the same "nothing wired unless asked" pattern the + // `chordMachine && editorInputRouter` pairing above already uses. + if (deps.onPaste) { + const onPaste = deps.onPaste; + const pasteDecoder = new TextDecoder(); + renderer.keyInput.on("paste", (event) => { + onPaste(pasteDecoder.decode(event.bytes)); + }); + } + // Terminal-capability reporting (Req 4.7, 13.3; design.md §6.5; Task // 4.2) — see `ShellRenderDeps.onCapabilitiesResolved`'s TSDoc for the // "at most twice, `.once` not `.on`" contract this implements. diff --git a/packages/cli/src/shutdownOnDestroy.test.ts b/packages/cli/src/shutdownOnDestroy.test.ts index 1795750..573550f 100644 --- a/packages/cli/src/shutdownOnDestroy.test.ts +++ b/packages/cli/src/shutdownOnDestroy.test.ts @@ -101,6 +101,7 @@ function createFakeShutdownRoot(overrides: { flush?: () => Promise } = {}) editorLangIdSync: disposable(), themeConfigSync: disposable(), keybindingPresetConfigSync: disposable(), + clipboardConfigSync: disposable(), themeSelectCommand: disposable(), openFileCommand: disposable(), tabCommands: disposable(), @@ -371,7 +372,7 @@ test("createShutdown's returned function is idempotent: destroy-then-signal runs await Promise.all([destroyCall, signalCall]); expect(calls.flush).toBe(1); - expect(calls.dispose).toBe(19); // one per disposable field in ShutdownRoot + expect(calls.dispose).toBe(20); // one per disposable field in ShutdownRoot expect(calls.disposeAll).toBe(1); }); @@ -387,7 +388,7 @@ test("createShutdown's returned function is idempotent: signal-then-destroy runs await Promise.all([signalCall, destroyCall]); expect(calls.flush).toBe(1); - expect(calls.dispose).toBe(19); + expect(calls.dispose).toBe(20); expect(calls.disposeAll).toBe(1); // A THIRD call, after the sequence has already fully settled, is still @@ -395,7 +396,7 @@ test("createShutdown's returned function is idempotent: signal-then-destroy runs // from as many quit paths as ever call it). await shutdown(); expect(calls.flush).toBe(1); - expect(calls.dispose).toBe(19); + expect(calls.dispose).toBe(20); expect(calls.disposeAll).toBe(1); }); diff --git a/packages/core/src/api/create.clipboard.test.ts b/packages/core/src/api/create.clipboard.test.ts new file mode 100644 index 0000000..3020e6a --- /dev/null +++ b/packages/core/src/api/create.clipboard.test.ts @@ -0,0 +1,65 @@ +/** + * Tests for `createTecodeApi`'s real `tecode.clipboard` wiring (Issue #91): + * `read`/`write` delegate to an injected `Clipboard`/`ClipboardNamespace` + * when supplied, and fall back to `stubs.ts`'s `createClipboardStub` + * exactly as before otherwise (`CreateTecodeApiDeps.clipboard`'s TSDoc). + */ + +import { describe, expect, test } from "bun:test"; +import { createCommandRegistry } from "../commands/registry"; +import { createDocumentManager } from "../buffer/documentManager"; +import { createFileSystem } from "../buffer/fileSystem"; +import type { ConfigServiceFs } from "../config/service"; +import { createConfigService } from "../config/service"; +import { createContextService } from "../keymap/context"; +import { createHostLog } from "../host/errors"; +import { createClipboard } from "../clipboard/clipboard"; +import { createTecodeApi } from "./create"; + +function createEmptyConfigFs(): ConfigServiceFs { + return { + readFile: () => Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })), + watch: () => ({ close() {} }), + }; +} + +async function buildBaseDeps() { + const log = createHostLog(); + const sink = { error() {} }; + const commands = createCommandRegistry({ log, sink }); + const documents = createDocumentManager({ log, sink }); + const fs = createFileSystem({ log }); + const config = createConfigService({ log, sink, fs: createEmptyConfigFs() }); + await config.ready; + const context = createContextService(); + return { commands, documents, fs, config, context, sink }; +} + +describe("createTecodeApi's tecode.clipboard (Issue #91)", () => { + test("falls back to the stub when no clipboard is supplied: read() resolves '', write() is a no-op", async () => { + const deps = await buildBaseDeps(); + const api = createTecodeApi(deps); + + expect(await api.clipboard.read()).toBe(""); + await expect(api.clipboard.write("ignored")).resolves.toBeUndefined(); + expect(await api.clipboard.read()).toBe(""); // still "" — the stub never remembers a write + }); + + test("read/write delegate to the real Clipboard when supplied", async () => { + const deps = await buildBaseDeps(); + const clipboard = createClipboard(); + const api = createTecodeApi({ ...deps, clipboard }); + + await api.clipboard.write("copied via tecode.clipboard"); + expect(await api.clipboard.read()).toBe("copied via tecode.clipboard"); + // Reading straight off the real service proves this is genuine + // delegation, not two independent buffers that happen to agree. + expect(await clipboard.read()).toBe("copied via tecode.clipboard"); + }); + + test("tecode.clipboard is frozen — assigning to it is a no-op (or throws in strict mode), never mutates behavior", async () => { + const deps = await buildBaseDeps(); + const api = createTecodeApi(deps); + expect(Object.isFrozen(api.clipboard)).toBe(true); + }); +}); diff --git a/packages/core/src/api/create.ts b/packages/core/src/api/create.ts index 9d17ea5..9992a23 100644 --- a/packages/core/src/api/create.ts +++ b/packages/core/src/api/create.ts @@ -30,6 +30,7 @@ */ import type { + ClipboardNamespace, CommandsNamespace, ConfigNamespace, ContextNamespace, @@ -62,6 +63,7 @@ import type { WindowMessageService } from "../ui/windowMessageService"; import type { LanguageRegistry } from "../languages/languageRegistry"; import { cloneSelection, createEditorNamespace } from "./editorNamespace"; import { + createClipboardStub, createEditorStub, createLanguagesStub, createThemesStub, @@ -212,6 +214,17 @@ export interface CreateTecodeApiDeps { * that never render anywhere, so a mismatch falls back to the stub. */ windowMessageService?: Pick; + /** + * Backs the REAL `tecode.clipboard` (Issue #91, `clipboard/clipboard.ts`'s + * `createClipboard`) — `read`/`write` delegate straight through, same + * "same function references, no wrapper closures" shape as + * `commandsNamespace`/`configNamespace` above. Optional: a caller that + * omits this (every test that predates Issue #91) keeps `stubs.ts`'s + * `createClipboardStub()` — an always-`""` `read()` and a no-op `write()` + * — exactly like every other real-backing dependency's fallback in this + * file. + */ + clipboard?: Pick; } /** @@ -421,6 +434,15 @@ export function createTecodeApi(deps: CreateTecodeApiDeps): Tecode { getLanguage: languageRegistry ? languageRegistry.getLanguage : languagesStub.getLanguage, }); + // Real backing (Issue #91) when a `Clipboard`/`ClipboardNamespace` is + // supplied; otherwise `stubs.ts`'s `createClipboardStub()` — see + // `CreateTecodeApiDeps.clipboard`'s TSDoc. + const clipboardStub = createClipboardStub(); + const clipboardNamespace: ClipboardNamespace = Object.freeze({ + read: deps.clipboard ? deps.clipboard.read : clipboardStub.read, + write: deps.clipboard ? deps.clipboard.write : clipboardStub.write, + }); + return Object.freeze({ commands: commandsNamespace, workspace: workspaceNamespace, @@ -431,5 +453,6 @@ export function createTecodeApi(deps: CreateTecodeApiDeps): Tecode { context: contextNamespace, languages: languagesNamespace, themes: themesNamespace, + clipboard: clipboardNamespace, }); } diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index d1f67b0..c54ee8d 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -13,6 +13,7 @@ export { } from "./editorNamespace"; export { createBaseTheme, + createClipboardStub, createEditorStub, createFindStub, createLanguagesStub, diff --git a/packages/core/src/api/stubs.ts b/packages/core/src/api/stubs.ts index 1c93297..449da6d 100644 --- a/packages/core/src/api/stubs.ts +++ b/packages/core/src/api/stubs.ts @@ -36,6 +36,7 @@ */ import type { + ClipboardNamespace, Disposable, EditorNamespace, FindNamespace, @@ -416,6 +417,28 @@ export interface ThemesStub extends ThemesNamespace { * {@link createBaseTheme} palette until a real theme loader (design.md §9) * can resolve a registered theme and track the active selection. */ +/** + * Build the `tecode.clipboard` stub (Issue #91) — `create.ts`'s fallback + * for a caller that supplies no `Clipboard` dependency at all (every test + * that predates this task). Unlike `createFileSystem`'s real backing + * (always constructed, never stubbed — `main.ts` builds one + * unconditionally), a clipboard genuinely has nothing useful to do with no + * backing buffer: `read()` always resolves `""`, and `write()` is a + * documented no-op that never throws (this module's TSDoc's never-throw + * discipline) — no extension can observe anything it "wrote" surviving + * past this stub, exactly as if no clipboard existed at all. + */ +export function createClipboardStub(): ClipboardNamespace { + return Object.freeze({ + read() { + return Promise.resolve(""); + }, + write() { + return Promise.resolve(); + }, + }); +} + export function createThemesStub(): ThemesStub { const registrations = createRegistrySet(); const baseTheme = createBaseTheme(); diff --git a/packages/core/src/clipboard/clipboard.test.ts b/packages/core/src/clipboard/clipboard.test.ts new file mode 100644 index 0000000..fe5b421 --- /dev/null +++ b/packages/core/src/clipboard/clipboard.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import { createHostLog } from "../host/errors"; +import { createClipboard } from "./clipboard"; + +describe("createClipboard (Issue #91)", () => { + test("read() starts empty", async () => { + const clipboard = createClipboard(); + expect(await clipboard.read()).toBe(""); + }); + + test("write() then read() round-trips through the internal buffer, with no system writer wired", async () => { + const clipboard = createClipboard(); + await clipboard.write("hello"); + expect(await clipboard.read()).toBe("hello"); + }); + + test("write() calls the injected system writer with the same text when sync is enabled (the default)", async () => { + const clipboard = createClipboard(); + const calls: string[] = []; + clipboard.setSystemWriter((text) => { + calls.push(text); + return true; + }); + + await clipboard.write("copied text"); + + expect(calls).toEqual(["copied text"]); + expect(await clipboard.read()).toBe("copied text"); + }); + + test("setSystemClipboardEnabled(false) updates the internal buffer but never calls the system writer", async () => { + const clipboard = createClipboard(); + let calls = 0; + clipboard.setSystemWriter(() => { + calls++; + return true; + }); + clipboard.setSystemClipboardEnabled(false); + + await clipboard.write("still buffered"); + + expect(calls).toBe(0); + expect(await clipboard.read()).toBe("still buffered"); + }); + + test("setSystemWriter(undefined) clears the writer — write() still resolves and updates the buffer", async () => { + const clipboard = createClipboard(); + clipboard.setSystemWriter(() => true); + clipboard.setSystemWriter(undefined); + + await expect(clipboard.write("x")).resolves.toBeUndefined(); + expect(await clipboard.read()).toBe("x"); + }); + + test("an OSC 52 write that THROWS is logged, swallowed, and write() still resolves with the buffer updated", async () => { + const log = createHostLog(); + const clipboard = createClipboard({ log }); + clipboard.setSystemWriter(() => { + throw new Error("terminal escape sequence rejected"); + }); + + await expect(clipboard.write("payload")).resolves.toBeUndefined(); + + expect(await clipboard.read()).toBe("payload"); + const entries = log.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]!.level).toBe("warning"); + expect(entries[0]!.error.message).toContain("terminal escape sequence rejected"); + }); + + test("an OSC 52 write that returns false (not thrown) is logged as a not-accepted warning, write() still resolves", async () => { + const log = createHostLog(); + const clipboard = createClipboard({ log }); + clipboard.setSystemWriter(() => false); + + await expect(clipboard.write("payload")).resolves.toBeUndefined(); + + expect(await clipboard.read()).toBe("payload"); + const entries = log.entries(); + expect(entries).toHaveLength(1); + expect(entries[0]!.level).toBe("warning"); + expect(entries[0]!.error.message).toContain("not accepted"); + }); + + test("with no `log` supplied, a throwing writer is still swallowed silently — write() never rejects", async () => { + const clipboard = createClipboard(); + clipboard.setSystemWriter(() => { + throw new Error("boom"); + }); + await expect(clipboard.write("x")).resolves.toBeUndefined(); + expect(await clipboard.read()).toBe("x"); + }); + + test("later write()s update the buffer, always reflecting the most recent value", async () => { + const clipboard = createClipboard(); + await clipboard.write("first"); + await clipboard.write("second"); + expect(await clipboard.read()).toBe("second"); + }); +}); diff --git a/packages/core/src/clipboard/clipboard.ts b/packages/core/src/clipboard/clipboard.ts new file mode 100644 index 0000000..e832f75 --- /dev/null +++ b/packages/core/src/clipboard/clipboard.ts @@ -0,0 +1,148 @@ +/** + * `createClipboard`: the implementation behind `tecode.clipboard` (Issue + * #91). Wraps a single in-memory buffer — a thin host-resource seam, the + * same shape `buffer/fileSystem.ts`'s `createFileSystem` wraps + * `node:fs/promises` behind — with a write-through OSC 52 sync to the + * terminal's own system clipboard, injected lazily once the real terminal + * seam (`packages/cli/src/renderShell.tsx`'s `onClipboardWriterReady`) has + * resolved one. + * + * **Why an internal buffer, not a live OSC 52 round-trip**: OSC 52 + * *reading* is not portable — many terminals never implement the query + * form at all, and those that do commonly gate it behind an interactive + * user prompt (`@tecode/api`'s `ClipboardNamespace` TSDoc explains this to + * extension authors too). `read()` below therefore only ever reports this + * buffer's own last-written value; nothing in this module ever attempts an + * OSC 52 *read*. + * + * **Never crashes the process** (matches `fileSystem.ts`'s own contract for + * `watch`): the injected OSC 52 writer is host-provided terminal-escape- + * sequence plumbing with no reliable failure signal — a terminal that + * silently ignores it looks identical, from here, to one that briefly + * failed. A writer that returns `false`, or throws outright, is reported + * through `deps.log` (when supplied) and otherwise swallowed; `write()` + * itself always resolves once the INTERNAL buffer has been updated, + * regardless of what the system-clipboard half did. + */ + +import type { ClipboardNamespace } from "@tecode/api"; +import type { HostError, HostLog } from "../host/errors"; + +/** Render a caught `unknown` value as a message string without risking a + * second throw (matches `fileSystem.ts`'s/`documentManager.ts`'s + * `describeError`). */ +function describeError(err: unknown): string { + try { + if (err instanceof Error) return err.message; + return String(err); + } catch { + return "Unknown error"; + } +} + +/** Dependencies for {@link createClipboard}. Every field is optional — + * `createClipboard()` with no arguments is a complete, working clipboard + * (internal buffer only, no system-clipboard sync until {@link + * Clipboard.setSystemWriter} injects one); `log` only adds visibility into + * OSC 52 write failures that would otherwise be silently swallowed. */ +export interface ClipboardDeps { + /** Structured log for OSC 52 write failures (design.md §14). Omitted + * (the default) swallows these silently — {@link Clipboard.write} still + * never throws or rejects either way. */ + log?: HostLog; +} + +/** + * {@link createClipboard}'s return type: the `ClipboardNamespace` extension + * code sees (`read`/`write`), plus two host-only setters `create.ts`/ + * `main.ts` use to wire this instance up to the real terminal and to the + * live `clipboard.useSystemClipboard` setting — narrowed away when + * `create.ts` assembles the frozen `tecode.clipboard` namespace extensions + * actually receive (matches `stubs.ts`'s `WindowStub`/`LanguagesStub` TSDoc + * on why a factory here returns more than its `@tecode/api` namespace + * shape). + */ +export interface Clipboard extends ClipboardNamespace { + /** + * Inject (or clear, with `undefined`) the OSC 52 write function + * (`packages/cli/src/renderShell.tsx`'s `onClipboardWriterReady`, + * bound to `@opentui/core`'s `CliRenderer.copyToClipboardOSC52`). A + * terminal-render seam that never resolves one (`renderShellHeadless`, + * or any test) leaves system-clipboard sync permanently inert — {@link + * write} still updates the internal buffer either way. + */ + setSystemWriter(write: ((text: string) => boolean) | undefined): void; + /** + * Enable or disable OSC 52 sync without touching the injected writer + * itself — the live backing for `editor-core`'s `clipboard. + * useSystemClipboard` configuration (Issue #91): `index.ts`'s + * `activate` calls this once at startup and again on every live + * `tecode.config.onDidChange` for that key. Defaults to `true` (this + * module's TSDoc), matching that setting's own schema default so a + * caller that never wires config sync at all still gets sync-when- + * possible, not silently-disabled, behavior. + */ + setSystemClipboardEnabled(enabled: boolean): void; +} + +/** + * Build a {@link Clipboard} (Issue #91). `deps.log` is optional — see + * {@link ClipboardDeps}. + */ +export function createClipboard(deps: ClipboardDeps = {}): Clipboard { + let buffer = ""; + let systemWriter: ((text: string) => boolean) | undefined; + // Matches `clipboard.useSystemClipboard`'s own `default: true` schema + // (`editor-core/manifest.ts`) — see `setSystemClipboardEnabled`'s TSDoc. + let systemClipboardEnabled = true; + + function logSafely(err: HostError): void { + if (!deps.log) return; + try { + deps.log.append("warning", err); + } catch { + // Swallowed: reporting a reporting failure has nowhere left to go + // (matches `fileSystem.ts`'s own `logSafely`). + } + } + + async function read(): Promise { + return buffer; + } + + async function write(text: string): Promise { + // The internal buffer is updated UNCONDITIONALLY, before any + // system-clipboard sync is even attempted — every caller (`read()` + // here, and `editor-core`'s own paste handler) must see the new + // value regardless of whether OSC 52 is enabled, wired, or working. + buffer = text; + + if (!systemClipboardEnabled || !systemWriter) return; + + try { + const accepted = systemWriter(text); + if (!accepted) { + logSafely({ + message: "Clipboard: OSC 52 system-clipboard write was not accepted by the terminal.", + }); + } + } catch (cause) { + // Never let an injected writer's failure propagate past this seam + // (this module's TSDoc) — logged, not rethrown, not left as a + // rejection. + logSafely({ + message: `Clipboard: OSC 52 system-clipboard write threw: ${describeError(cause)}`, + }); + } + } + + function setSystemWriter(write: ((text: string) => boolean) | undefined): void { + systemWriter = write; + } + + function setSystemClipboardEnabled(enabled: boolean): void { + systemClipboardEnabled = enabled; + } + + return { read, write, setSystemWriter, setSystemClipboardEnabled }; +} diff --git a/packages/core/src/clipboard/index.ts b/packages/core/src/clipboard/index.ts new file mode 100644 index 0000000..def89c1 --- /dev/null +++ b/packages/core/src/clipboard/index.ts @@ -0,0 +1,4 @@ +// The clipboard domain (Issue #91): an internal buffer with write-through +// OSC 52 sync to the terminal's system clipboard (`clipboard.ts`), backing +// `tecode.clipboard` and `editor-core`'s copy/cut/paste commands. +export { createClipboard, type Clipboard, type ClipboardDeps } from "./clipboard"; diff --git a/packages/core/src/editor/inputRouter.test.ts b/packages/core/src/editor/inputRouter.test.ts index e7a3be3..22bcc24 100644 --- a/packages/core/src/editor/inputRouter.test.ts +++ b/packages/core/src/editor/inputRouter.test.ts @@ -257,3 +257,139 @@ describe("createEditorInputRouter (Task 2.2, Req 4.6, 6.6, design.md §6.1, §8. expect(document.undo()).toBeUndefined(); }); }); + +describe("EditorInputRouter.insertText (Issue #91's paste path, Req 6.6)", () => { + test("no-op when editorTextFocus is falsy", () => { + const document = createTestDocument("hello"); + const session = createFakeSession(document, [cursorAt(0, 0)]); + const context = createContextService(); // editorTextFocus never set + const router = buildRouter({ context, editorSession: session }); + + router.insertText("X"); + expect(document.getLine(0)).toBe("hello"); + expect(session.setStateCallCount()).toBe(0); + }); + + test("no-op when there is no active document", () => { + const session = createFakeSession(undefined, [cursorAt(0, 0)]); + const router = buildRouter({ editorSession: session }); + expect(() => router.insertText("X")).not.toThrow(); + expect(session.setStateCallCount()).toBe(0); + }); + + test("no-op on a readonly document", () => { + const document = createTestDocument("hello", { readonly: true }); + const session = createFakeSession(document, [cursorAt(0, 0)]); + const router = buildRouter({ editorSession: session }); + + router.insertText("X"); + expect(document.getLine(0)).toBe("hello"); + }); + + test("a single cursor: multi-line text lands as ONE document.applyEdits call, not one per line", () => { + // The mutation this test is built to catch: an implementation that + // loops `document.applyEdits(...)` once per line of the pasted text + // (instead of building one `TextEdit[]` batch and calling `applyEdits` + // exactly once) would still leave the BUFFER content correct — the + // assertions below on `getLine` alone would not catch it — but would + // call through `applyEdits` more than once. Wrapping the real + // `CoreDocument.applyEdits` to count invocations is what actually + // proves the "one call" contract (this module's `EditorInputRouter. + // insertText` TSDoc, Req 6.6). + const document = createTestDocument("ac"); + let applyEditsCallCount = 0; + const originalApplyEdits = document.applyEdits.bind(document); + document.applyEdits = (edits, opts) => { + applyEditsCallCount++; + originalApplyEdits(edits, opts); + }; + const session = createFakeSession(document, [cursorAt(0, 1)]); + const router = buildRouter({ editorSession: session }); + + router.insertText("line1\nline2\nline3"); + + expect(applyEditsCallCount).toBe(1); + expect(document.getLine(0)).toBe("aline1"); + expect(document.getLine(1)).toBe("line2"); + expect(document.getLine(2)).toBe("line3c"); + expect(session.currentSelections()).toEqual([cursorAt(2, 5)]); + }); + + test("a multi-line paste across multiple cursors is a SINGLE undo entry", () => { + const document = createTestDocument("a\nc"); + const original = [cursorAt(0, 1), cursorAt(1, 1)]; + const session = createFakeSession(document, original); + const router = buildRouter({ editorSession: session }); + + router.insertText("X\nY"); + // Original text "a\nc" with "X\nY" inserted after 'a' (offset 1) AND + // after 'c' (offset 3, the buffer's end) — both in ONE batch, computed + // against the ORIGINAL (pre-batch) coordinates: "aX\nY\ncX\nY". + expect(document.getLine(0)).toBe("aX"); + expect(document.getLine(1)).toBe("Y"); + expect(document.getLine(2)).toBe("cX"); + expect(document.getLine(3)).toBe("Y"); + + const restoredSelections = document.undo(); + expect(document.getLine(0)).toBe("a"); + expect(document.getLine(1)).toBe("c"); + expect(restoredSelections).toEqual(original); + + // A second undo has nothing left to do — the whole multi-cursor, + // multi-line paste really was ONE undo entry, not one per cursor/line. + expect(document.undo()).toBeUndefined(); + }); + + test("replaces a non-collapsed FORWARD selection and lands the cursor after the inserted text", () => { + const document = createTestDocument("abcdef"); + const start = pos(0, 1); + const end = pos(0, 4); + const selection: Selection = { start, end, anchor: start, active: end }; + const session = createFakeSession(document, [selection]); + const router = buildRouter({ editorSession: session }); + + router.insertText("XY"); + expect(document.getLine(0)).toBe("aXYef"); + expect(session.currentSelections()).toEqual([cursorAt(0, 3)]); + }); + + test("replaces a non-collapsed BACKWARD selection (active at the start) and still lands after the inserted text", () => { + const document = createTestDocument("abcdef"); + const start = pos(0, 1); + const end = pos(0, 4); + // Backward selection: anchor at the far end, active at the near end — + // `range.start`/`range.end` are still `[1, 4)` (Selection extends + // Range), but `active` is `start`, not `end`. This is exactly the case + // `buildInsertTextBatch`'s TSDoc explains: tracking `active` directly + // would land the cursor at the WRONG end of the inserted text. + const selection: Selection = { start, end, anchor: end, active: start }; + const session = createFakeSession(document, [selection]); + const router = buildRouter({ editorSession: session }); + + router.insertText("XY"); + expect(document.getLine(0)).toBe("aXYef"); + expect(session.currentSelections()).toEqual([cursorAt(0, 3)]); + }); + + test("two cursors on the same line both insert and both advance correctly", () => { + const document = createTestDocument("abcdef"); + const session = createFakeSession(document, [cursorAt(0, 2), cursorAt(0, 5)]); + const router = buildRouter({ editorSession: session }); + + router.insertText("Z"); + expect(document.getLine(0)).toBe("abZcdeZf"); + expect(session.currentSelections()).toEqual([cursorAt(0, 3), cursorAt(0, 7)]); + }); + + test("bypasses the single-code-point restriction: a multi-character sequence is not rejected", () => { + // `routeKeyEvent`'s own `classifyKeyEvent`/`isPrintableSequence` would + // reject any `sequence` longer than one code point outright — proving + // `insertText` never goes through that path at all. + const document = createTestDocument(""); + const session = createFakeSession(document, [cursorAt(0, 0)]); + const router = buildRouter({ editorSession: session }); + + router.insertText("hello world"); + expect(document.getLine(0)).toBe("hello world"); + }); +}); diff --git a/packages/core/src/editor/inputRouter.ts b/packages/core/src/editor/inputRouter.ts index 5fcc2ab..08d2b21 100644 --- a/packages/core/src/editor/inputRouter.ts +++ b/packages/core/src/editor/inputRouter.ts @@ -203,6 +203,67 @@ function buildEditBatch( return { edits, newSelections }; } +/** Build the single `TextEdit` a call to {@link createEditorInputRouter}'s + * `insertText` produces for one selection (Issue #91's paste path): replace + * the selection's whole range (a collapsed cursor's zero-width `[active, + * active)`, or a real selection's `[start, end)`) with `text` wholesale — + * deliberately the SAME "insert, replacing any active selection" shape + * `editor-core`'s `editing.ts` uses for `buildInsertEdit` (Tab/Enter), just + * duplicated here rather than imported: `editor-core` is a `builtin` + * extension and this module lives in `@tecode/core` — the ESLint layering + * rule (`no-restricted-imports`) only allows the reverse direction. Unlike + * {@link buildEditForCursor}'s `"insert"` case (a single code point at a + * collapsed cursor, `classifyKeyEvent`'s domain), `text` here is arbitrary — + * one or many lines, from a paste — which is exactly why `insertText` is a + * SEPARATE public method rather than a new `EditOp` value: routing it + * through `classifyKeyEvent`/`isPrintableSequence` would reject anything + * longer than one code point outright (this module's TSDoc's "Scope"). */ +function buildInsertTextEdit(selection: Selection, text: string): TextEdit { + return { range: { start: selection.start, end: selection.end }, newText: text }; +} + +/** + * Build the full multi-cursor batch {@link createEditorInputRouter}'s + * `insertText` applies for one paste (Issue #91, Req 6.6's "multi-cursor + * batching" — same one-`applyEdits`-call contract this module's TSDoc + * states for a keystroke): dedupe cursors sharing one `active` point + * (matching {@link buildEditBatch}'s own first step), build one + * {@link buildInsertTextEdit} per surviving selection, drop any that + * overlaps one already kept, and compute each selection's resulting + * collapsed cursor. + * + * **Tracks each selection's OWN edit's `range.end`, not its `active`** — + * unlike this module's private `buildEditBatch` above (whose selections are + * always collapsed pre-Task-2.3, so `active` and `range.end` are always the + * same point, making the distinction moot there): a PASTE can replace a + * genuine, possibly-BACKWARD selection, whose `active` can be the far + * (leftward/upward) end of `range` rather than `range.end` — tracking + * `active` directly would land the post-paste cursor at the wrong end of a + * backward selection's now-inserted text. `editor-core`'s `editing.ts`'s + * real `buildEditBatch` (Task 2.3) already solves this identical problem + * the identical way; this is that same "own edit's `range.end`, else the + * original `active`, run through `transformPosition`" shape, independently + * implemented here since `editing.ts` cannot be imported (this function's + * sibling {@link buildInsertTextEdit} TSDoc's layering note). + */ +function buildInsertTextBatch( + selections: readonly Selection[], + text: string, +): { edits: TextEdit[]; newSelections: Selection[] } { + const deduped = dedupeByActive(selections); + const rawEdits = deduped.map((selection) => buildInsertTextEdit(selection, text)); + const edits = dropOverlapping(rawEdits); + const survivingSet = new Set(edits); + + const newPositions = deduped.map((selection, i) => { + const own = rawEdits[i]!; + const trackPoint = survivingSet.has(own) ? own.range.end : selection.active; + return transformPosition(trackPoint, edits); + }); + + return { edits, newSelections: toMergedSelections(newPositions) }; +} + /** Dependencies for {@link createEditorInputRouter}. Narrowed to `Pick`s of * the real services (matching `keymap/chords.ts`'s `ChordStateMachineDeps` * pattern) so tests can inject minimal fakes. */ @@ -232,6 +293,28 @@ export interface EditorInputRouter { * `chords.ts`'s/`bindingTable.ts`'s own guarded-boundary discipline). */ routeKeyEvent(event: KeyEventLike): boolean; + /** + * Insert `text` at every cursor, replacing each selection's range if it + * has one (Issue #91's paste path, design.md §6.1/§8.3's "focused + * component" destination extended to bracketed-paste terminal input, not + * just single-keystroke fallthrough). ALWAYS applied as exactly one + * `document.applyEdits(...)` call across every selection ({@link + * buildInsertTextBatch}'s TSDoc, Req 6.6) — never one call per line or + * per cursor — so a multi-line paste is a single undo step, matching + * `routeKeyEvent`'s own one-`applyEdits`-per-invocation contract. + * Deliberately bypasses `classifyKeyEvent`/`isPrintableSequence`'s + * single-code-point restriction entirely: `text` is not run through + * either at all, so an arbitrary-length (and multi-line) paste is never + * rejected the way a `KeyEventLike` with a multi-character `sequence` + * would be. + * + * No-ops exactly like `routeKeyEvent` does (same guards, same order): no + * `editorTextFocus`, no active document, or a readonly document (Req + * 5.5) — `applyEdits` is skipped entirely and `text` never reaches the + * buffer. Never throws (this interface's own "guarded boundary" + * discipline, matching `routeKeyEvent`). + */ + insertText(text: string): void; } /** Build an {@link EditorInputRouter} (Task 2.2). */ @@ -276,5 +359,40 @@ export function createEditorInputRouter(deps: EditorInputRouterDeps): EditorInpu } } - return { routeKeyEvent }; + /** {@link EditorInputRouter.insertText} — see that TSDoc for the + * contract. Same guard order/shape as {@link routeKeyEvent} above, minus + * the `classifyKeyEvent` step it deliberately bypasses. */ + function insertText(text: string): void { + try { + if (!context.get("editorTextFocus")) return; + + const document = editorSession.getActiveDocument(); + if (!document) return; + + // Same read-only guard as `routeKeyEvent` (Req 5.5) — no cursor + // movement either, for the same "must not desync from an edit + // `applyEdits` would silently drop" reason. + if (document.readonly) return; + + const state = editorSession.getState(document.uri); + const { edits, newSelections } = buildInsertTextBatch(state.selections, text); + + if (edits.length > 0) { + // ONE `applyEdits` call for the whole batch (this method's TSDoc, + // Req 6.6) — every selection's replacement is one `TextEdit` in + // `edits`, so a multi-line/multi-cursor paste is exactly one + // atomic buffer mutation and one `UndoStack` entry, never a loop + // that calls `applyEdits` once per line or per cursor. + document.applyEdits(edits, { + selectionsBefore: state.selections, + selectionsAfter: newSelections, + }); + } + editorSession.setState(document.uri, { ...state, selections: newSelections }); + } catch { + // Never throw past this seam — see `routeKeyEvent`'s own catch. + } + } + + return { routeKeyEvent, insertText }; } diff --git a/packages/core/src/editor/positionTransform.test.ts b/packages/core/src/editor/positionTransform.test.ts index 1eb2788..b8de7b2 100644 --- a/packages/core/src/editor/positionTransform.test.ts +++ b/packages/core/src/editor/positionTransform.test.ts @@ -95,6 +95,46 @@ describe("transformPosition (Task 2.2, editor/positionTransform.ts)", () => { expect(transformPosition(pos(3, 3), [])).toEqual(pos(3, 3)); }); + test("Issue #91: a multi-line insert's own cursor lands on the tail line, at the tail line's length — not the original column plus the tail length", () => { + // A collapsed insert of multi-line text (a paste) at (0,1): before this + // fix, this function unconditionally added `range.start.character` + // (here, 1) to the tail line's length, landing one character too far + // right whenever the edit's own start column was not 0 — this is that + // exact regression, pinned (`editor/inputRouter.ts`'s `insertText`, + // `positionTransform.ts`'s TSDoc "Issue #91" note). + const result = transformPosition(pos(0, 1), [edit(0, 1, 0, 1, "line1\nline2\nline3")]); + expect(result).toEqual(pos(2, 5)); // "line3".length, not 1 + "line3".length + }); + + test("Issue #91: a multi-line insert earlier on the same line RESETS a later position's character, not merely shifts it", () => { + const result = transformPosition(pos(0, 10), [edit(0, 2, 0, 2, "AA\nBBB")]); + // Line: +1 (one inserted newline). Character: the target position was + // 8 characters into the "remainder" after the edit's original end + // (10 - 2); that remainder now follows "BBB" on the new tail line — + // 3 + 8 = 11 — NOT `range.start.character(2) + "BBB".length(3) + + // (10-2)` or any formula that keeps referencing the ORIGINAL line's + // column space past the multi-line edit. + expect(result).toEqual(pos(1, 11)); + }); + + test("Issue #91: two same-line preceding edits, one single-line and one multi-line — the CLOSER multi-line edit resets the column, the further single-line edit no longer contributes", () => { + // Two independent cursors' own edits on the same original line: one at + // column 2 inserting a single character "X" (no newline), another at + // column 5 inserting the two-line "AA\nBB" — both strictly before the + // target position (10), closer-to-farther: column 5, then column 2. + const edits = [edit(0, 2, 0, 2, "X"), edit(0, 5, 0, 5, "AA\nBB")]; + const forward = transformPosition(pos(0, 10), edits); + const reversed = transformPosition(pos(0, 10), [...edits].reverse()); + // Line: +1 (only the column-5 edit inserted a newline). Character: + // walked closest-first, the column-5 multi-line edit is hit FIRST and + // resets the column ("BB".length(2) + the 5 characters of original + // remainder past column 5 = 7); the column-2 single-character edit, + // though also "preceding", no longer affects character at all once a + // closer multi-line edit has already reset the origin. + expect(forward).toEqual(pos(1, 7)); + expect(reversed).toEqual(forward); + }); + test("a clamped position still shifts by a preceding edit, regardless of edit order", () => { // Position (0,6) sits strictly inside the replacement (0,4)-(0,10), so // it clamps to that edit's start (0,4) — which the insert of "x" at diff --git a/packages/core/src/editor/positionTransform.ts b/packages/core/src/editor/positionTransform.ts index ad6b5fc..f5ae5c2 100644 --- a/packages/core/src/editor/positionTransform.ts +++ b/packages/core/src/editor/positionTransform.ts @@ -90,31 +90,67 @@ export function transformPosition(position: Position, edits: readonly TextEdit[] ); const anchor = containingEdit ? containingEdit.range.start : position; let line = anchor.line; - let character = anchor.character; - for (const edit of edits) { - if (edit === containingEdit) continue; // contributes nothing beyond the clamp - const { range, newText } = edit; - const insertedLines = splitIntoLines(newText); - const insertedLineCount = insertedLines.length - 1; + // Every edit "before" the anchor (bucket 1 above), regardless of `edits`' + // own order — `line` is a pure sum, exactly as this module's TSDoc + // describes: each preceding edit contributes its own `netLineDelta` + // independently of every other one. + const preceding = edits.filter( + (edit) => edit !== containingEdit && comparePositions(anchor, edit.range.end) >= 0, + ); + for (const { range, newText } of preceding) { + const insertedLineCount = splitIntoLines(newText).length - 1; const removedLineCount = range.end.line - range.start.line; - const netLineDelta = insertedLineCount - removedLineCount; + line += insertedLineCount - removedLineCount; + } - if (comparePositions(anchor, range.end) >= 0) { - // Bucket 1 (this module's TSDoc): the anchor is at-or-after this - // edit's end, in ORIGINAL coordinates. - if (anchor.line === range.end.line) { - const lastInsertedLineLength = insertedLines[insertedLines.length - 1]!.length; - const newEndCharacter = - range.start.character + (insertedLineCount === 0 ? newText.length : lastInsertedLineLength); - character = character - range.end.character + newEndCharacter; - } - line += netLineDelta; + // `character`, unlike `line`, is NOT a pure sum once more than one + // preceding edit shares the anchor's ORIGINAL line — Issue #91's + // `EditorInputRouter.insertText` is this codebase's first caller to ever + // hand `transformPosition` a genuinely multi-line `newText` (every edit + // shape `routeKeyEvent`'s own `buildEditBatch` builds — a single typed + // character, or an always-`newText: ""` backspace/delete — is single-line, + // so this branch was previously unexercised here). A SINGLE-line preceding + // edit's character contribution is a fixed, order-independent delta + // (`newEndCharacter - range.end.character`, added on top of whatever + // `character` already is) — that's what makes the reassignment loop below + // correct regardless of processing order for that case, matching this + // module's own TSDoc reasoning. A MULTI-line preceding edit is different: + // it does not shift the anchor's column, it RESETS it — the anchor's line + // no longer starts at any original column at all once at least one + // newline has been inserted before it, it starts fresh at whatever column + // that edit's OWN last inserted line ends at. So same-line preceding + // edits must be walked CLOSEST-TO-THE-ANCHOR FIRST (descending + // `range.end`): every single-line edit encountered before the first + // multi-line one accumulates the usual per-edit delta, and the moment a + // multi-line edit is reached, `character` is reset to that edit's own + // tail-line length and the walk stops — any edit further left (closer to + // the original line's start) no longer affects `character` at all (it + // already contributed to `line`, in the sum above, since its own inserted + // newlines happened before this position either way). This is + // `@tecode/builtin/editor-core`'s `positionTransform.ts` — a module that + // cannot import this one (the `builtin`/`core` ESLint layering rule) and + // so carries its own copy — documents as a deliberate fix over an + // earlier version of THIS function; ported back here now that this + // module has its own multi-line caller. When every same-line preceding + // edit is single-line (every case exercised before this task), this + // reduces to exactly the previous per-edit-in-any-order accumulation, + // since plain addition commutes — `positionTransform.test.ts`'s existing + // cases are unchanged by this rewrite. + let character = anchor.character; + const sameLine = preceding + .filter((edit) => edit.range.end.line === anchor.line) + .sort((a, b) => comparePositions(b.range.end, a.range.end)); + for (const { range, newText } of sameLine) { + const insertedLines = splitIntoLines(newText); + const insertedLineCount = insertedLines.length - 1; + if (insertedLineCount === 0) { + character = character - range.end.character + range.start.character + newText.length; + continue; } - - // Bucket 2: this edit starts at-or-after the anchor — unaffected. - // (Non-overlap guarantees no OTHER edit can strictly contain the - // anchor once the containing edit has been factored out.) + const lastInsertedLineLength = insertedLines[insertedLines.length - 1]!.length; + character = character - range.end.character + lastInsertedLineLength; + break; } return { line, character }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7fc61df..45ff637 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -327,6 +327,7 @@ export { } from "./config/index"; export { createBaseTheme, + createClipboardStub, createEditorStub, createFindStub, createLanguagesStub, @@ -350,3 +351,4 @@ export { type EditorInputRouterDeps, type LineReader, } from "./editor/index"; +export { createClipboard, type Clipboard, type ClipboardDeps } from "./clipboard/index"; diff --git a/samples/settings.json b/samples/settings.json index 81f8a2b..096fe0c 100644 --- a/samples/settings.json +++ b/samples/settings.json @@ -42,7 +42,15 @@ // Show hidden (dot-prefixed) and .gitignore-ignored files in the // explorer sidebar. - "explorer.showHidden": false + "explorer.showHidden": false, + + // --- editor-core (packages/builtin/editor-core/manifest.ts) --- + + // Sync copy/cut to the terminal's system clipboard via OSC 52, when the + // terminal supports it (Issue #91). ctrl+x/ctrl+v are bound to cut/paste; + // copy has no default keybinding (still reachable via the command + // palette) — see editor-core/manifest.ts's TSDoc for why. + "clipboard.useSystemClipboard": true // Req 9.5 also names "editor.wordWrap" and "files.autoSave" among the // MVP settings. As of this release, NEITHER is implemented: no From 35270dbd98e4046c095b90d074b1c648ece457c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:05:57 +0000 Subject: [PATCH 2/2] Declare clipboard on the virtual tecode module, keep a pasted BOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both confirmed by reverting the fix and watching the failure appear. The `declare module "tecode"` block listed nine namespaces and not the new `clipboard`. The runtime projection does not consult that file, so `import { clipboard } from "tecode"` worked but failed to type-check in an extension — and no test in this repo would have caught it. Reverting the line reproduces `TS2305: Module '"tecode"' has no exported member 'clipboard'`. Added a note that every namespace on the frozen object must appear there, since the symptom is invisible from inside this repo. Paste decoded with a default `new TextDecoder()`, whose `ignoreBOM: false` treats a leading U+FEFF as an encoding marker and silently drops it — so pasting a BOM-prefixed payload inserted one character fewer than was pasted. `ignoreBOM: true` keeps it. Only a leading BOM was affected; one mid-payload already survived. `renderShellToTerminal` opens a real TTY that `bun test` cannot provide, so the decode is now a small exported function, which is what makes the behaviour assertable at all. Its test pins both the fixed output and what the default decoder would have produced, naming which behaviour is guarded. Not changed: review also reported `samples/settings.json` as unparseable from line 45. That is Biome reading a JSONC file as strict JSON — it reports the same failure from line 1 against main, where the file is untouched by this branch, and the repo's own parser reads it with `clipboard.useSystemClipboard` as a root key. Biome is not in this project's toolchain. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- packages/cli/src/renderShell.test.ts | 18 +++++++++++++++- packages/cli/src/renderShell.tsx | 27 ++++++++++++++++++++++-- packages/core/src/api/tecode-module.d.ts | 10 ++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/renderShell.test.ts b/packages/cli/src/renderShell.test.ts index aa8f02e..d929a00 100644 --- a/packages/cli/src/renderShell.test.ts +++ b/packages/cli/src/renderShell.test.ts @@ -8,7 +8,7 @@ import { createCommandRegistry, } from "@tecode/core"; import { createBaseTheme } from "@tecode/core"; -import { renderShellHeadless } from "./renderShell"; +import { decodePastedBytes, renderShellHeadless } from "./renderShell"; // renderShellToTerminal is intentionally NOT exercised here: it opens a // real @opentui/core CliRenderer/TTY, which bun test's sandboxed, non-TTY @@ -39,3 +39,19 @@ test("renderShellHeadless resolves without touching a real terminal", async () = await expect(renderShellHeadless(deps)).resolves.toBeUndefined(); }); + +// Issue #91 regression: a DEFAULT `new TextDecoder()` has `ignoreBOM: +// false`, which silently drops a leading U+FEFF — so pasting a BOM-prefixed +// payload used to insert one character fewer than was pasted. +test("decodePastedBytes keeps a leading BOM instead of silently dropping it", () => { + const withBom = new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69]); // U+FEFF + "hi" + expect(decodePastedBytes(withBom)).toBe("hi"); + // Sanity: this is exactly what the default decoder would have produced, + // pinning WHICH behaviour is being guarded against. + expect(new TextDecoder().decode(withBom)).toBe("hi"); +}); + +test("decodePastedBytes leaves ordinary multi-line UTF-8 payloads untouched", () => { + const bytes = new TextEncoder().encode("héllo\nwörld\n日本語"); + expect(decodePastedBytes(bytes)).toBe("héllo\nwörld\n日本語"); +}); diff --git a/packages/cli/src/renderShell.tsx b/packages/cli/src/renderShell.tsx index d969b78..957e725 100644 --- a/packages/cli/src/renderShell.tsx +++ b/packages/cli/src/renderShell.tsx @@ -217,6 +217,30 @@ export interface ShellRenderDeps { * one call site in `main.ts` — an implementation that throws still leaves * `runTecode`'s own never-throwing startup contract to that call site, not * to this type. */ +/** + * Decode a bracketed-paste payload (`PasteEvent.bytes`) into the string + * handed to {@link ShellRenderDeps.onPaste} — and, through it, straight + * into `EditorInputRouter.insertText` (Issue #91). + * + * `ignoreBOM: true` is load-bearing, and is the opposite of what the name + * suggests: per the WHATWG Encoding Standard a DEFAULT `new TextDecoder()` + * has `ignoreBOM: false`, which makes it treat a leading U+FEFF as an + * encoding marker and SILENTLY DROP it; `true` makes it treat that U+FEFF + * as ordinary text and keep it. Paste has to insert exactly the characters + * the user pasted — a round trip that copies a BOM-prefixed line and pastes + * it back must not quietly lose a character. Only a LEADING BOM is affected + * either way: one in the middle of the payload already survives the default + * decoder. + * + * Exported for its own test: {@link renderShellToTerminal}, its only + * caller, opens a real `CliRenderer`/TTY that `bun test` cannot provide + * (see this module's TSDoc), so this seam is where the behaviour is + * assertable at all. + */ +export function decodePastedBytes(bytes: Uint8Array): string { + return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); +} + export type RenderShell = (deps: ShellRenderDeps) => Promise; /** @@ -290,9 +314,8 @@ export const renderShellToTerminal: RenderShell = async (deps) => { // `chordMachine && editorInputRouter` pairing above already uses. if (deps.onPaste) { const onPaste = deps.onPaste; - const pasteDecoder = new TextDecoder(); renderer.keyInput.on("paste", (event) => { - onPaste(pasteDecoder.decode(event.bytes)); + onPaste(decodePastedBytes(event.bytes)); }); } diff --git a/packages/core/src/api/tecode-module.d.ts b/packages/core/src/api/tecode-module.d.ts index 653c895..314b7b0 100644 --- a/packages/core/src/api/tecode-module.d.ts +++ b/packages/core/src/api/tecode-module.d.ts @@ -8,7 +8,13 @@ * "object"` actually does at runtime (project the registered object's own * enumerable properties onto the module's named exports — `alias.ts` * registers the `Tecode` object itself, whose own properties are these - * nine namespaces). + * ten namespaces). + * + * Every namespace `createTecodeApi` puts on the frozen `Tecode` object + * MUST appear below. A namespace missing here still works at runtime — the + * `loader: "object"` projection does not consult this file — so the only + * symptom is that `import { } from "tecode"` fails to type-check in + * an extension, which no test in this repo would catch. * * **Why this file, here, is enough**: this repo's root `tsconfig.json` sets * no `"include"`, so a single `bunx tsc --noEmit` run from the repo root @@ -32,6 +38,7 @@ declare module "tecode" { import type { + ClipboardNamespace, CommandsNamespace, ConfigNamespace, ContextNamespace, @@ -43,6 +50,7 @@ declare module "tecode" { WorkspaceNamespace, } from "@tecode/api"; + export const clipboard: ClipboardNamespace; export const commands: CommandsNamespace; export const workspace: WorkspaceNamespace; export const window: WindowNamespace;