From 99ef6b6686541e6d10a467c6cc82336beec52799 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 12:36:00 -0400 Subject: [PATCH 01/15] Optimize Vim adapter cursor mapping and edits Cache display offsets and line boundaries, reuse grapheme encoding, and avoid redundant cursor updates and full-buffer edit allocations. Add Unicode regression coverage and larger-buffer benchmarks. --- src/modules/vim/edit.test.ts | 14 ++++++ src/modules/vim/edit.ts | 48 +++++++++++++++----- src/modules/vim/graphemes.ts | 30 ++++++------ src/modules/vim/map.test.ts | 39 +++++++++++++++- src/modules/vim/map.ts | 57 +++++++++++++++++------ src/modules/vim/vimee.ts | 88 +++++++++++++++--------------------- test/benchmark.ts | 34 +++++++++----- test/editor.test.ts | 20 ++++++++ 8 files changed, 226 insertions(+), 104 deletions(-) diff --git a/src/modules/vim/edit.test.ts b/src/modules/vim/edit.test.ts index 36fe468..30e6a7f 100644 --- a/src/modules/vim/edit.test.ts +++ b/src/modules/vim/edit.test.ts @@ -25,6 +25,20 @@ describe("editInput", () => { expect(fixture.calls).toEqual([["selection", 1, 3], ["insert", "x"]]) }) + + for (const [before, after, start, end, inserted] of [ + ["a👩‍💻b", "a👩‍🔬b", 1, 3, "👩‍🔬"], + ["a\u0301x", "a\u0300x", 0, 1, "a\u0300"], + ["ax", "a\u0301x", 0, 1, "a\u0301"], + ["a\u0301x", "ax", 0, 1, "a"], + ["a🇦🇧🇨🇩z", "a🇽🇦🇧🇨🇩z", 1, 5, "🇽🇦🇧🇨🇩"], + ] as const) { + test(`expands shared text to whole graphemes: ${before} → ${after}`, () => { + const fixture = createFixture(before) + editInput(fixture.input, after) + expect(fixture.calls).toEqual([["selection", start, end], ["insert", inserted]]) + }) + } }) function createFixture(plainText: string) { diff --git a/src/modules/vim/edit.ts b/src/modules/vim/edit.ts index 42e9aac..df6c1e7 100644 --- a/src/modules/vim/edit.ts +++ b/src/modules/vim/edit.ts @@ -11,23 +11,47 @@ type Input = { } export function editInput(input: Input, value: string) { - if (input.plainText === value) return + const before = input.plainText + if (before === value) return input.clearSelection() - const before = [...graphemes.segment(input.plainText)] - const after = [...graphemes.segment(value)] - let prefix = 0 - while (prefix < before.length && prefix < after.length && before[prefix].segment === after[prefix].segment) prefix++ + // Find the changed span without allocating grapheme objects for both buffers. + let start = 0 + while (start < before.length && start < value.length && before[start] === value[start]) start++ + const beforeSegments = graphemes.segment(before) + const afterSegments = graphemes.segment(value) + start = Math.min(segmentAt(before, beforeSegments, start)?.index ?? start, segmentAt(value, afterSegments, start)?.index ?? start) - let suffix = 0 - while (suffix + prefix < before.length && suffix + prefix < after.length && before[before.length - suffix - 1].segment === after[after.length - suffix - 1].segment) suffix++ + let end = before.length + let valueEnd = value.length + while (end > start && valueEnd > start && before[end - 1] === value[valueEnd - 1]) { + end-- + valueEnd-- + } - const start = before[prefix]?.index ?? input.plainText.length - const end = suffix ? before[before.length - suffix].index : input.plainText.length - const valueEnd = suffix ? after[after.length - suffix].index : value.length - const startOffset = charToDisplay(input.plainText, start) + // Expand the ends together until neither cuts a grapheme in the shared suffix. + while (true) { + const left = segmentAt(before, beforeSegments, end) + const right = segmentAt(value, afterSegments, valueEnd) + const leftExtra = left && left.index < end ? left.index + left.segment.length - end : 0 + const rightExtra = right && right.index < valueEnd ? right.index + right.segment.length - valueEnd : 0 + const extra = Math.max(leftExtra, rightExtra) + if (!extra) break + end += extra + valueEnd += extra + } + + const startOffset = charToDisplay(before, start) if (start === end) input.cursorOffset = startOffset - else input.setSelection(startOffset, charToDisplay(input.plainText, end)) + else input.setSelection(startOffset, startOffset + charToDisplay(before.slice(start, end), end - start)) input.insertText(value.slice(start, valueEnd)) } + +function segmentAt(text: string, segments: Intl.Segments, index: number) { + // Bun's containing() can include the previous grapheme at a high surrogate. + // Query its low surrogate instead, which is inside the same grapheme. + const point = text.codePointAt(index) + if (point !== undefined && point > 0xffff) index++ + return segments.containing(index) +} diff --git a/src/modules/vim/graphemes.ts b/src/modules/vim/graphemes.ts index 0fcb373..79e2843 100644 --- a/src/modules/vim/graphemes.ts +++ b/src/modules/vim/graphemes.ts @@ -7,23 +7,27 @@ export function createGraphemeCodec() { const encoded = new Map() const decoded = new Map() let next = 0xe000 + + // The caller has already segmented this value; do not segment it again. + function encodeGrapheme(segment: string) { + const code = segment.charCodeAt(0) + if (segment.length === 1 && (code < 0xe000 || code > 0xf8ff)) return segment + let token = encoded.get(segment) + if (!token) { + if (next > 0xf8ff) throw new Error("Vim grapheme dictionary is full") + token = String.fromCharCode(next++) + encoded.set(segment, token) + decoded.set(token, segment) + } + return token + } + return { + encodeGrapheme, encode(text: string) { let result = "" for (const { segment } of segments.segment(text)) { - const code = segment.charCodeAt(0) - if (segment.length === 1 && (code < 0xe000 || code > 0xf8ff)) { - result += segment - continue - } - let token = encoded.get(segment) - if (!token) { - if (next > 0xf8ff) throw new Error("Vim grapheme dictionary is full") - token = String.fromCharCode(next++) - encoded.set(segment, token) - decoded.set(token, segment) - } - result += token + result += encodeGrapheme(segment) } return result }, diff --git a/src/modules/vim/map.test.ts b/src/modules/vim/map.test.ts index ed7a1c9..147719e 100644 --- a/src/modules/vim/map.test.ts +++ b/src/modules/vim/map.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { charToDisplay, displayToChar, displayWidth } from "./map" +import { createGraphemeCodec } from "./graphemes" +import { charToDisplay, createPromptMap, displayToChar, displayWidth, hostCharOffset, hostFromVimOffset, hostOffset, hostPosition, vimLineLength } from "./map" describe("vim display offsets", () => { test("maps ASCII and CJK offsets", () => { @@ -43,3 +44,39 @@ describe("vim display offsets", () => { expect(charToDisplay(text, 2)).toBe(3) }) }) + +describe("cached Vim positions", () => { + for (const text of ["", "\n\n", "a中b\n👩‍💻e\u0301\tz\n", "\u200b\ue000👨‍👩‍👧‍👦\n\u0301x\u200b"]) { + test(`matches display conversions at every boundary: ${JSON.stringify(text)}`, () => { + const codec = createGraphemeCodec() + const map = createPromptMap(text, codec) + expect(map.displayWidth).toBe(displayWidth(text)) + + for (let offset = -1; offset <= map.displayWidth + 1; offset++) { + const charIndex = displayToChar(text, offset) + expect(hostCharOffset(map, offset)).toBe(charIndex) + const lines = codec.encode(text.slice(0, charIndex)).split("\n") + const position = { line: lines.length - 1, col: lines[lines.length - 1].length } + expect(hostPosition(map, offset)).toEqual(position) + expect(hostOffset(map, position)).toBe(charToDisplay(text, charIndex)) + } + for (let index = 0; index <= text.length; index++) { + expect(hostFromVimOffset(map, map.hostToVim[index])).toBe(charToDisplay(text, index)) + } + }) + } + + test("keeps empty lines and clamps positions to logical line boundaries", () => { + const map = createPromptMap("\n中e\u0301\n👩‍💻\n") + const lengths = [0, 2, 1, 0] + const starts = [0, 1, 5, 8] + const ends = [0, 4, 7, 8] + for (let line = 0; line < lengths.length; line++) { + expect(vimLineLength(map, line)).toBe(lengths[line]) + expect(hostOffset(map, { line, col: -1 })).toBe(starts[line]) + expect(hostOffset(map, { line, col: 100 })).toBe(ends[line]) + } + expect(hostOffset(map, { line: -1, col: 0 })).toBe(0) + expect(hostOffset(map, { line: 100, col: 0 })).toBe(8) + }) +}) diff --git a/src/modules/vim/map.ts b/src/modules/vim/map.ts index 9ea30a0..8112935 100644 --- a/src/modules/vim/map.ts +++ b/src/modules/vim/map.ts @@ -39,50 +39,77 @@ export type PromptMap = { vimText: string hostToVim: number[] vimToHost: number[] + vimToDisplay: number[] + lineStarts: number[] + displayWidth: number } export function createPromptMap(hostText: string, codec = createGraphemeCodec()): PromptMap { const hostToVim: number[] = [] const vimToHost: number[] = [] + const vimToDisplay: number[] = [] + const lineStarts = [0] let vimText = "" let vimOffset = 0 + let width = 0 for (const { index: hostOffset, segment } of graphemes.segment(hostText)) { for (let index = hostOffset; index < hostOffset + segment.length; index++) hostToVim[index] = vimOffset - vimText += codec.encode(segment) + const encoded = codec.encodeGrapheme(segment) + vimText += encoded vimToHost[vimOffset] = hostOffset + vimToDisplay[vimOffset] = width + width += graphemeWidth(segment) vimOffset++ + if (encoded === "\n") lineStarts.push(vimOffset) } hostToVim[hostText.length] = vimOffset vimToHost[vimOffset] = hostText.length - return { hostText, vimText, hostToVim, vimToHost } + vimToDisplay[vimOffset] = width + return { hostText, vimText, hostToVim, vimToHost, vimToDisplay, lineStarts, displayWidth: width } } export function hostPosition(map: PromptMap, hostDisplayOffset: number): CursorPosition { - const charIdx = displayToChar(map.hostText, hostDisplayOffset) - return positionFromOffset(map.vimText, map.hostToVim[clamp(charIdx, 0, map.hostText.length)]) + const offset = indexAtOffset(map.vimToDisplay, hostDisplayOffset) + const line = indexAtOffset(map.lineStarts, offset) + return { line, col: offset - map.lineStarts[line] } +} + +export function hostCharOffset(map: PromptMap, hostDisplayOffset: number) { + return map.vimToHost[indexAtOffset(map.vimToDisplay, hostDisplayOffset)] } export function hostOffset(map: PromptMap, position: CursorPosition) { - return hostFromVimOffset(map, offsetFromPosition(map.vimText, position)) + return hostFromVimOffset(map, vimOffsetFromPosition(map, position)) } export function hostFromVimOffset(map: PromptMap, offset: number) { - return charToDisplay(map.hostText, map.vimToHost[clamp(offset, 0, map.vimText.length)]) + return map.vimToDisplay[clamp(offset, 0, map.vimText.length)] } -function positionFromOffset(text: string, offset: number): CursorPosition { - const lines = text.slice(0, offset).split("\n") - return { line: lines.length - 1, col: lines[lines.length - 1]?.length ?? 0 } +export function vimLineLength(map: PromptMap, line: number) { + const start = map.lineStarts[line] + if (start === undefined) return 0 + const end = line + 1 < map.lineStarts.length ? map.lineStarts[line + 1] - 1 : map.vimText.length + return end - start } -function offsetFromPosition(text: string, position: CursorPosition) { - const lines = text.split("\n") - const line = clamp(position.line, 0, Math.max(0, lines.length - 1)) - let offset = 0 - for (let index = 0; index < line; index++) offset += lines[index].length + 1 - return offset + clamp(position.col, 0, lines[line]?.length ?? 0) +export function vimOffsetFromPosition(map: PromptMap, position: CursorPosition) { + const line = clamp(position.line, 0, map.lineStarts.length - 1) + return map.lineStarts[line] + clamp(position.col, 0, vimLineLength(map, line)) +} + +// Find the last boundary at or before an offset, including zero-width graphemes. +function indexAtOffset(offsets: number[], offset: number) { + let low = 0 + let high = offsets.length + while (low < high) { + const middle = Math.floor((low + high) / 2) + if (offsets[middle] <= offset) low = middle + 1 + else high = middle + } + return Math.max(0, low - 1) } function clamp(value: number, min: number, max: number) { diff --git a/src/modules/vim/vimee.ts b/src/modules/vim/vimee.ts index 68195fa..13938e3 100644 --- a/src/modules/vim/vimee.ts +++ b/src/modules/vim/vimee.ts @@ -5,7 +5,7 @@ import { focusedInput, setInput, type EditBufferLike, type PromptContext } from import type { VimConfig } from "./config" import type { VimLog } from "./log" import { createGraphemeCodec } from "./graphemes" -import { charToDisplay, displayToChar, displayWidth, createPromptMap, hostFromVimOffset, hostOffset, hostPosition, type PromptMap } from "./map" +import { displayToChar, displayWidth, createPromptMap, hostCharOffset, hostFromVimOffset, hostOffset, hostPosition, vimLineLength, vimOffsetFromPosition, type PromptMap } from "./map" import type { createVimState } from "./state" type VimState = ReturnType @@ -80,9 +80,6 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } const text = input?.plainText ?? ref.current.input - const dw = displayWidth(text) - const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw) - const charOff = displayToChar(text, displayOff) const canBrowseHistory = !options.readOnly && vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending() const historyCommand = canBrowseHistory ? defaultHistoryCommand(vimeeKey, text, historyText, defaultHistoryKeys) : undefined @@ -97,11 +94,12 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL historyText = undefined const map = mapForHostText(text) + const displayOff = clamp(input?.cursorOffset ?? map.displayWidth, 0, map.displayWidth) const cursor = hostPosition(map, displayOff) const wasPending = keybinds?.isPending() ?? false const pendingBefore = pendingInsert - sync(map, cursor) + sync(cursor) const shouldFlashYank = shouldFlashYankFor(vimeeKey) const visualYankRange = visualYankRangeFor(map) @@ -157,7 +155,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL syncMode(state, vim.mode) const keybindPending = keybinds?.isPending() ?? false - if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff) + if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, hostCharOffset(map, displayOff)) pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : "" state.setPending(pendingDisplay(vim, keybindPending)) updateTimeout(ctx) @@ -207,16 +205,16 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL pendingContext = undefined } - function sync(map: PromptMap, cursor: CursorPosition) { + function sync(cursor: CursorPosition) { if (cursor.line !== vim.cursor.line || cursor.col !== vim.cursor.col) preferredColumn = undefined - if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText) vim = { ...vim, cursor, mode: state.mode() } } function mapForHostText(text: string) { if (activeMap.hostText === text) return activeMap - if (state.mode() === "insert") recordNativeChange(text) - activeMap = createPromptMap(text, codec) + const nextMap = createPromptMap(text, codec) + if (state.mode() === "insert") recordNativeChange(nextMap) + activeMap = nextMap if (state.mode() === "insert") { if (!nativeInsertUndoSaved) { buffer.saveUndoPoint(vim.cursor) @@ -230,10 +228,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL return activeMap } - function recordNativeChange(text: string) { - const before = codec.encode(activeMap.hostText) - const after = codec.encode(text) - const start = codec.encode(activeMap.hostText.slice(0, displayToChar(activeMap.hostText, hostOffset(activeMap, vim.cursor)))).length + function recordNativeChange(nextMap: PromptMap) { + const before = activeMap.vimText + const after = nextMap.vimText + const start = vimOffsetFromPosition(activeMap, vim.cursor) let prefix = 0 while (prefix < start && prefix < after.length && before[prefix] === after[prefix]) prefix++ let suffix = 0 @@ -258,10 +256,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL currentMap = createPromptMap(codec.decode(action.content), codec) activeMap = currentMap setInput(ref, currentMap.hostText) - buffer.replaceContent(action.content) - break - case "cursor-move": - setCursor(input, currentMap, action.position) + // Decoding can join neighboring graphemes; use the map's units. + buffer.replaceContent(currentMap.vimText) break case "yank": if (!action.register) options.onYank?.(codec.decode(action.text)) @@ -286,6 +282,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } } + // The engine context already has the final cursor; move the editor once. setCursor(input, currentMap, vim.cursor) syncVisualSelection(input, currentMap, ctx) } @@ -356,13 +353,13 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL const map = mapForHostText(text) if (input && text.length > 0) { - const dw = displayWidth(text) + const dw = map.displayWidth const offset = clamp(input?.cursorOffset ?? dw, 0, dw) - const charOffset = displayToChar(text, offset) + const charOffset = hostCharOffset(map, offset) if (charOffset > 0 && text[charOffset - 1] !== "\n") { - input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1))) + input.cursorOffset = hostOffset(map, hostPosition(map, Math.max(0, offset - 1))) } - clampNormalCursor(input) + clampNormalCursor(input, map) } const lastChange = nativeInsertUndoSaved ? [...vim.pendingChange, "Escape"] : vim.lastChange @@ -570,10 +567,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } else { target = start if (key === "^") { - const from = displayToChar(map.hostText, start) - const to = displayToChar(map.hostText, end + 1) + const from = hostCharOffset(map, start) + const to = hostCharOffset(map, end + 1) const first = map.hostText.slice(from, to).search(/\S/) - if (first >= 0) target = charToDisplay(map.hostText, from + first) + if (first >= 0) target = hostFromVimOffset(map, map.hostToVim[from + first]) } } return { start: vim.cursor, end: hostPosition(map, target), linewise: !screen, inclusive: !screen || key === "$" } @@ -659,8 +656,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) { if (!input) return - input.cursorOffset = hostOffset(map, position) - if (vim.mode !== "insert") clampNormalCursor(input) + const offset = hostOffset(map, position) + if (input.cursorOffset !== offset) input.cursorOffset = offset + if (vim.mode !== "insert") clampNormalCursor(input, map) } function syncVisualSelection(input: EditBufferLike | undefined, map: PromptMap, ctx: PromptContext) { @@ -760,7 +758,7 @@ function visualLineRange(map: PromptMap, anchor: CursorPosition, cursor: CursorP const startLine = Math.min(anchor.line, cursor.line) const endLine = Math.max(anchor.line, cursor.line) const start = hostOffset(map, { line: startLine, col: 0 }) - const end = hostOffset(map, { line: endLine, col: vimLineLength(map.vimText, endLine) }) + const end = hostOffset(map, { line: endLine, col: vimLineLength(map, endLine) }) return hostRange(map, start, end) } @@ -771,14 +769,14 @@ function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string): return visualLineRange(map, cursor, { line: cursor.line + Math.max(0, lineCount - 1), col: 0 }) } - const start = vimOffsetFromPosition(map.vimText, cursor) + const start = vimOffsetFromPosition(map, cursor) return vimOffsetRange(map, start, start + text.length - 1) } function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefined { if (range.linewise) return visualLineRange(map, range.start, range.end) - const start = vimOffsetFromPosition(map.vimText, range.start) - const end = vimOffsetFromPosition(map.vimText, range.end) + const start = vimOffsetFromPosition(map, range.start) + const end = vimOffsetFromPosition(map, range.end) return vimOffsetRange(map, start, end) } @@ -790,7 +788,7 @@ function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange function hostRange(map: PromptMap, left: number, right: number): HostRange | undefined { if (!map.hostText) return undefined - const dw = displayWidth(map.hostText) + const dw = map.displayWidth const start = clamp(Math.min(left, right), 0, Math.max(0, dw - 1)) const end = clamp(Math.max(left, right), 0, Math.max(0, dw - 1)) return { start, end } @@ -841,32 +839,20 @@ function isVisualMode(mode: VimContext["mode"]): mode is "visual" | "visual-line return mode === "visual" || mode === "visual-line" } -function vimLineLength(text: string, line: number) { - return text.split("\n")[line]?.length ?? 0 -} - -function vimOffsetFromPosition(text: string, position: CursorPosition) { - const lines = text.split("\n") - const line = clamp(position.line, 0, Math.max(0, lines.length - 1)) - let offset = 0 - for (let index = 0; index < line; index++) offset += lines[index].length + 1 - return offset + clamp(position.col, 0, lines[line]?.length ?? 0) -} - -function clampNormalCursor(input: EditBufferLike) { +function clampNormalCursor(input: EditBufferLike, map: PromptMap) { const cursor = input.visualCursor const offset = input.cursorOffset - const text = input.plainText - if (!cursor || offset === undefined || text === undefined) return + const text = map.hostText + if (!cursor || offset === undefined) return if (cursor.visualCol === 0) return - const dw = displayWidth(text) + const dw = map.displayWidth if (offset >= dw) { - input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, dw - 1))) + input.cursorOffset = hostOffset(map, hostPosition(map, Math.max(0, dw - 1))) return } - const charIdx = displayToChar(text, offset) + const charIdx = hostCharOffset(map, offset) if (charIdx < text.length && text[charIdx] === '\n') { - input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1))) + input.cursorOffset = hostOffset(map, hostPosition(map, Math.max(0, offset - 1))) } } diff --git a/test/benchmark.ts b/test/benchmark.ts index 3effdf4..5ea767e 100644 --- a/test/benchmark.ts +++ b/test/benchmark.ts @@ -1,16 +1,26 @@ import { createFixture } from "./fixture" -import { createPromptMap } from "../src/modules/vim/map" +import { createPromptMap, hostFromVimOffset } from "../src/modules/vim/map" -for (const length of [1000, 5000, 10000]) { - const fixture = await createFixture("word ".repeat(length / 5)) - try { - const start = performance.now() - createPromptMap(fixture.input.plainText) - const mapMs = performance.now() - start - const moveStart = performance.now() - await fixture.keys("hl".repeat(50)) - console.log(JSON.stringify({ length, mapMs, hundredMotionsMs: performance.now() - moveStart })) - } finally { - fixture.dispose() +for (const pattern of ["word ", "word\n", "中e\u0301👩‍💻\t\n"]) { + for (const length of [1000, 10000, 100000]) { + const text = pattern.repeat(Math.floor(length / pattern.length)) + const fixture = await createFixture(text) + try { + const start = performance.now() + const map = createPromptMap(text) + const mapMs = performance.now() - start + fixture.input.cursorOffset = hostFromVimOffset(map, Math.floor(map.vimText.length / 2)) + await fixture.keys("hl".repeat(5)) + const moveStart = performance.now() + await fixture.keys("hl".repeat(50)) + const motionMsPerKey = (performance.now() - moveStart) / 100 + const editStart = performance.now() + await fixture.keys("xu".repeat(10)) + const deleteUndoMsPerKey = (performance.now() - editStart) / 20 + if (fixture.input.plainText !== text) throw new Error("Delete/undo did not restore the buffer") + console.log(JSON.stringify({ pattern, length: text.length, mapMs, motionMsPerKey, deleteUndoMsPerKey })) + } finally { + fixture.dispose() + } } } diff --git a/test/editor.test.ts b/test/editor.test.ts index c761add..5baa4b0 100644 --- a/test/editor.test.ts +++ b/test/editor.test.ts @@ -389,4 +389,24 @@ describe("real textarea Vim editing", () => { expect(fixture.input.plainText).toBe("[Image 1] abc") expect(fixture.input.extmarks.get(id)?.data).toBe("attachment") }) + + test("external edits refresh cached lines and display widths", async () => { + fixture = await createFixture("ab") + await fixture.keys("$") + fixture.input.insertText("中\n") + await fixture.keys("0") + expect(fixture.input.cursorOffset).toBe(4) + await fixture.keys("xu") + expect(fixture.input.plainText).toBe("a中\nb") + expect(fixture.input.cursorOffset).toBe(4) + }) + + test("a pasted combining mark joins its neighbor without desynchronizing Vim", async () => { + fixture = await createFixture("ab") + fixture.adapter.setRegister("\u0301") + await fixture.keys("p0x") + expect(fixture.input.plainText).toBe("b") + await fixture.keys("u") + expect(fixture.input.plainText).toBe("a\u0301b") + }) }) From 5814eef63895230d4a0ad69da9d5a99c31a32176 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 12:46:05 -0400 Subject: [PATCH 02/15] Remove obsolete V1 snippets integration Delete the copied snippets module and unused V1 prompt framework, Vim UI wiring, updater, and example configuration. --- src/modules/snippets/index.tsx | 17 - src/modules/snippets/loader.ts | 147 ------ src/modules/snippets/search.ts | 118 ----- src/modules/snippets/skill-loader.ts | 81 ---- src/modules/snippets/state.ts | 40 -- src/modules/snippets/trigger.ts | 68 --- src/modules/snippets/types.ts | 50 -- src/modules/snippets/view.tsx | 674 --------------------------- src/modules/vim/index.tsx | 200 -------- src/modules/vim/view.tsx | 130 ------ src/plugin.tsx | 116 ----- src/prompt/host.tsx | 38 -- src/prompt/modules.ts | 16 - src/prompt/root.tsx | 69 --- src/prompt/types.ts | 42 -- src/update.ts | 120 ----- tui.json.example | 37 -- 17 files changed, 1963 deletions(-) delete mode 100644 src/modules/snippets/index.tsx delete mode 100644 src/modules/snippets/loader.ts delete mode 100644 src/modules/snippets/search.ts delete mode 100644 src/modules/snippets/skill-loader.ts delete mode 100644 src/modules/snippets/state.ts delete mode 100644 src/modules/snippets/trigger.ts delete mode 100644 src/modules/snippets/types.ts delete mode 100644 src/modules/snippets/view.tsx delete mode 100644 src/modules/vim/index.tsx delete mode 100644 src/modules/vim/view.tsx delete mode 100644 src/plugin.tsx delete mode 100644 src/prompt/host.tsx delete mode 100644 src/prompt/modules.ts delete mode 100644 src/prompt/root.tsx delete mode 100644 src/prompt/types.ts delete mode 100644 src/update.ts delete mode 100644 tui.json.example diff --git a/src/modules/snippets/index.tsx b/src/modules/snippets/index.tsx deleted file mode 100644 index 8dfbc4c..0000000 --- a/src/modules/snippets/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { PromptModule } from "../../prompt/types" -import type { SnippetController } from "./types" -import { SnippetAutocomplete } from "./view" - -export function createSnippetsModule(controller: SnippetController): PromptModule { - return { - id: "snippets", - order: -10, - onSubmit() { - return controller.accept?.() === true - }, - renderAbove(ctx) { - return - }, - } -} diff --git a/src/modules/snippets/loader.ts b/src/modules/snippets/loader.ts deleted file mode 100644 index b656dab..0000000 --- a/src/modules/snippets/loader.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { basename, join } from "node:path" -import { mkdir, writeFile } from "node:fs/promises" -import type { SnippetInfo, SnippetSource } from "./types" - -const SNIPPET_EXT = ".md" -const EMPTY_SNIPPET = `--- -description: "" ---- - -` - -export async function loadSnippets(projectDir?: string) { - const registry = new Map() - const home = process.env.HOME - - if (home) { - await loadDir(registry, join(home, ".config/opencode/snippets"), "global") - await loadDir(registry, join(home, ".config/opencode/snippet"), "global") - } - - if (projectDir) { - await loadDir(registry, join(projectDir, ".opencode/snippets"), "project") - await loadDir(registry, join(projectDir, ".opencode/snippet"), "project") - } - - return listSnippets(registry).sort((left, right) => sourceRank(left) - sourceRank(right) || left.name.localeCompare(right.name)) -} - -async function loadDir(registry: Map, dir: string, source: SnippetSource) { - let entries: string[] - try { - entries = await Array.fromAsync(new Bun.Glob(`*${SNIPPET_EXT}`).scan({ cwd: dir, onlyFiles: true })) - } catch { - return - } - - for (const entry of entries) { - const snippet = await loadFile(join(dir, entry), source) - if (snippet) registerSnippet(registry, snippet) - } -} - -async function loadFile(filePath: string, source: SnippetSource) { - let raw: string - try { - raw = await Bun.file(filePath).text() - } catch { - return undefined - } - - const parsed = parseFrontmatter(raw) - return { - name: basename(filePath, SNIPPET_EXT), - content: parsed.content.trim(), - aliases: normalizeAliases(parsed.data.aliases ?? parsed.data.alias), - description: typeof parsed.data.description === "string" ? parsed.data.description : undefined, - filePath, - source, - } satisfies SnippetInfo -} - -export async function ensureSnippetDraft(name: string, projectDir?: string) { - const dir = await ensureSnippetsDir(projectDir) - const filePath = join(dir, `${name}${SNIPPET_EXT}`) - if (!(await Bun.file(filePath).exists())) await writeFile(filePath, EMPTY_SNIPPET) - return filePath -} - -async function ensureSnippetsDir(projectDir?: string) { - const home = process.env.HOME - const dir = projectDir ? join(projectDir, ".opencode/snippet") : home ? join(home, ".config/opencode/snippet") : undefined - if (!dir) throw new Error("Unable to resolve snippets directory") - await mkdir(dir, { recursive: true }) - return dir -} - -export function parseFrontmatter(raw: string) { - if (!raw.startsWith("---\n")) return { data: {} as Record, content: raw } - - const end = raw.indexOf("\n---", 4) - if (end < 0) return { data: {} as Record, content: raw } - - return { - data: parseYamlish(raw.slice(4, end)), - content: raw.slice(end + 4).replace(/^\r?\n/, ""), - } -} - -function parseYamlish(input: string) { - const data: Record = {} - const lines = input.split(/\r?\n/) - let currentList: string | undefined - - for (const line of lines) { - const listItem = /^\s*-\s*(.+)$/.exec(line) - if (listItem && currentList) { - const value = String(listItem[1]).trim() - data[currentList] = [...asArray(data[currentList]), unquote(value)] - continue - } - - const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line) - if (!pair) continue - - currentList = undefined - const key = pair[1] - const value = pair[2].trim() - if (!value) { - data[key] = [] - currentList = key - } else { - data[key] = unquote(value) - } - } - - return data -} - -export function normalizeAliases(value: unknown) { - return asArray(value).filter((entry) => typeof entry === "string" && entry.length > 0) as string[] -} - -function asArray(value: unknown) { - return Array.isArray(value) ? value : typeof value === "string" ? [value] : [] -} - -export function unquote(value: string) { - return value.replace(/^['"]|['"]$/g, "") -} - -function registerSnippet(registry: Map, snippet: SnippetInfo) { - const old = registry.get(snippet.name.toLowerCase()) - if (old) { - for (const alias of old.aliases) registry.delete(alias.toLowerCase()) - } - - registry.set(snippet.name.toLowerCase(), snippet) - for (const alias of snippet.aliases) registry.set(alias.toLowerCase(), snippet) -} - -function listSnippets(registry: Map) { - return [...new Map([...registry.values()].map((snippet) => [snippet.name, snippet])).values()] -} - -function sourceRank(snippet: SnippetInfo) { - return snippet.source === "project" ? 0 : 1 -} diff --git a/src/modules/snippets/search.ts b/src/modules/snippets/search.ts deleted file mode 100644 index 6f1f7dd..0000000 --- a/src/modules/snippets/search.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { SkillInfo, SnippetInfo } from "./types" - -export type HighlightPart = { - text: string - match: boolean -} - -function normalizeSearchText(input: string) { - return input.toLowerCase().replace(/[^a-z0-9]/g, "") -} - -function scoreText(input: string, query: string) { - const raw = input.toLowerCase() - const compact = normalizeSearchText(input) - const needle = query.toLowerCase().trim() - const compactNeedle = normalizeSearchText(query) - - if (raw === needle) return 0 - if (compactNeedle && compact === compactNeedle) return 1 - if (raw.startsWith(needle)) return 2 - if (compactNeedle && compact.startsWith(compactNeedle)) return 3 - if (raw.includes(needle)) return 4 - if (compactNeedle && compact.includes(compactNeedle)) return 5 - return Number.POSITIVE_INFINITY -} - -function snippetDescription(snippet: SnippetInfo) { - return (snippet.description || snippet.content).replace(/\s+/g, " ").trim() -} - -function scoreSnippet(snippet: SnippetInfo, query: string) { - if (!query) return 0 - - const nameScore = Math.min(scoreText(snippet.name, query), ...snippet.aliases.map((alias) => scoreText(alias, query))) - if (Number.isFinite(nameScore)) return nameScore - - const description = (snippet.description || "").replace(/\s+/g, " ").trim().toLowerCase() - const lowerQuery = query.toLowerCase() - if (description.startsWith(lowerQuery)) return 6 - if (description.includes(lowerQuery)) return 7 - return Number.POSITIVE_INFINITY -} - -function sourceRank(snippet: SnippetInfo) { - return snippet.source === "project" ? 0 : 1 -} - -export function filterSnippets(snippets: SnippetInfo[], query: string) { - const needle = query.trim() - return snippets - .map((snippet) => ({ snippet, score: scoreSnippet(snippet, needle) })) - .filter((entry) => Number.isFinite(entry.score)) - .sort((left, right) => left.score - right.score || sourceRank(left.snippet) - sourceRank(right.snippet) || left.snippet.name.localeCompare(right.snippet.name)) - .map((entry) => entry.snippet) -} - -export function matchedAliases(snippet: SnippetInfo, query: string) { - const needle = query.trim() - if (!needle) return [] - return snippet.aliases.filter((alias) => Number.isFinite(scoreText(alias, needle))) -} - -export function describeSnippet(snippet: SnippetInfo) { - return snippetDescription(snippet) -} - -export function highlightMatches(input: string, query: string): HighlightPart[] { - const needle = query.trim().toLowerCase() - if (!needle) return [{ text: input, match: false }] - - const lower = input.toLowerCase() - const parts: HighlightPart[] = [] - let cursor = 0 - - while (cursor < input.length) { - const index = lower.indexOf(needle, cursor) - if (index < 0) break - if (index > cursor) parts.push({ text: input.slice(cursor, index), match: false }) - parts.push({ text: input.slice(index, index + needle.length), match: true }) - cursor = index + needle.length - } - - if (parts.length === 0) return [{ text: input, match: false }] - if (cursor < input.length) parts.push({ text: input.slice(cursor), match: false }) - return parts -} - -function skillTag(skill: SkillInfo) { - return `skill(${skill.name})` -} - -function scoreSkill(skill: SkillInfo, query: string) { - if (!query) return 0 - const nameScore = Math.min(scoreText(skill.name, query), scoreText(skillTag(skill), query)) - if (Number.isFinite(nameScore)) return nameScore - const description = (skill.description || "").replace(/\s+/g, " ").trim().toLowerCase() - const lowerQuery = query.toLowerCase() - if (description.startsWith(lowerQuery)) return 6 - if (description.includes(lowerQuery)) return 7 - return Number.POSITIVE_INFINITY -} - -function skillSourceRank(skill: SkillInfo) { - return skill.source === "project" ? 0 : 1 -} - -export function filterSkills(skills: SkillInfo[], query: string) { - const needle = query.trim() - return skills - .map((skill) => ({ skill, score: scoreSkill(skill, needle) })) - .filter((entry) => Number.isFinite(entry.score)) - .sort((left, right) => left.score - right.score || skillSourceRank(left.skill) - skillSourceRank(right.skill) || left.skill.name.localeCompare(right.skill.name)) - .map((entry) => entry.skill) -} - -export function describeSkill(skill: SkillInfo) { - return (skill.description || skill.content).replace(/\s+/g, " ").trim() -} diff --git a/src/modules/snippets/skill-loader.ts b/src/modules/snippets/skill-loader.ts deleted file mode 100644 index e3aed31..0000000 --- a/src/modules/snippets/skill-loader.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { dirname, join, parse } from "node:path" -import type { SkillInfo, SnippetSource } from "./types" -import { parseFrontmatter } from "./loader" - -const SKILL_FILE = "SKILL.md" - -export async function loadSkills(projectDir?: string) { - const registry = new Map() - const home = process.env.HOME - - if (home) { - await loadSkillDir(registry, join(home, ".config/opencode/skill"), "global") - await loadSkillDir(registry, join(home, ".config/opencode/skills"), "global") - await loadSkillDir(registry, join(home, ".claude/skills"), "global") - await loadSkillDir(registry, join(home, ".agents/skills"), "global") - } - - for (const root of await projectRoots(projectDir)) { - await loadSkillDir(registry, join(root, ".opencode/skill"), "project") - await loadSkillDir(registry, join(root, ".opencode/skills"), "project") - await loadSkillDir(registry, join(root, ".claude/skills"), "project") - await loadSkillDir(registry, join(root, ".agents/skills"), "project") - } - - return [...registry.values()].sort((left, right) => sourceRank(left) - sourceRank(right) || left.name.localeCompare(right.name)) -} - -async function projectRoots(projectDir?: string) { - if (!projectDir) return [] - const roots: string[] = [] - let current = projectDir - - while (true) { - roots.push(current) - if (await Bun.file(join(current, ".git")).exists()) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - - return roots.reverse() -} - -async function loadSkillDir(registry: Map, dir: string, source: SnippetSource) { - let entries: string[] - try { - entries = await Array.fromAsync(new Bun.Glob(`*/${SKILL_FILE}`).scan({ cwd: dir, onlyFiles: true })) - } catch { - return - } - - for (const entry of entries) { - const skill = await loadSkill(join(dir, entry), source) - if (skill) registry.set(skill.name.toLowerCase(), skill) - } -} - -async function loadSkill(filePath: string, source: SnippetSource) { - let raw: string - try { - raw = await Bun.file(filePath).text() - } catch { - return undefined - } - - const parsed = parseFrontmatter(raw) - const folderName = parse(dirname(filePath)).base - const name = typeof parsed.data.name === "string" && parsed.data.name.trim() ? parsed.data.name.trim() : folderName - - return { - name, - content: parsed.content.trim(), - description: typeof parsed.data.description === "string" ? parsed.data.description : undefined, - filePath, - source, - } satisfies SkillInfo -} - -function sourceRank(skill: SkillInfo) { - return skill.source === "project" ? 0 : 1 -} diff --git a/src/modules/snippets/state.ts b/src/modules/snippets/state.ts deleted file mode 100644 index 7e6ea73..0000000 --- a/src/modules/snippets/state.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { dirname, join } from "node:path" -import { mkdir, writeFile } from "node:fs/promises" - -const SCOPE_GLOBAL = "__global__" - -export async function addPendingDraft(workspaceDir: string | undefined, name: string) { - const filePath = statePath("pending-drafts.json") - const state = await readState(filePath) - const scope = workspaceDir || SCOPE_GLOBAL - const current = Array.isArray(state[scope]) ? state[scope].filter((item): item is string => typeof item === "string") : [] - const next = new Set([...current, name.toLowerCase()]) - state[scope] = [...next].sort() - await writeState(filePath, state) -} - -export async function markSnippetReloadRequested(workspaceDir?: string) { - const filePath = statePath("snippet-reload.json") - const state = await readState(filePath) - state[workspaceDir || SCOPE_GLOBAL] = Date.now() - await writeState(filePath, state) -} - -function statePath(name: string) { - const home = process.env.HOME - if (!home) throw new Error("HOME is not set") - return join(home, ".config/opencode/state", name) -} - -async function readState(filePath: string) { - try { - const parsed = JSON.parse(await Bun.file(filePath).text()) - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record - } catch {} - return {} as Record -} - -async function writeState(filePath: string, state: Record) { - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`) -} diff --git a/src/modules/snippets/trigger.ts b/src/modules/snippets/trigger.ts deleted file mode 100644 index 2488d67..0000000 --- a/src/modules/snippets/trigger.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { HashtagTriggerMatch, SnippetInfo } from "./types" - -export function findTrailingHashtagTrigger(input: string): HashtagTriggerMatch | undefined { - let start = -1 - for (let i = input.length - 1; i >= 0; i--) { - const char = input[i] - if (char === "#") { - start = i - break - } - if (/\s/.test(char)) return undefined - } - - if (start < 0) return undefined - if (start > 0 && !/\s/.test(input[start - 1])) return undefined - - const query = input.slice(start + 1) - const token = `#${query}` - - return { - start, - end: input.length, - query, - token, - } -} - -export function replaceTrailingHashtag(input: string, name: string) { - const match = findTrailingHashtagTrigger(input) - if (!match) return input - return `${input.slice(0, match.start)}#${name} ` -} - -export function insertSnippetTag(input: string, name: string) { - const match = findTrailingHashtagTrigger(input) - if (match) return replaceTrailingHashtag(input, name) - const separator = input.length === 0 || /\s$/.test(input) ? "" : " " - return `${input}${separator}#${name} ` -} - -export function insertSkillLoad(input: string, name: string) { - const tag = `skill(${name})` - const match = findTrailingHashtagTrigger(input) - if (match) return replaceTrailingHashtag(input, tag) - const separator = input.length === 0 || /\s$/.test(input) ? "" : " " - return `${input}${separator}#${tag} ` -} - -export function preferredSnippetTag(input: string, snippet: SnippetInfo) { - const query = findTrailingHashtagTrigger(input)?.query - if (query && snippet.aliases.some((alias) => alias === query)) return query - return snippet.name -} - -export function insertSnippetTrigger(input: string) { - if (findTrailingHashtagTrigger(input)) return input - const separator = input.length === 0 || /\s$/.test(input) ? "" : " " - return `${input}${separator}#` -} - -export function isReloadCommand(input: string) { - return input.trim() === "/snippets:reload" -} - -export function stepSelection(current: number, total: number, delta: number) { - if (total <= 0) return 0 - return Math.max(0, Math.min(total - 1, current + delta)) -} diff --git a/src/modules/snippets/types.ts b/src/modules/snippets/types.ts deleted file mode 100644 index 09fc4f6..0000000 --- a/src/modules/snippets/types.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type SnippetSource = "global" | "project" - -export type SnippetInfo = { - name: string - content: string - aliases: string[] - description?: string - filePath: string - source: SnippetSource -} - -export type SkillInfo = { - name: string - content: string - description?: string - filePath: string - source: SnippetSource -} - -export type AutocompleteItem = - | { - kind: "snippet" - id: string - label: string - description: string - aliases: string[] - snippet: SnippetInfo - } - | { - kind: "skill" - id: string - label: string - description: string - aliases: string[] - skill: SkillInfo - } - -export type HashtagTriggerMatch = { - start: number - end: number - query: string - token: string -} - -export type SnippetController = { - accept?: () => boolean - reload?: () => void - insertTrigger?: () => void - navigate?: (delta: number) => boolean -} diff --git a/src/modules/snippets/view.tsx b/src/modules/snippets/view.tsx deleted file mode 100644 index 0b5c39c..0000000 --- a/src/modules/snippets/view.tsx +++ /dev/null @@ -1,674 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { spawn } from "node:child_process" -import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" -import { RGBA, type ScrollBoxRenderable } from "@opentui/core" -import type { TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui" -import type { PromptContext } from "../../prompt/types" -import { ensureSnippetDraft, loadSnippets } from "./loader" -import { loadSkills } from "./skill-loader" -import { addPendingDraft, markSnippetReloadRequested } from "./state" -import { describeSkill, describeSnippet, filterSkills, filterSnippets, highlightMatches, matchedAliases } from "./search" -import { findTrailingHashtagTrigger, insertSkillLoad, insertSnippetTag, insertSnippetTrigger, isReloadCommand, preferredSnippetTag, stepSelection } from "./trigger" -import type { AutocompleteItem, SnippetController, SnippetInfo } from "./types" - -const PROMPT_SYNC_MS = 50 -const MENU_MAX_HEIGHT = 10 -const MOUSE_HOVER_SUPPRESS_MS = 150 -const INLINE_BORDER = { - border: ["left", "right"] as Array<"left" | "right">, - customBorderChars: { - topLeft: "", - bottomLeft: "", - vertical: "┃", - topRight: "", - bottomRight: "", - horizontal: " ", - bottomT: "", - topT: "", - cross: "", - leftT: "", - rightT: "", - }, -} - -type SnippetAutocompleteProps = { - ctx: PromptContext - controller: SnippetController -} - -type InputMode = "keyboard" | "mouse" - -export function SnippetAutocomplete(props: SnippetAutocompleteProps) { - const [snippets, setSnippets] = createSignal([]) - const [skills, setSkills] = createSignal>>([]) - const [loading, setLoading] = createSignal(true) - const [input, setInput] = createSignal("") - const [syncingPrompt, setSyncingPrompt] = createSignal(false) - const [menuEpoch, setMenuEpoch] = createSignal(0) - const [selected, setSelected] = createSignal(0) - const [dismissed, setDismissed] = createSignal() - const [inputMode, setInputMode] = createSignal("keyboard") - const [ignoreMouseUntil, setIgnoreMouseUntil] = createSignal(0) - const [lastMousePos, setLastMousePos] = createSignal<{ x: number; y: number }>() - const [creating, setCreating] = createSignal(false) - const [dialogOpen, setDialogOpen] = createSignal(false) - const [dialogHandoffUntil, setDialogHandoffUntil] = createSignal(0) - - let syncInterval: ReturnType | undefined - let pendingPromptSync: ReturnType | undefined - let pendingPromptFocus: ReturnType | undefined - let pendingDialogHandoff: ReturnType | undefined - let commandTimer: ReturnType | undefined - let commandDispose: (() => void) | undefined - let disposed = false - let scroll: ScrollBoxRenderable | undefined - - const refresh = async () => { - setLoading(true) - try { - const [nextSnippets, nextSkills] = await Promise.all([loadSnippets(props.ctx.api.state.path.directory), loadSkills(props.ctx.api.state.path.directory)]) - if (!disposed) { - setSnippets(nextSnippets) - setSkills(nextSkills) - } - } finally { - if (!disposed) setLoading(false) - } - } - - void refresh() - - syncInterval = setInterval(() => { - const ref = props.ctx.prompt() - if (!ref) { - if (input()) setInput("") - setSyncingPrompt(false) - return - } - const next = ref.current.input ?? "" - if (next !== input()) setInput(next) - }, PROMPT_SYNC_MS) - - onCleanup(() => { - disposed = true - if (syncInterval) clearInterval(syncInterval) - if (pendingPromptSync) clearTimeout(pendingPromptSync) - if (pendingPromptFocus) clearTimeout(pendingPromptFocus) - if (pendingDialogHandoff) clearTimeout(pendingDialogHandoff) - if (commandTimer) clearTimeout(commandTimer) - commandDispose?.() - props.controller.accept = undefined - props.controller.reload = undefined - props.controller.insertTrigger = undefined - props.controller.navigate = undefined - }) - - const dialogBlockingInput = () => props.ctx.api.ui.dialog.open || dialogOpen() || dialogHandoffUntil() > Date.now() - const match = createMemo(() => { - if (props.ctx.disabled || props.ctx.visible === false) return undefined - return findTrailingHashtagTrigger(input()) - }) - const query = createMemo(() => match()?.query.trim() ?? "") - const options = createMemo(() => (match() ? optionsForQuery(query()) : [])) - const draftName = createMemo(() => normalizeSnippetName(query())) - const canCreate = createMemo(() => !loading() && options().length === 0 && query().length > 0 && draftName().length > 0) - const visible = createMemo(() => !!match() && !syncingPrompt() && dismissed() !== match()?.token) - const menuVisible = createMemo(() => visible() && (options().length > 0 || canCreate())) - const menuHeight = createMemo(() => Math.min(MENU_MAX_HEIGHT, Math.max(1, options().length || 1))) - const selectedFg = createMemo(() => selectedText(props.ctx.api.theme.current)) - const activeRowId = createMemo(() => options()[selected()]?.id ?? (canCreate() ? "create-snippet" : undefined)) - const optionKey = createMemo(() => options().map((option) => option.id).join("\n")) - - createEffect(() => { - menuEpoch() - if (visible()) scroll = undefined - }) - - createEffect(() => { - match()?.token - optionKey() - setSelected(0) - lockKeyboardSelection() - setTimeout(() => { - scroll?.scrollTo(0) - const first = activeRowId() - if (first) scroll?.scrollChildIntoView(first) - }, 0) - }) - - createEffect(() => { - const row = activeRowId() - if (row) scroll?.scrollChildIntoView(row) - }) - - const chooseItem = (item: AutocompleteItem) => { - const ref = props.ctx.prompt() - if (!ref) return false - const next = item.kind === "skill" ? insertSkillLoad(ref.current.input, item.skill.name) : insertSnippetTag(ref.current.input, preferredSnippetTag(ref.current.input, item.snippet)) - syncPromptInput(ref, next) - setDismissed(undefined) - ref.focus() - props.ctx.requestRender() - return true - } - - const choose = (index = selected()) => { - const item = options()[index] - return item ? chooseItem(item) : false - } - - const navigate = (delta: number) => { - const ref = props.ctx.prompt() - if (!ref || !ref.focused || dialogBlockingInput()) return false - - const current = findTrailingHashtagTrigger(ref.current.input) - if (!current || dismissed() === current.token) return false - - const value = current.query.trim() - const total = optionsForQuery(value).length - if (total === 0 && !canCreateForQuery(current.query)) return false - - if (pendingPromptSync) clearTimeout(pendingPromptSync) - pendingPromptSync = undefined - setInput(ref.current.input) - setSyncingPrompt(false) - lockKeyboardSelection() - setSelected((current) => stepSelection(current, total || 1, delta)) - props.ctx.requestRender() - return true - } - - const accept = () => { - const ref = props.ctx.prompt() - if (!ref) return false - - if (isReloadCommand(ref.current.input)) { - void executeReloadInPrompt(ref) - return true - } - - if (dialogBlockingInput()) return true - - const current = findTrailingHashtagTrigger(ref.current.input) - if (!current || dismissed() === current.token) return false - - const live = optionsForQuery(current.query.trim()) - const liveIndex = Math.min(selected(), Math.max(live.length - 1, 0)) - - if (syncingPrompt()) { - if (live.length > 0) { - chooseItem(live[liveIndex] ?? live[0]) - return true - } - if (canCreateForQuery(current.query)) { - void createSnippetDraft(current.query) - return true - } - return false - } - - if (visible() && options().length > 0) { - choose(Math.min(selected(), options().length - 1)) - return true - } - - if (loading()) return true - - if (live.length > 0) { - chooseItem(live[liveIndex] ?? live[0]) - return true - } - - if (canCreateForQuery(current.query)) { - void createSnippetDraft(current.query) - return true - } - - return false - } - - const canAcceptSubmit = (ref: TuiPromptRef) => { - if (isReloadCommand(ref.current.input)) return true - if (dialogBlockingInput()) return true - - const current = findTrailingHashtagTrigger(ref.current.input) - return !!current && dismissed() !== current.token - } - - props.controller.accept = accept - props.controller.reload = () => { - const ref = props.ctx.prompt() - if (ref) void executeReloadInPrompt(ref) - } - props.controller.insertTrigger = () => { - const ref = props.ctx.prompt() - if (!ref) return - syncPromptInput(ref, insertSnippetTrigger(ref.current.input)) - ref.focus() - } - props.controller.navigate = navigate - - createEffect(() => { - const ref = props.ctx.prompt() - if (!ref) return - - if (commandTimer) clearTimeout(commandTimer) - commandDispose?.() - commandDispose = undefined - - commandTimer = setTimeout(() => { - commandTimer = undefined - if (disposed) return - - commandDispose = props.ctx.api.keymap.registerLayer({ - commands: [ - { - namespace: "palette", - name: "snippets.reload", - title: "Reload snippets", - desc: "Reload snippet files from disk", - category: "Prompt", - slashName: "snippets:reload", - run() { - void executeReloadInPrompt(ref) - }, - }, - { - namespace: "palette", - name: "snippets.insert", - title: "Insert snippet", - desc: "Insert a snippet trigger into the prompt", - category: "Prompt", - run() { - syncPromptInput(ref, insertSnippetTrigger(ref.current.input)) - ref.focus() - }, - }, - { - namespace: "palette", - name: "snippets.accept", - title: "Accept snippet autocomplete", - category: "Prompt", - hidden: true, - enabled: () => ref.focused && canAcceptSubmit(ref), - run() { - if (accept()) return - ref.submit() - }, - }, - ], - bindings: acceptSnippetBindings(props.ctx.api), - }) - }, 0) - }) - - const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => { - const ref = props.ctx.prompt() - const name = event.name?.toLowerCase() - - if (ref && isReloadCommand(ref.current.input) && (name === "return" || name === "enter")) { - void executeReloadInPrompt(ref) - event.preventDefault() - event.stopPropagation() - return - } - - if (dialogBlockingInput()) return - if (!visible()) return - - if ((name === "up" || name === "down") && navigate(name === "up" ? -1 : 1)) { - event.preventDefault() - event.stopPropagation() - return - } - - if (name === "escape") { - setDismissed(match()?.token) - event.preventDefault() - event.stopPropagation() - return - } - - if (name === "tab" && (options().length > 0 || canCreate())) { - if (options().length > 0) choose() - else void createSnippetDraft() - event.preventDefault() - event.stopPropagation() - return - } - - schedulePromptSync() - }) - - onCleanup(() => offKeyboard()) - - return ( - - - (scroll = ref)} backgroundColor={props.ctx.api.theme.current.backgroundMenu} height={menuHeight()} scrollbarOptions={{ visible: false }}> - void createSnippetDraft()} />}> - {(item, index) => ( - { - if (!allowMouseHover()) return - if (!recordMouseMove(x, y)) return - setInputMode("mouse") - }} - onMouseOver={() => { - if (inputMode() === "mouse") setSelected(index()) - }} - onMouseDown={() => { - setInputMode("mouse") - setLastMousePos(undefined) - setSelected(index()) - }} - onMouseUp={() => choose(index())} - /> - )} - - - - - ) - - function optionsForQuery(value: string): AutocompleteItem[] { - const snippetOptions: AutocompleteItem[] = filterSnippets(snippets(), value).map((snippet) => ({ - kind: "snippet", - id: `snippet:${snippet.name}`, - label: `#${snippet.name}`, - description: describeSnippet(snippet), - aliases: matchedAliases(snippet, value), - snippet, - })) - const skillOptions: AutocompleteItem[] = filterSkills(skills(), value).map((skill) => ({ - kind: "skill", - id: `skill:${skill.name}`, - label: `#skill(${skill.name})`, - description: describeSkill(skill), - aliases: [], - skill, - })) - return [...snippetOptions, ...skillOptions] - } - - function canCreateForQuery(value: string) { - if (loading()) return false - const name = normalizeSnippetName(value) - return name.length > 0 && optionsForQuery(value).length === 0 - } - - function fallbackLabel() { - if (loading()) return "Loading snippets and skills..." - if (snippets().length === 0 && skills().length === 0) return "No snippets or skills found" - return "No matching snippets or skills" - } - - function lockKeyboardSelection() { - setInputMode("keyboard") - setIgnoreMouseUntil(Date.now() + MOUSE_HOVER_SUPPRESS_MS) - } - - function allowMouseHover() { - return Date.now() >= ignoreMouseUntil() - } - - function recordMouseMove(x: number, y: number) { - const last = lastMousePos() - if (last?.x === x && last.y === y) return false - setLastMousePos({ x, y }) - return true - } - - function beginDialogHandoff() { - const until = Date.now() + MOUSE_HOVER_SUPPRESS_MS - setDialogHandoffUntil(until) - if (pendingDialogHandoff) clearTimeout(pendingDialogHandoff) - pendingDialogHandoff = setTimeout(() => { - if (dialogHandoffUntil() === until) setDialogHandoffUntil(0) - props.ctx.requestRender() - }, MOUSE_HOVER_SUPPRESS_MS + 25) - } - - function restorePromptFocus(ref: TuiPromptRef) { - if (pendingPromptFocus) clearTimeout(pendingPromptFocus) - pendingPromptFocus = setTimeout(() => ref.focus(), 175) - } - - function schedulePromptSync() { - const ref = props.ctx.prompt() - if (!ref || dialogBlockingInput()) return - const previous = input() - setSyncingPrompt(true) - setMenuEpoch((current) => current + 1) - if (pendingPromptSync) clearTimeout(pendingPromptSync) - pendingPromptSync = setTimeout(() => { - const next = ref.current.input ?? "" - if (next !== previous) setInput(next) - setSyncingPrompt(false) - props.ctx.requestRender() - }, 0) - } - - async function executeReloadInPrompt(ref: TuiPromptRef) { - const count = await reloadSnippetsInTui(props.ctx.api, refresh) - syncPromptInput(ref, "") - setDismissed(undefined) - ref.focus() - props.ctx.requestRender() - setTimeout(() => { - props.ctx.api.ui.toast({ - title: "Snippets reloaded", - message: `Reloaded ${count} snippet${count === 1 ? "" : "s"}.`, - duration: 3000, - }) - props.ctx.requestRender() - }, 0) - } - - async function createSnippetDraft(rawQuery?: string) { - const ref = props.ctx.prompt() - const name = normalizeSnippetName(rawQuery ?? query()) - if (!ref || !name || creating()) return - const current = findTrailingHashtagTrigger(ref.current.input) - const nextInput = current ? `${ref.current.input.slice(0, current.start)}#${name}` : `#${name}` - const dismissedToken = `#${name}` - const editor = resolveExternalEditor() - - if (!editor) { - props.ctx.api.ui.toast({ variant: "warning", message: "Set VISUAL or EDITOR to create snippets from the TUI." }) - return - } - - props.ctx.api.ui.dialog.setSize("medium") - setDialogOpen(true) - props.ctx.api.ui.dialog.replace(() => ( - { - setDialogOpen(false) - beginDialogHandoff() - props.ctx.api.ui.dialog.clear() - restorePromptFocus(ref) - }} - onConfirm={() => { - setDialogOpen(false) - beginDialogHandoff() - props.ctx.api.ui.dialog.clear() - void (async () => { - setCreating(true) - try { - syncPromptInput(ref, nextInput) - const filePath = await ensureSnippetDraft(name, props.ctx.api.state.path.directory) - await addPendingDraft(props.ctx.api.state.path.directory, name) - setDismissed(dismissedToken) - setCreating(false) - await openExternalEditor(props.ctx.api, filePath, editor) - } catch (error) { - props.ctx.api.ui.toast({ variant: "error", message: `Failed to create snippet: ${error instanceof Error ? error.message : String(error)}` }) - syncPromptInput(ref, nextInput) - setDismissed(undefined) - } finally { - setCreating(false) - restorePromptFocus(ref) - } - })() - }} - /> - )) - } -} - -function OptionRow(props: { - ctx: PromptContext - item: AutocompleteItem - query: string - selected: boolean - selectedFg: RGBA - onMouseMove: (x: number, y: number) => void - onMouseOver: () => void - onMouseDown: () => void - onMouseUp: () => void -}) { - const fg = () => (props.selected ? props.selectedFg : props.ctx.api.theme.current.text) - const mutedFg = () => (props.selected ? props.selectedFg : props.ctx.api.theme.current.textMuted) - - return ( - props.onMouseMove(event.x, event.y)} - onMouseOver={props.onMouseOver} - onMouseDown={props.onMouseDown} - onMouseUp={props.onMouseUp} - > - - - - 0}> - - - - - - - - - - - ) -} - -function FallbackRow(props: { ctx: PromptContext; canCreate: boolean; creating: boolean; label: string; draftName: string; onCreate: () => void }) { - return ( - - {props.label} - - } - > - - {props.creating ? "Creating snippet..." : `Add new Snippet: #${props.draftName}`} - - - ) -} - -function Highlighted(props: { text: string; query: string; fg: RGBA }) { - return {(part) => (part.match ? {part.text} : part.text)} -} - -function syncPromptInput(prompt: TuiPromptRef, input: string) { - prompt.set({ input, mode: prompt.current.mode, parts: [...prompt.current.parts] }) -} - -function acceptSnippetBindings(api: TuiPluginApi) { - const command = "snippets.accept" - const title = "Accept snippet autocomplete" - - if (!api.tuiConfig.keybinds.has("input.submit")) { - return [{ key: "input_submit", cmd: command, desc: title }] - } - - return api.tuiConfig.keybinds.get("input.submit").map((binding) => ({ - ...binding, - cmd: command, - desc: binding.desc ?? title, - })) -} - -function selectedText(theme: PromptContext["api"]["theme"]["current"]) { - if (theme.background.a !== 0) return theme.background - const { r, g, b } = theme.primary - const luminance = 0.299 * r + 0.587 * g + 0.114 * b - return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255) -} - -function normalizeSnippetName(input: string) { - return input - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/-{2,}/g, "-") - .replace(/^-+|-+$/g, "") -} - -async function reloadSnippetsInTui(api: TuiPluginApi, refresh: () => Promise) { - await refresh() - await markSnippetReloadRequested(api.state.path.directory) - return loadSnippets(api.state.path.directory).then((items) => items.length) -} - -type Editor = { command: string; env: "VISUAL" | "EDITOR" } - -function resolveExternalEditor(): Editor | undefined { - const visual = Bun.env.VISUAL?.trim() - if (visual) return { command: visual, env: "VISUAL" } - const editor = Bun.env.EDITOR?.trim() - if (editor) return { command: editor, env: "EDITOR" } - return undefined -} - -function editorBinary(editor: Editor) { - return editor.command.split(/\s+/)[0] ?? editor.command -} - -function usesTerminalUi(editor: Editor) { - return !/^(code|cursor|windsurf|subl|zed|open|idea|webstorm|phpstorm|pycharm|rubymine|goland|clion|rider|datagrip)$/i.test(editorBinary(editor)) -} - -async function openExternalEditor(api: TuiPluginApi, filePath: string, editor: Editor) { - const args = editor.command.split(/\s+/).filter(Boolean) - const command = args.shift() - if (!command) return false - - if (usesTerminalUi(editor)) api.renderer.suspend() - try { - await new Promise((resolve, reject) => { - const child = spawn(command, [...args, filePath], { stdio: usesTerminalUi(editor) ? "inherit" : "ignore", detached: !usesTerminalUi(editor) }) - child.on("error", reject) - child.on("exit", () => resolve()) - if (!usesTerminalUi(editor)) { - child.unref() - resolve() - } - }) - return true - } finally { - if (usesTerminalUi(editor)) { - api.renderer.resume() - api.renderer.console.clear() - api.renderer.requestRender() - } - } -} diff --git a/src/modules/vim/index.tsx b/src/modules/vim/index.tsx deleted file mode 100644 index cbf8035..0000000 --- a/src/modules/vim/index.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { KeyEvent, ParsedKey } from "@opentui/core" -import { onCleanup } from "solid-js" -import type { Accessor } from "solid-js" -import type { PromptContext, PromptModule } from "../../prompt/types" -import type { SnippetController } from "../snippets/types" -import { applyVimCursorStyle, focusedInput } from "./actions" -import type { VimConfig } from "./config" -import { createVimConfig } from "./config" -import { displayToChar } from "./map" -import { keyNotation } from "./keys" -import { createVimLog } from "./log" -import type { VimLog } from "./log" -import { createVimState } from "./state" -import { createVimeeAdapter } from "./vimee" -import { VimStatus } from "./view" - -export function createVimModule(options?: unknown, enabled: Accessor = () => true, snippets?: SnippetController): PromptModule { - const config = createVimConfig(options) - const log = createVimLog(config) - const state = createVimState(config.defaultMode, log) - log("module.create", { defaultMode: config.defaultMode, keymapTimeout: config.keymapTimeout }) - - return { - id: "vim", - order: 0, - setup(ctx) { - log("module.setup", { kind: ctx.kind, sessionID: ctx.sessionID, workspaceID: ctx.workspaceID }) - }, - renderAbove(ctx) { - return - }, - renderRight(ctx) { - return readablePending(state.pending())} subscribe={state.subscribe} enabled={enabled} theme={ctx.api.theme.current} pendingDisplayDelay={config.pendingDisplayDelay} disabled={ctx.disabled} log={log} requestRender={ctx.requestRender} /> - }, - } -} - -function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType; snippets?: SnippetController; enabled: Accessor; log: VimLog }) { - let cursorStyleMode = "" - const vimee = createVimeeAdapter(props.state, props.config, props.log) - props.log("keyboard.mount", { kind: props.ctx.kind }) - - const cursorStyleTimer = setInterval(syncCursorStyle, 50) - const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => { - props.log("keyboard.event", { - name: event.name, - ctrl: event.ctrl, - meta: event.meta, - shift: event.shift, - defaultPrevented: event.defaultPrevented, - propagationStopped: event.propagationStopped, - mode: props.state.mode(), - pending: props.state.pending(), - }) - - if (!canHandleKeys(props)) { - props.log("keyboard.skip", { enabled: props.enabled(), disabled: props.ctx.disabled, visible: props.ctx.visible, dialogOpen: props.ctx.api.ui.dialog.open }) - return - } - - const key = keyNotation(event) - if (!key) { - props.log("keyboard.no_key") - return - } - - if (passThroughKey(event, key, props.state.mode())) return - - if (key === "" && props.state.mode() === "normal" && !props.state.pending()) { - props.log("keyboard.fallthrough", { key, mode: props.state.mode() }) - return - } - - if (sendNavigationKey(event, props.ctx, key, props.state.mode(), props.snippets)) { - syncCursorStyle(true) - props.ctx.requestRender() - return - } - - const modeBefore = props.state.mode() - let consumed = false - try { - consumed = vimee.handle(event, key, props.ctx) - } finally { - if (consumed || props.state.mode() !== modeBefore) { - event.preventDefault() - event.stopPropagation() - } - } - if (consumed) { - syncCursorStyle(true) - props.ctx.requestRender() - } - }) - - onCleanup(() => { - props.log("keyboard.cleanup", { kind: props.ctx.kind }) - offKeyboard() - vimee.cleanup() - clearInterval(cursorStyleTimer) - }) - - return - - function syncCursorStyle(force = false) { - const mode = props.state.mode() - if (!force && cursorStyleMode === mode) return - if (applyVimCursorStyle(props.ctx, props.config.cursorStyles[mode])) { - cursorStyleMode = mode - props.log("cursor.style", { mode, style: props.config.cursorStyles[mode].style, blinking: props.config.cursorStyles[mode].blinking }) - } - } -} - -function canHandleKeys(props: { ctx: PromptContext; enabled: Accessor }) { - return props.enabled() && !props.ctx.disabled && props.ctx.visible !== false && !props.ctx.api.ui.dialog.open -} - -function readablePending(sequence: string) { - if (!sequence) return undefined - return sequence.replaceAll("><", " ") -} - -function passThroughKey(event: KeyEvent, key: string, mode: string) { - if (mode !== "normal") return false - return event.super === true || isArrowKey(key) || key === "" -} - -function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string, snippets?: SnippetController) { - if (mode !== "normal") return false - const delta = snippetNavigationDelta(key) - if (delta !== 0 && snippets?.navigate?.(delta)) { - event.preventDefault() - event.stopPropagation() - return true - } - - const forwarded = commandNavigationKey(ctx, key) - if (!forwarded) return false - - event.preventDefault() - event.stopPropagation() - ctx.api.renderer.keyInput.processParsedKey(forwarded) - return true -} - -function snippetNavigationDelta(key: string) { - if (key === "j") return 1 - if (key === "k") return -1 - return 0 -} - -function commandNavigationKey(ctx: PromptContext, key: string): ParsedKey | undefined { - if (!isPromptFocused(ctx)) return navigationKey(key) - if (!isNativeCompletionToken(ctx)) return undefined - if (key === "j") return arrowKey("down", "\u001B[B") - if (key === "k") return arrowKey("up", "\u001B[A") - return undefined -} - -function isPromptFocused(ctx: PromptContext) { - return ctx.prompt()?.focused === true && !!focusedInput(ctx) -} - -function isNativeCompletionToken(ctx: PromptContext) { - const text = ctx.prompt()?.current.input ?? focusedInput(ctx)?.plainText ?? "" - const input = focusedInput(ctx) - const charIdx = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0)) - const beforeCursor = text.slice(0, Math.min(charIdx + 1, text.length)) - return /^\/\S*$/.test(beforeCursor) || /(?:^|\s)@\S*$/.test(beforeCursor) -} - -function navigationKey(key: string): ParsedKey | undefined { - if (key === "h") return arrowKey("left", "\u001B[D") - if (key === "j") return arrowKey("down", "\u001B[B") - if (key === "k") return arrowKey("up", "\u001B[A") - if (key === "l") return arrowKey("right", "\u001B[C") - return undefined -} - -function isArrowKey(key: string) { - return key === "" || key === "" || key === "" || key === "" -} - -function arrowKey(name: string, sequence: string): ParsedKey { - return { - name, - ctrl: false, - meta: false, - shift: false, - option: false, - sequence, - number: false, - raw: sequence, - eventType: "press", - source: "raw", - super: false, - } -} diff --git a/src/modules/vim/view.tsx b/src/modules/vim/view.tsx deleted file mode 100644 index 3cc85d9..0000000 --- a/src/modules/vim/view.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { TextRenderable } from "@opentui/core" -import { onCleanup } from "solid-js" -import type { Accessor } from "solid-js" -import type { PromptContext } from "../../prompt/types" -import type { VimLog } from "./log" -import type { VimMode, VimStateListener } from "./state" - -const STATUS_SYNC_MS = 50 - -type VimStatusProps = { - mode: Accessor - pending: Accessor - subscribe: (listener: VimStateListener) => () => void - enabled: Accessor - theme: PromptContext["api"]["theme"]["current"] - pendingDisplayDelay?: number - disabled?: boolean - log?: VimLog - requestRender?: () => void -} - -export function VimStatus(props: VimStatusProps) { - let modeText: TextRenderable | undefined - let pendingText: TextRenderable | undefined - let displayedMode = props.mode() - let displayedPending: string | undefined - let displayedEnabled = props.enabled() - let pendingTimer: ReturnType | undefined - let scheduledPending: string | undefined - - const sync = () => { - const nextMode = props.mode() - const nextPending = props.pending() - let changed = false - - if (displayedMode !== nextMode) { - props.log?.("status.sync", { fromMode: displayedMode, toMode: nextMode, fromPending: displayedPending, toPending: displayedPending }) - displayedMode = nextMode - changed = true - } - - const nextEnabled = props.enabled() - if (displayedEnabled !== nextEnabled) { - props.log?.("status.enabled", { from: displayedEnabled, to: nextEnabled }) - displayedEnabled = nextEnabled - changed = true - } - - if (changed) updateModeText() - syncPending(nextPending) - if (changed) props.requestRender?.() - } - - const unsubscribe = props.subscribe(sync) - const timer = setInterval(sync, STATUS_SYNC_MS) - onCleanup(() => { - unsubscribe() - clearInterval(timer) - if (pendingTimer) clearTimeout(pendingTimer) - modeText = undefined - pendingText = undefined - }) - - return ( - - { pendingText = ref; updatePendingText() }} fg={props.theme.info}>{displayedPending ? `${displayedPending} ` : ""} - { modeText = ref; updateModeText() }} fg={modeColor(displayedMode, displayedEnabled, props.theme, props.disabled)}>{displayedEnabled ? modeLabel(displayedMode) : ""} - - ) - - function updateModeText() { - if (!modeText) return - modeText.content = displayedEnabled ? modeLabel(displayedMode) : "" - modeText.fg = modeColor(displayedMode, displayedEnabled, props.theme, props.disabled) - } - - function updatePendingText() { - if (!pendingText) return - pendingText.content = displayedEnabled && displayedPending ? `${displayedPending} ` : "" - } - - function syncPending(nextPending: string | undefined) { - if (!nextPending) { - if (pendingTimer) clearTimeout(pendingTimer) - pendingTimer = undefined - scheduledPending = undefined - setDisplayedPending(undefined) - return - } - - if (displayedPending === nextPending) return - if (scheduledPending === nextPending) return - - if (pendingTimer) clearTimeout(pendingTimer) - scheduledPending = nextPending - const delay = props.pendingDisplayDelay ?? 120 - if (delay <= 0) { - scheduledPending = undefined - setDisplayedPending(nextPending) - return - } - - pendingTimer = setTimeout(() => { - pendingTimer = undefined - scheduledPending = undefined - if (props.pending() === nextPending) setDisplayedPending(nextPending) - }, delay) - } - - function setDisplayedPending(nextPending: string | undefined) { - if (displayedPending === nextPending) return - props.log?.("status.sync", { fromMode: displayedMode, toMode: displayedMode, fromPending: displayedPending, toPending: nextPending }) - displayedPending = nextPending - updatePendingText() - props.requestRender?.() - } -} - -function modeColor(mode: VimMode, enabled: boolean, theme: PromptContext["api"]["theme"]["current"], disabled?: boolean) { - if (!enabled) return undefined - if (disabled) return theme.textMuted - return mode === "insert" ? theme.success : theme.warning -} - -function modeLabel(mode: VimMode) { - if (mode === "visual") return "VISUAL" - if (mode === "visual-line") return "VISUAL LINE" - return mode === "normal" ? "NORMAL" : "INSERT" -} diff --git a/src/plugin.tsx b/src/plugin.tsx deleted file mode 100644 index bd32058..0000000 --- a/src/plugin.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" -import { createSignal } from "solid-js" -import { PromptRoot } from "./prompt/root" -import type { PromptModule } from "./prompt/types" -import { createVimModule } from "./modules/vim" -import { createSnippetsModule } from "./modules/snippets" -import type { SnippetController } from "./modules/snippets/types" -import { checkAutoUpdate } from "./update" - -const tui: TuiPlugin = async (api, options, meta) => { - const vimEnabledKey = `${meta.id}:enabled` - const [vimEnabled, setVimEnabled] = createSignal(api.kv.get(vimEnabledKey, true) === true) - - api.keymap.registerLayer({ - commands: [ - { - namespace: "palette", - name: "opencode-vim.toggle", - title: "Toggle Vim Mode", - desc: "Enable or disable Vim key handling", - category: "Vim", - slashName: "vim", - run() { - const next = !vimEnabled() - setVimEnabled(next) - api.kv.set(vimEnabledKey, next) - api.ui.toast({ variant: "info", message: `Vim mode ${next ? "enabled" : "disabled"}` }) - }, - }, - ], - }) - - if (readAutoUpdate(options)) { - let timer: Timer | undefined - api.lifecycle.onDispose(() => { - if (timer) clearTimeout(timer) - }) - - void checkAutoUpdate(meta, api.lifecycle.signal).then((result) => { - if (!result.updated) return - - timer = setTimeout(() => { - api.ui.toast({ variant: "info", message: "Updating opencode-vim. Restart OpenCode to finish." }) - }, 5000) - }).catch(() => {}) - } - - const moduleCache = new Map() - const createModules = (key: string) => { - const cached = moduleCache.get(key) - if (cached) return cached - - const modules: PromptModule[] = [] - const snippets: SnippetController = {} - if (hasSnippetsPlugin(api)) modules.push(createSnippetsModule(snippets)) - modules.push(createVimModule(options, vimEnabled, snippets)) - moduleCache.set(key, modules) - return modules - } - - api.slots.register({ - order: 50, - slots: { - home_prompt(ctx, props) { - return - }, - session_prompt(ctx, props) { - return ( - - ) - }, - }, - }) -} - -function readAutoUpdate(options: unknown) { - if (!options || typeof options !== "object") return true - const value = (options as { autoUpdate?: unknown }).autoUpdate - return typeof value === "boolean" ? value : true -} - -function hasSnippetsPlugin(api: TuiPluginApi) { - return configHasSnippets(api.state.config.plugin) || configHasSnippets(api.tuiConfig.plugin) || api.plugins.list().some(pluginHasSnippets) -} - -function configHasSnippets(plugins: unknown) { - if (!Array.isArray(plugins)) return false - return plugins.some((plugin) => specHasSnippets(Array.isArray(plugin) ? plugin[0] : plugin)) -} - -function pluginHasSnippets(plugin: ReturnType[number]) { - return plugin.enabled && (specHasSnippets(plugin.id) || specHasSnippets(plugin.spec)) -} - -function specHasSnippets(spec: unknown) { - if (typeof spec !== "string") return false - return spec === "opencode-snippets" || spec.startsWith("opencode-snippets@") || spec.includes("/opencode-snippets") -} - -const plugin: TuiPluginModule & { id: string } = { - id: "local.opencode-vim", - tui, -} - -export default plugin diff --git a/src/prompt/host.tsx b/src/prompt/host.tsx deleted file mode 100644 index 917de59..0000000 --- a/src/prompt/host.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { TuiPromptRef } from "@opencode-ai/plugin/tui" -import type { JSX } from "@opentui/solid" -import type { PromptContext, PromptModule } from "./types" -import { submitModules } from "./modules" - -type HostPromptProps = { - ctx: PromptContext - modules: PromptModule[] - ref?: (ref: TuiPromptRef | undefined) => void - right?: JSX.Element -} - -export function HostPrompt(props: HostPromptProps) { - const api = props.ctx.api - - const setRef = (ref: TuiPromptRef | undefined) => { - props.ctx.setPromptRef(ref) - props.ref?.(ref) - } - - const onSubmit = () => { - if (submitModules(props.modules, props.ctx)) return - props.ctx.submitHost() - } - - return ( - - ) -} diff --git a/src/prompt/modules.ts b/src/prompt/modules.ts deleted file mode 100644 index 9e1e5af..0000000 --- a/src/prompt/modules.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { PromptContext, PromptModule } from "./types" - -export function sortModules(modules: PromptModule[]) { - return [...modules].sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id)) -} - -export function submitModules(modules: PromptModule[], ctx: PromptContext) { - for (const module of modules) { - if (module.onSubmit?.(ctx) === true) return true - } - return false -} - -export function notifyPromptRef(modules: PromptModule[], ref: ReturnType, ctx: PromptContext) { - for (const module of modules) module.onPromptRef?.(ref, ctx) -} diff --git a/src/prompt/root.tsx b/src/prompt/root.tsx deleted file mode 100644 index a400fdd..0000000 --- a/src/prompt/root.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { onCleanup } from "solid-js" -import type { JSX } from "@opentui/solid" -import { HostPrompt } from "./host" -import { notifyPromptRef, sortModules } from "./modules" -import type { PromptContext, PromptRootProps } from "./types" - -export function PromptRoot(props: PromptRootProps) { - const modules = sortModules(props.modules) - const api = props.api - let current: ReturnType - const ctx: PromptContext = { - api, - slot: props.slot, - kind: props.kind, - get sessionID() { - return props.sessionID - }, - get workspaceID() { - return props.workspaceID - }, - get visible() { - return props.visible - }, - get disabled() { - return props.disabled - }, - prompt: () => current, - setPromptRef(ref) { - current = ref - notifyPromptRef(modules, ref, ctx) - }, - submitHost: () => props.onSubmit?.(), - requestRender: () => api.renderer.requestRender(), - } - - const cleanups = modules.flatMap((module) => { - const cleanup = module.setup?.(ctx) - return typeof cleanup === "function" ? [cleanup] : [] - }) - onCleanup(() => { - for (const cleanup of cleanups) cleanup() - }) - - const moduleAbove = modules.map((module) => module.renderAbove?.(ctx)).filter(Boolean) as JSX.Element[] - const moduleBelow = modules.map((module) => module.renderBelow?.(ctx)).filter(Boolean) as JSX.Element[] - const moduleRight = modules.map((module) => module.renderRight?.(ctx)).filter(Boolean) as JSX.Element[] - const hostRight = renderHostRight(props) - - return ( - - {moduleAbove} - } /> - {moduleBelow} - - ) -} - -function renderHostRight(props: PromptRootProps) { - const Slot = props.api.ui.Slot - if (props.kind === "home") return - return -} - -function RightItems(props: { items: JSX.Element[] }) { - const items = props.items.filter(Boolean) - if (items.length === 0) return undefined - return {items} -} diff --git a/src/prompt/types.ts b/src/prompt/types.ts deleted file mode 100644 index 589a1c9..0000000 --- a/src/prompt/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { JSX } from "@opentui/solid" -import type { TuiPluginApi, TuiPromptRef, TuiSlotContext } from "@opencode-ai/plugin/tui" - -export type PromptKind = "home" | "session" - -export type PromptContext = { - api: TuiPluginApi - slot: TuiSlotContext - kind: PromptKind - sessionID?: string - workspaceID?: string - visible?: boolean - disabled?: boolean - prompt: () => TuiPromptRef | undefined - setPromptRef: (ref: TuiPromptRef | undefined) => void - submitHost: () => void - requestRender: () => void -} - -export type PromptModule = { - id: string - order?: number - setup?: (ctx: PromptContext) => void | (() => void) - onPromptRef?: (ref: TuiPromptRef | undefined, ctx: PromptContext) => void - onSubmit?: (ctx: PromptContext) => boolean | void - renderAbove?: (ctx: PromptContext) => JSX.Element - renderBelow?: (ctx: PromptContext) => JSX.Element - renderRight?: (ctx: PromptContext) => JSX.Element -} - -export type PromptRootProps = { - api: TuiPluginApi - slot: TuiSlotContext - kind: PromptKind - sessionID?: string - workspaceID?: string - visible?: boolean - disabled?: boolean - onSubmit?: () => void - ref?: (ref: TuiPromptRef | undefined) => void - modules: PromptModule[] -} diff --git a/src/update.ts b/src/update.ts deleted file mode 100644 index 5da1c30..0000000 --- a/src/update.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { rm } from "node:fs/promises" -import { basename, dirname, join } from "node:path" -import { fileURLToPath } from "node:url" -import type { TuiPluginMeta } from "@opencode-ai/plugin/tui" - -type PackageJson = { - name?: string - version?: string - dependencies?: Record -} - -type UpdateResult = - | { updated: true; name: string; current: string; latest: string } - | { updated: false; error: "remove_failed"; name: string; current: string; latest: string } - | { updated: false } - -export async function checkAutoUpdate(meta: TuiPluginMeta, signal: AbortSignal): Promise { - if (meta.source !== "npm") return { updated: false } - - const packageDir = await findPackageDir() - if (!packageDir) return { updated: false } - - const pkg = await readPackageJson(join(packageDir, "package.json")) - if (!pkg.name || !pkg.version) return { updated: false } - - const latest = await fetchLatestVersion(pkg.name, signal) - if (!latest || !isVersionNewer(latest, pkg.version)) return { updated: false } - - const removeDir = await updateRemoveDir(packageDir, pkg.name) - try { - await rm(removeDir, { recursive: true, force: true }) - } catch { - return { updated: false, error: "remove_failed", name: pkg.name, current: pkg.version, latest } - } - - return { updated: true, name: pkg.name, current: pkg.version, latest } -} - -async function findPackageDir() { - let dir = dirname(fileURLToPath(import.meta.url)) - for (;;) { - const packagePath = join(dir, "package.json") - const pkg = await readPackageJson(packagePath) - if (pkg?.name === "opencode-vim") return dir - - const parent = dirname(dir) - if (parent === dir) return undefined - dir = parent - } -} - -async function updateRemoveDir(packageDir: string, name: string) { - const nodeModulesDir = dirname(packageDir) - if (basename(nodeModulesDir) !== "node_modules") return packageDir - - const wrapperDir = dirname(nodeModulesDir) - const wrapperPkg = await readPackageJson(join(wrapperDir, "package.json")) - return wrapperPkg?.dependencies?.[name] ? wrapperDir : packageDir -} - -async function readPackageJson(path: string): Promise { - try { - const file = Bun.file(path) - if (!(await file.exists())) return undefined - const data = await file.json() - return data && typeof data === "object" ? (data as PackageJson) : undefined - } catch { - return undefined - } -} - -async function fetchLatestVersion(name: string, signal: AbortSignal) { - try { - const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, { signal }) - if (!response.ok) return undefined - const data = await response.json() - return data && typeof data === "object" && typeof data.version === "string" ? data.version : undefined - } catch { - return undefined - } -} - -function isVersionNewer(latest: string, current: string) { - const next = parseVersion(latest) - const prev = parseVersion(current) - if (!next || !prev) return false - - for (let i = 0; i < 3; i++) { - if (next.parts[i] !== prev.parts[i]) return next.parts[i] > prev.parts[i] - } - - if (!next.pre.length && prev.pre.length) return true - if (next.pre.length && !prev.pre.length) return false - - for (let i = 0; i < Math.max(next.pre.length, prev.pre.length); i++) { - const a = next.pre[i] - const b = prev.pre[i] - if (a === undefined) return false - if (b === undefined) return true - if (a === b) continue - - const aNumber = /^\d+$/.test(a) ? Number(a) : undefined - const bNumber = /^\d+$/.test(b) ? Number(b) : undefined - if (aNumber !== undefined && bNumber !== undefined) return aNumber > bNumber - if (aNumber !== undefined) return false - if (bNumber !== undefined) return true - return a > b - } - - return false -} - -function parseVersion(version: string) { - const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.+)?$/) - if (!match) return undefined - return { - parts: [Number(match[1]), Number(match[2]), Number(match[3])], - pre: match[4]?.split(".") ?? [], - } -} diff --git a/tui.json.example b/tui.json.example deleted file mode 100644 index d9ac4ef..0000000 --- a/tui.json.example +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://opencode.ai/tui.json", - "plugin": [ - [ - "./plugin/opencode-vim", - { - "vim": { - "defaultMode": "insert", - "keymapTimeout": 500, - "pendingDisplayDelay": 120, - "cursorStyles": { - "insert": { - "style": "line", - "blinking": true - }, - "normal": { - "style": "block", - "blinking": true - } - }, - "debug": false, - "debugPath": "/home/you/.cache/opencode/opencode-vim.log", - "keymaps": { - "insert": { - "kj": "normal" - }, - "normal": { - "i": "insert", - "": "submit", - "Y": "y$" - } - } - } - } - ] - ] -} From 4221695b522869f03f84557fed26eaf9ee0d77b5 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 13:57:04 -0400 Subject: [PATCH 03/15] MIT license and contributing docs --- CONTRIBUTING.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 ++++++++++++++ package-lock.json | 1 + package.json | 1 + 4 files changed, 93 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0f1fb44 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing + +Bug reports, fixes, and improvements are welcome. + +## Reporting bugs + +Include the OpenCode and plugin versions, your operating system and terminal, +and steps to reproduce the problem. Mention the Vim mode, relevant custom +keybindings, and whether you use a terminal multiplexer such as tmux. Describe +what you expected and what happened instead. + +For larger changes, open an issue first so we can agree on the approach. + +## Development setup + +Fork and clone the repository, then create a branch based on `dev`. +Use Node.js 22 (the CI version), npm, and a current version of Bun. + +```sh +npm ci +``` + +The test helpers support Linux and macOS on x64 and ARM64. E2E tests also require +tmux. Downloads use `tar`, plus `unzip` on macOS. + +## Running tests + +```sh +bun run typecheck +bun run test +bun run test:e2e +``` + +- `test` covers editing behavior, plugin integration, source and npm package + loading, and comparisons against a pinned Neovim version downloaded + automatically. Three optional integration cases require an OpenCode source + checkout via `OPENCODE_SOURCE` and otherwise skip. +- `test:e2e` builds and packs the current plugin, then runs real terminal + interactions against the latest stable OpenCode 2 release. Each scenario gets + isolated configuration and a fresh session. No model requests are submitted. + Captures, logs, and results are saved under `test-results/e2e/`. + +Internet access is needed for initial binary downloads and for E2E tests to +resolve the latest OpenCode release. Downloaded binaries are cached under +`node_modules/.cache/`. + +Use `bun run build` to build the plugin into `dist/`. + +## Adding tests + +Add focused regression coverage for bug fixes. Use E2E scenarios when the behavior +depends on real OpenCode keyboard handling, focus, dialogs, or tabs. + +E2E scenarios live in `test/e2e/scenarios/` and are registered in +`test/e2e/run.ts`. Reuse the shared fixture and terminal helpers, and wait for +expected screen content rather than using fixed delays. + +## Pull requests + +- Target `dev`. +- Follow the surrounding code style and favor straightforward, readable code. +- Keep PRs focused and descriptions short, clear, and to the point. Explain what + changed, why, and how you tested it. Respect reviewers' time: avoid walls of + text, repetitive summaries, and unnecessary detail. Link related issues. +- If you use AI, understand, review, and test the changes before submitting. + You are responsible for everything in your PR. + +## License + +Contributions are licensed under the project's [MIT License](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..95d4250 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tarquinen and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package-lock.json b/package-lock.json index fe878a3..fd65cb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "opencode-vim", "version": "0.0.26", + "license": "MIT", "dependencies": { "@vimee/core": "^0.3.0" }, diff --git a/package.json b/package.json index 5196336..ae42bde 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "name": "opencode-vim", "version": "0.0.26", + "license": "MIT", "type": "module", "repository": { "type": "git", From 114056ae099aa3ab1239d6946085aa987a021b5f Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 28 Sep 2026 14:27:50 -0400 Subject: [PATCH 04/15] Move OpenCode integration tests to E2E --- CONTRIBUTING.md | 8 +- test/e2e/data/transcript.ts | 43 ++++++ test/e2e/fixture.ts | 17 ++- test/e2e/run.ts | 28 +++- test/e2e/scenarios/dialog-focus.ts | 23 ++++ test/e2e/scenarios/message-reader.ts | 56 ++++++++ test/e2e/scenarios/transcript.ts | 113 +++++++++++++++ test/e2e/screens.ts | 24 ++++ test/e2e/terminal.ts | 11 +- test/opencode-dialog.test.tsx | 197 --------------------------- test/opencode-transcript.test.tsx | 185 ------------------------- 11 files changed, 315 insertions(+), 390 deletions(-) create mode 100644 test/e2e/data/transcript.ts create mode 100644 test/e2e/scenarios/message-reader.ts create mode 100644 test/e2e/scenarios/transcript.ts create mode 100644 test/e2e/screens.ts delete mode 100644 test/opencode-dialog.test.tsx delete mode 100644 test/opencode-transcript.test.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f1fb44..877d40b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,13 +33,15 @@ bun run test:e2e - `test` covers editing behavior, plugin integration, source and npm package loading, and comparisons against a pinned Neovim version downloaded - automatically. Three optional integration cases require an OpenCode source - checkout via `OPENCODE_SOURCE` and otherwise skip. + automatically. - `test:e2e` builds and packs the current plugin, then runs real terminal interactions against the latest stable OpenCode 2 release. Each scenario gets - isolated configuration and a fresh session. No model requests are submitted. + isolated configuration and a fresh session, with imported fixture transcripts + for message-reader and history tests. No model requests are submitted. Captures, logs, and results are saved under `test-results/e2e/`. +Run individual scenarios with `bun run test:e2e message-reader`. + Internet access is needed for initial binary downloads and for E2E tests to resolve the latest OpenCode release. Downloaded binaries are cached under `node_modules/.cache/`. diff --git a/test/e2e/data/transcript.ts b/test/e2e/data/transcript.ts new file mode 100644 index 0000000..8bb3648 --- /dev/null +++ b/test/e2e/data/transcript.ts @@ -0,0 +1,43 @@ +import type { Message } from "../fixture" + +export const readerMessages: Message[] = [ + { id: "msg_e2e_reader", type: "user", text: "one two\nsecond line", time: { created: 1 } }, +] + +export function transcriptMessages(running = false): Message[] { + const model = { providerID: "test", id: "fixture" } + const messages: Message[] = [ + { id: "msg_e2e_001", type: "user", text: "Inspect the fixture files", time: { created: 1 } }, + ] + for (const [index, file] of ["first.ts", "second.ts"].entries()) { + messages.push({ + id: `msg_e2e_00${index + 2}`, + type: "assistant", agent: "build", model, + time: { created: index + 2, completed: index + 3 }, + content: [ + ...(index === 0 ? [ + { type: "reasoning" as const, text: "Check measurements", time: { created: 2, completed: 3 } }, + { type: "reasoning" as const, text: "Check the cursor", time: { created: 3, completed: 4 } }, + ] : []), + { + type: "tool", id: `read-${index}`, name: "read", + time: running ? { created: 4 } : { created: 4, completed: 5 }, + state: running + ? { status: "running", input: { path: file }, metadata: {} } + : { status: "completed", input: { path: file }, content: [{ type: "text", text: `Contents of ${file}` }] }, + }, + ...(index === 1 && !running ? [{ type: "text" as const, text: "Fixture inspection complete" }] : []), + ], + }) + } + return messages +} + +export function historyMessages(): Message[] { + const messages: Message[] = [] + for (let index = 0; index < 240; index++) { + const number = String(index).padStart(3, "0") + messages.push({ id: `msg_history_${number}`, type: "user", text: `History entry ${number}`, time: { created: index + 1 } }) + } + return messages +} diff --git a/test/e2e/fixture.ts b/test/e2e/fixture.ts index fde95e5..f7556bc 100644 --- a/test/e2e/fixture.ts +++ b/test/e2e/fixture.ts @@ -3,11 +3,14 @@ import { execFileSync } from "node:child_process" import { cp, mkdir } from "node:fs/promises" import { createServer } from "node:net" import path from "node:path" +import type { Context } from "@opencode/plugin/tui/context" import { createTerminal, type Terminal } from "./terminal" export type Fixture = { terminal: Terminal; sessionTitle: string } +export type Message = ReturnType[number] +export type FixtureSetup = { messages?: Message[]; cli?: Record } -type Options = { +type Options = FixtureSetup & { opencode: { binary: string; version: string } plugin: string directory: string @@ -50,6 +53,7 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = plugins: [plugin], tabs: { mode: "on" }, attention: { notifications: false, sound: false }, + ...options.cli, })) const listener = createServer() @@ -80,7 +84,16 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = await Bun.sleep(100) } } - const session = (await request("/api/session", { title: sessionTitle, location: { directory: workspace } })).data + let session = (await request("/api/session", { title: sessionTitle, location: { directory: workspace } })).data + if (options.messages) { + session = (await request("/api/experimental/session/import", { + info: { ...session, id: `ses_${crypto.randomUUID().replaceAll("-", "")}` }, + messages: options.messages, + location: { directory: workspace }, + })).data + const imported = (await request(`/api/experimental/session/${session.id}/export`)).data + assert.equal(imported.messages.length, options.messages.length) + } const command = ["env", "-i"] for (const [key, value] of Object.entries(environment("tui"))) command.push(`${key}=${value}`) command.push(opencode.binary, "--server", url, "--session", session.id, workspace) diff --git a/test/e2e/run.ts b/test/e2e/run.ts index 74b39e7..6fc1d92 100644 --- a/test/e2e/run.ts +++ b/test/e2e/run.ts @@ -1,19 +1,41 @@ import { execFileSync } from "node:child_process" import { mkdir, mkdtemp, rm } from "node:fs/promises" import path from "node:path" -import { runWithFixture } from "./fixture" +import { runWithFixture, type Fixture, type FixtureSetup } from "./fixture" import { installOpenCode } from "./opencode" import { packPlugin } from "./plugin" import { agentSwitching } from "./scenarios/agent-switching" import { dialogFocus } from "./scenarios/dialog-focus" import { tabSwitching } from "./scenarios/tab-switching" +import { messageReader } from "./scenarios/message-reader" +import { transcriptGrouped, transcriptLowDetail, transcriptUngrouped, transcriptRunning, transcriptHistory } from "./scenarios/transcript" +import { readerMessages, transcriptMessages, historyMessages } from "./data/transcript" -const scenarios = [ +const scenarios: Array<{ name: string; run: (fixture: Fixture) => Promise; setup?: FixtureSetup }> = [ { name: "tab-switching", run: tabSwitching }, { name: "dialog-focus", run: dialogFocus }, { name: "agent-switching", run: agentSwitching }, + { name: "message-reader", run: messageReader, setup: { messages: readerMessages } }, ] +for (const animations of [true, false]) { + const suffix = animations ? "animated" : "static" + for (const [name, run, session, running] of [ + ["grouped", transcriptGrouped, { verbosity: "medium", grouping: "auto", thinking: "hide" }, false], + ["low-detail", transcriptLowDetail, { verbosity: "low", grouping: "auto", thinking: "hide" }, false], + ["ungrouped", transcriptUngrouped, { verbosity: "medium", grouping: "none", thinking: "show" }, false], + ["running", transcriptRunning, { verbosity: "medium", grouping: "auto", thinking: "hide" }, true], + ] as const) { + const messages = transcriptMessages(running) + if (name === "grouped") messages.unshift(...historyMessages()) + scenarios.push({ name: `transcript-${name}-${suffix}`, run, setup: { messages, cli: { animations, session } } }) + } + scenarios.push({ name: `transcript-history-${suffix}`, run: transcriptHistory, setup: { messages: historyMessages(), cli: { animations } } }) +} +const requested = Bun.argv.slice(2) +for (const name of requested) { + if (!scenarios.some((scenario) => scenario.name === name)) throw new Error(`Unknown E2E scenario: ${name}`) +} if (!Bun.which("tmux")) throw new Error("E2E tests require tmux. Install it, then run bun run test:e2e.") const root = path.resolve(import.meta.dir, "../..") @@ -33,9 +55,11 @@ try { const plugin = await packPlugin(root, temporary, artifacts) for (const scenario of scenarios) { + if (requested.length && !requested.includes(scenario.name)) continue const started = Date.now() try { await runWithFixture({ + ...scenario.setup, opencode, plugin, directory: path.join(temporary, scenario.name), diff --git a/test/e2e/scenarios/dialog-focus.ts b/test/e2e/scenarios/dialog-focus.ts index 423a3c8..25ef936 100644 --- a/test/e2e/scenarios/dialog-focus.ts +++ b/test/e2e/scenarios/dialog-focus.ts @@ -20,6 +20,10 @@ export async function dialogFocus({ terminal }: Fixture) { await screen(`${label}-normal`, (text) => text.includes("Commands") && text.includes("NORMAL")) await type("0dw") await screen(`${label}-edited`, (text) => /^\s+settings\s*$/m.test(text) && text.includes("Open settings")) + await type("u") + await screen(`${label}-undo`, (text) => /^\s+Open settings\s*$/m.test(text)) + await keys("C-r") + await screen(`${label}-redo`, (text) => /^\s+settings\s*$/m.test(text)) await keys("Escape") await screen(`${label}-closed`, (text) => !text.includes("Commands") && text.includes("original draft") && text.includes(mode)) if (mode === "NORMAL") { @@ -37,4 +41,23 @@ export async function dialogFocus({ terminal }: Fixture) { await keys("C-c") await screen(`${label}-cleared`, (text) => !text.includes("draft restored")) } + + await keys("C-p", "Escape") + await screen("dialog-navigation-start", (text) => text.includes("Commands") && text.includes("Switch session") && text.includes("NORMAL")) + await type("j".repeat(30)) + await screen("dialog-scrolled-down", (text) => text.includes("Commands") && !text.includes("Switch session")) + await type("k".repeat(30)) + await screen("dialog-scrolled-up", (text) => text.includes("Commands") && text.includes("Switch session")) + await type("iOpen settings") + await screen("dialog-submit-filter", (text) => text.includes("Open settings") && !text.includes("Switch session")) + await keys("Escape") + await screen("dialog-submit-normal", (text) => text.includes("Commands") && text.includes("NORMAL")) + await keys("Enter") + await screen("dialog-submitted", (text) => text.includes("Settings") && !text.includes("Commands") && text.includes("INSERT")) + await keys("Escape") + await screen("dialog-settings-normal", (text) => text.includes("Settings") && text.includes("NORMAL")) + await keys("Escape") + await screen("dialog-submit-closed", (text) => !text.includes("Settings") && text.includes("INSERT")) + await type("after dialog submit") + await screen("dialog-submit-focus", (text) => text.includes("after dialog submit") && text.includes("INSERT")) } diff --git a/test/e2e/scenarios/message-reader.ts b/test/e2e/scenarios/message-reader.ts new file mode 100644 index 0000000..dc0d337 --- /dev/null +++ b/test/e2e/scenarios/message-reader.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../fixture" +import { reader, readerContains } from "../screens" + +export async function messageReader({ terminal }: Fixture) { + const { keys, type, screen, cursor } = terminal + await type("original draft") + await keys("Escape") + await screen("reader-prompt", (text) => text.includes("NORMAL") && text.includes("original draft")) + await type("s") + await screen("reader-session", (text) => text.includes("SESSION")) + await keys("Enter") + let frame: ReturnType + await screen("reader-opened", (text) => { + frame = reader(text) + return frame?.content.includes("one two") ?? false + }) + assert(frame) + assert(frame.left > 0 && frame.right < 120 && frame.top > 0 && frame.bottom < 38) + assert(Math.abs((frame.left + frame.right) / 2 - 60) <= 2) + assert(Math.abs((frame.top + frame.bottom) / 2 - 19) <= 2) + const start = cursor() + await type("wvl") + await screen("reader-visual", (text) => text.includes("VISUAL") && text.includes("y copy · Esc cancel")) + assert.deepEqual(cursor(), { x: start.x + 5, y: start.y }) + await keys("Escape") + await screen("reader-selection-cancelled", (text) => readerContains(text, "one two")) + const remembered = cursor() + await keys("Escape") + await screen("reader-back-to-session", (text) => text.includes("SESSION") && !text.includes("v select")) + await keys("Enter") + await screen("reader-reopened", (text) => readerContains(text, "one two")) + assert.deepEqual(cursor(), remembered) + await keys("C-c") + await screen("reader-ctrl-c", (text) => text.includes("SESSION") && !text.includes("v select")) + + for (const exit of ["s", "Escape"]) { + await keys("Enter") + await screen(`reader-${exit}-opened`, (text) => readerContains(text, "second line")) + await keys(exit) + if (exit === "Escape") { + await screen("reader-escape-back", (text) => text.includes("SESSION") && !text.includes("v select")) + await keys("Escape") + } + await screen(`reader-${exit}-prompt`, (text) => text.includes("NORMAL") && !text.includes("SESSION") && !text.includes("v select")) + await keys("PageUp") + await type("A restored") + await screen(`reader-${exit}-focus`, (text) => text.includes("original draft restored") && text.includes("INSERT")) + await keys("C-c") + await type("original draft") + await keys("Escape") + await screen(`reader-${exit}-normal`, (text) => text.includes("NORMAL")) + await type("s") + await screen(`reader-${exit}-session`, (text) => text.includes("SESSION")) + } +} diff --git a/test/e2e/scenarios/transcript.ts b/test/e2e/scenarios/transcript.ts new file mode 100644 index 0000000..5fe4df6 --- /dev/null +++ b/test/e2e/scenarios/transcript.ts @@ -0,0 +1,113 @@ +import type { Fixture } from "../fixture" +import { readerContains, selected } from "../screens" + +async function openSelected({ terminal }: Fixture, label: string, value: string) { + await terminal.screen(`${label}-selected`, (text) => selected(text, value)) + await terminal.keys("Enter") + await terminal.screen(`${label}-reader`, (text) => readerContains(text, value)) + await terminal.keys("Escape") + await terminal.screen(`${label}-closed`, (text) => text.includes("SESSION") && !text.includes("v select")) +} + +async function enterSession({ terminal }: Fixture) { + await terminal.keys("Escape") + await terminal.screen("transcript-normal", (text) => text.includes("NORMAL")) + await terminal.type("s") + await terminal.screen("transcript-session", (text) => text.includes("SESSION")) +} + +export async function transcriptGrouped(fixture: Fixture) { + const { keys, type, screen, click } = fixture.terminal + await enterSession(fixture) + await openSelected(fixture, "final-answer", "Fixture inspection complete") + await type("k") + await screen("tools-collapsed", (text) => selected(text, "Explored: 2 reads") && !text.includes("Read first.ts")) + await keys("Enter") + await screen("tools-expanded", (text) => text.includes("Read first.ts") && text.includes("Read second.ts")) + await type("j") + await openSelected(fixture, "first-tool", "Read first.ts") + await type("j") + await openSelected(fixture, "second-tool", "Read second.ts") + let point = { x: 0, y: 0 } + await screen("tools-before-collapse", (text) => { + if (!selected(text, "Read second.ts")) return false + const lines = text.split("\n") + for (let y = 0; y < lines.length; y++) { + const x = lines[y].indexOf("Explored: 2 reads") + if (x !== -1) { point = { x, y }; return true } + } + return false + }) + await click(point.x, point.y) + await screen("tools-recollapsed", (text) => selected(text, "Explored: 2 reads") && !text.includes("Read first.ts")) + await type("k") + await screen("thought-header", (text) => selected(text, "Thought")) + await keys("Enter") + await screen("thoughts-expanded", (text) => text.includes("Check measurements") && text.includes("Check the cursor")) + await type("j") + await openSelected(fixture, "first-thought", "Check measurements") + await type("j") + await openSelected(fixture, "second-thought", "Check the cursor") +} + +export async function transcriptLowDetail(fixture: Fixture) { + const { keys, type, screen } = fixture.terminal + await enterSession(fixture) + await type("k") + await screen("activity-collapsed", (text) => selected(text, "2 thoughts, 2 reads")) + await keys("Enter") + await screen("activity-expanded", (text) => text.includes("Thought") && text.includes("Explored: 2 reads")) + await type("j") + await screen("nested-thoughts", (text) => selected(text, "Thought")) + await keys("Enter") + await screen("nested-thoughts-expanded", (text) => text.includes("Check measurements")) + await type("j") + await openSelected(fixture, "nested-first-thought", "Check measurements") + await type("j") + await openSelected(fixture, "nested-second-thought", "Check the cursor") + await type("j") + await screen("nested-tools", (text) => selected(text, "Explored: 2 reads")) + await keys("Enter") + await screen("nested-tools-expanded", (text) => text.includes("Read first.ts")) + await type("j") + await openSelected(fixture, "nested-first-tool", "Read first.ts") + await type("j") + await openSelected(fixture, "nested-second-tool", "Read second.ts") + await type("j") + await openSelected(fixture, "after-activity", "Fixture inspection complete") +} + +export async function transcriptUngrouped(fixture: Fixture) { + await enterSession(fixture) + for (const [index, value] of ["Read second.ts", "Read first.ts", "Check the cursor", "Check measurements"].entries()) { + await fixture.terminal.type("k") + await openSelected(fixture, `ungrouped-${index}`, value) + } +} + +export async function transcriptRunning(fixture: Fixture) { + const { keys, type, screen } = fixture.terminal + await enterSession(fixture) + await screen("running-group", (text) => selected(text, "Exploring: 2 reads")) + await keys("Enter") + await screen("running-expanded", (text) => text.includes("Read first.ts") && text.includes("Read second.ts")) + await type("j") + await openSelected(fixture, "running-first-tool", "Read first.ts") + await type("j") + await openSelected(fixture, "running-second-tool", "Read second.ts") +} + +export async function transcriptHistory(fixture: Fixture) { + const { type, screen } = fixture.terminal + await enterSession(fixture) + await openSelected(fixture, "history-latest", "History entry 239") + await type("130k") + await screen("history-loaded-older", (text) => selected(text, "History entry 109"), 15_000) + await openSelected(fixture, "history-older", "History entry 109") + await type("gg") + await screen("history-first", (text) => selected(text, "History entry 000"), 15_000) + await openSelected(fixture, "history-first", "History entry 000") + await type("G") + await screen("history-return-to-latest", (text) => selected(text, "History entry 239"), 15_000) + await openSelected(fixture, "history-restored", "History entry 239") +} diff --git a/test/e2e/screens.ts b/test/e2e/screens.ts new file mode 100644 index 0000000..48ce50c --- /dev/null +++ b/test/e2e/screens.ts @@ -0,0 +1,24 @@ +export function selected(text: string, value: string) { + for (const line of text.split("\n")) { + if (line.includes("▎") && line.includes(value)) return true + } + return false +} + +export function reader(text: string) { + const lines = text.split("\n") + const footer = lines.findIndex((line) => line.includes("MESSAGE") && line.includes("v select")) + if (footer === -1) return + const left = lines[footer].indexOf("MESSAGE") + for (let index = footer - 1; index >= 0; index--) { + const right = lines[index].lastIndexOf("esc") + if (right < left) continue + let content = "" + for (let row = index + 1; row < footer; row++) content += lines[row].slice(left, right + 3) + "\n" + return { content, left, right: right + 3, top: index, bottom: footer + 1 } + } +} + +export function readerContains(text: string, value: string) { + return reader(text)?.content.includes(value) ?? false +} diff --git a/test/e2e/terminal.ts b/test/e2e/terminal.ts index b233077..a86f4fb 100644 --- a/test/e2e/terminal.ts +++ b/test/e2e/terminal.ts @@ -37,6 +37,15 @@ export function createTerminal(socket: string, artifacts: string) { await keys("-l", text) } + function cursor() { + const [x, y] = tmux("display-message", "-p", "-t", "e2e", "#{cursor_x} #{cursor_y}").trim().split(" ") + return { x: Number(x), y: Number(y) } + } + + async function click(x: number, y: number) { + await keys("-l", `\x1b[<0;${x + 1};${y + 1}M\x1b[<0;${x + 1};${y + 1}m`) + } + async function screen(label: string, matches: (text: string) => boolean, timeout = 5_000) { const deadline = Date.now() + timeout while (true) { @@ -55,5 +64,5 @@ export function createTerminal(socket: string, artifacts: string) { } } - return { start, stop, keys, type, screen } + return { start, stop, keys, type, screen, cursor, click } } diff --git a/test/opencode-dialog.test.tsx b/test/opencode-dialog.test.tsx deleted file mode 100644 index bc7d379..0000000 --- a/test/opencode-dialog.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { expect, test } from "bun:test" -import * as pluginModule from "@opencode/plugin/tui" -import { InputRenderable, TextareaRenderable } from "@opentui/core" -import { testRender, type JSX } from "@opentui/solid" -import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" -import { createSignal, onMount, Show } from "solid-js" -import { mkdtemp, rm } from "node:fs/promises" -import plugin from "../tui" - -// Optional integration with the real OpenCode providers, keymap, and dialog UI. -// The ordinary suite also exercises InputRenderable without a source checkout. -test.skipIf(!process.env.OPENCODE_SOURCE)("OpenCode dialogs: Vim filtering and the session message modal", async () => { - // Bun can load nested dependencies without running runtime import rewriting. - // Keep their Solid owner/context stack shared with this test's renderer. - const solid = await import("solid-js") - const store = await import("solid-js/store") - Bun.plugin({ - name: "dialog-test-shared-solid", - setup(build) { - build.onLoad({ filter: /[/\\]node_modules[/\\]solid-js[/\\]dist[/\\]solid\.js$/ }, () => ({ exports: solid, loader: "object" })) - build.onLoad({ filter: /[/\\]node_modules[/\\]solid-js[/\\]store[/\\]dist[/\\]store\.js$/ }, () => ({ exports: store, loader: "object" })) - }, - }) - ensureRuntimePluginSupport({ additional: { "@opencode/plugin/tui": pluginModule } }) - const root = `${process.env.OPENCODE_SOURCE}/packages/tui` - const [{ ConfigProvider }, { ThemeProvider, useThemes }, { Keymap }, { DialogProvider, useDialog }, - { DialogSelect }, { ToastProvider }, { TestTuiContexts }, { createTuiResolvedConfig }] = await Promise.all([ - import(`${root}/src/config/index.tsx`), - import(`${root}/src/context/theme.tsx`), - import(`${root}/src/context/keymap.tsx`), - import(`${root}/src/ui/dialog.tsx`), - import(`${root}/src/ui/dialog-select.tsx`), - import(`${root}/src/ui/toast.tsx`), - import(`${root}/test/fixture/tui-environment.tsx`), - import(`${root}/test/fixture/tui-runtime.ts`), - ]) - const directory = await mkdtemp("/tmp/opencode/vim-dialog-") - const selected: string[] = [] - const moved: string[] = [] - let ref: { filter: string; selected?: { value: string } } | undefined - let dialog: { stack: unknown[]; clear(): void } | undefined - let screen: Awaited> | undefined - let showSession = () => {} - let prompt: TextareaRenderable | undefined - const messageText = "one two\nsecond line" - const options: Array<{ title: string; value: string }> = [] - for (let index = 0; index < 25; index++) options.push({ title: `Alpha ${index}`, value: String(index) }) - options.push({ title: "Beta", value: "beta" }) - - function Fixture() { - const keymap = Keymap.use() - const keymapState = Keymap.useState() - const themes = useThemes() - const hostDialog = useDialog() - dialog = hostDialog - const [app, setApp] = createSignal<() => JSX.Element>(() => null) - const [inSession, setInSession] = createSignal(false) - showSession = () => setInSession(true) - Keymap.createLayer(() => ({ mode: "base", commands: [{ id: "prompt.submit", run() {} }] })) - plugin.setup({ - renderer: screen!.renderer, - options: { defaultMode: "insert" }, - get theme() { return themes.currentTokens() }, - storage: { store: () => [{ enabled: true }, () => {}] }, - keymap: { ...keymap, ...keymapState, layer: Keymap.createLayer }, - data: { session: { message: { list: () => [{ id: "message-1", type: "user", text: messageText, time: { created: 1 } }] } } }, - ui: { - router: { current: () => inSession() ? { type: "session", sessionID: "session-1" } : { type: "home" } }, toast: { show() {} }, - dialog: { - show: (view: () => JSX.Element, onClose?: () => void) => hostDialog.replace(view, onClose), - set: (options: { size: string; centered: boolean }) => { hostDialog.setSize(options.size); hostDialog.setCentered(options.centered) }, - clear: () => hostDialog.clear(), - }, - slot(claim: { append?: string; render: () => JSX.Element }) { - if (claim.append === "app") setApp(() => claim.render) - return () => {} - }, - }, - } as unknown as Parameters[0]) - onMount(() => hostDialog.replace(() => { ref = value }} - onMove={(option: { value: string }) => moved.push(option.value)} - onSelect={(option: { value: string }) => selected.push(option.value)} - />)) - return <> - {app()()} - - - {messageText} - -