diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5646fd..10a9878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - name: Typecheck run: bun run typecheck - - name: Test source and npm package + - name: Test units and components run: bun run test e2e: @@ -72,7 +72,7 @@ jobs: - name: Install dependencies run: npm ci - - name: Test in OpenCode + - name: Test source and npm package in OpenCode run: bun run test:e2e - name: Upload failure diagnostics diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f474dc..d9e662c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,13 +31,15 @@ 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. +- `test` covers our editing, clipboard and reader components, and comparisons + against a pinned Neovim version downloaded 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, with imported fixture transcripts - for message-reader and history tests. No model requests are submitted. + for message-reader and transcript history tests. Live response and prompt + history scenarios use a local controlled model stream, without paid inference. + Source and packed-package runtime loading, reactivity and cleanup are tested + inside real OpenCode too. Captures, logs, and results are saved under `test-results/e2e/`. Run individual scenarios with `bun run test:e2e message-reader`. @@ -56,9 +58,19 @@ shared fixtures and setup in `test/helpers/`. Add focused regression coverage for bug fixes. Use E2E scenarios when the behavior depends on real OpenCode keyboard handling, focus, dialogs, or tabs. +Test plugin-owned behavior directly with OpenTUI: editing and clipboard tests use +`test/helpers/fixture.ts`; reader component tests use `test/helpers/reader.tsx`. +These fixtures mount our components, not the plugin entrypoint. They may control +external boundaries such as desktop clipboard access and record outgoing calls. +Do not mount the plugin in a fake OpenCode context or imitate host mode changes, +transcript trees, scrolling, history, dialogs, or command implementations. Test +those integrations through real OpenCode E2E scenarios. See `test/README.md` for +the coverage map. + 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. +`test/e2e/run.ts`. Prepared conversations belong in `test/e2e/data/`; shared +setup, drivers and assertions belong in `test/e2e/support/`. Reuse those helpers +and wait for expected screen content rather than using fixed delays. ## Pull requests diff --git a/docs/configuration.md b/docs/configuration.md index e47e90f..9dd85b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,8 +48,9 @@ entering session mode. ### Custom keymaps Mappings apply to `insert`, `normal`, `visual`, and `visual-line` editing modes in -the prompt and search dialogs. Use `keymaps.session` for transcript browsing and -its message/tool modals. +the prompt and search dialogs. Insert-mode mappings also apply while typing a +question answer. Use `keymaps.session` for transcript browsing and its +message/tool modals. See [Custom Keymaps](./keymap-actions.md) for actions, key notation, and examples. See [Keybindings and Modes](./vim-behavior.md) for the default behavior. diff --git a/docs/keymap-actions.md b/docs/keymap-actions.md index 6d0da14..45c1601 100644 --- a/docs/keymap-actions.md +++ b/docs/keymap-actions.md @@ -29,6 +29,8 @@ Each entry maps a key sequence to an action in one Vim mode. Put `keymaps` insid Mappings apply while editing the prompt or a search dialog, in `insert`, `normal`, `visual`, or `visual-line` mode. `session` mappings apply to transcript browsing and its message/tool modals; use `sessionKey` to change the session toggle. +Insert-mode mappings also apply to question answers; `submit` uses the question's +native confirm/submit action. | Action | Behavior | | --- | --- | diff --git a/docs/vim-behavior.md b/docs/vim-behavior.md index 563dbc8..ace2ef8 100644 --- a/docs/vim-behavior.md +++ b/docs/vim-behavior.md @@ -82,6 +82,22 @@ The footer follows the active editor's mode. Closing the dialog restores the prompt's mode or the session footer. Prompt and dialog edits have separate undo histories. +## Questions + +Questions start in normal mode. `j`/`k` move between every answer, including away +from "Type your own answer". `h`/`l` or Tab switch question fields. Enter selects +an option, and Space toggles a multi-select answer. + +Press `i` to open or reopen the custom answer. Enter also opens an empty custom +answer; for an existing multi-select answer it keeps the native toggle behavior. +Type in insert mode; `Esc`, `Ctrl+[`, or your configured insert-mode mapping to `normal` +returns to navigation and preserves the answer. `Esc` in normal mode dismisses +the question. The mode is shown above the question, and the prompt's previous +mode is restored when it closes. + +Insert mappings use the same configuration and timeout as the prompt. There is +no default two-letter binding for leaving insert mode. + ## Vim compatibility This is a Vim-style subset powered by `@vimee/core`, with OpenCode-specific diff --git a/src/form.tsx b/src/form.tsx new file mode 100644 index 0000000..183ee12 --- /dev/null +++ b/src/form.tsx @@ -0,0 +1,203 @@ +import type { Context } from "@opencode/plugin/tui/context" +import { KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core" +import { createEffect, onCleanup, Show, untrack } from "solid-js" +import type { PromptContext } from "./modules/vim/actions" +import type { VimConfig } from "./modules/vim/config" +import { editInput } from "./modules/vim/edit" +import { keyNotation } from "./modules/vim/keys" +import type { VimLog } from "./modules/vim/log" +import { createVimState } from "./modules/vim/state" +import { createVimeeAdapter } from "./modules/vim/vimee" +import { VimStatus } from "./modules/vim/status" + +export function createFormMode(context: Context, config: VimConfig, log: VimLog, enabled: () => boolean) { + const state = createVimState("normal", log) + const adapter = createVimeeAdapter(state, config, log) + const active = () => enabled() && context.keymap.mode.current() === "form" + let input: typeof context.renderer.currentFocusedEditor = null + let cursorStyle: CursorStyleOptions | undefined + let forwarding = false + let opening: Array | undefined + const ctx: PromptContext = { + api: { + renderer: context.renderer, + keymap: { + dispatchCommand(command) { + const ok = context.keymap.commands().some((item) => item.id === command) + if (ok) context.keymap.dispatch(command) + return { ok } + }, + }, + theme: { get current() { return { + background: context.theme.background.base, + info: context.theme.text.feedback.info.base, + warning: context.theme.text.feedback.warning.base, + } } }, + }, + prompt: () => input ? { + current: { input: input.plainText, mode: "normal", parts: [] }, + set: (value) => { if (input) editInput(input, value.input, context.renderer.widthMethod) }, + submit: () => sendKey("return", "\r"), + blur: () => input?.blur(), + } : undefined, + requestRender: () => context.renderer.requestRender(), + } + + const removeStatus = context.ui.slot({ + append: "session.composer.top", + render: () => + + + + {state.mode() === "normal" ? "j/k select · i type an answer" : "esc normal"} + + + , + }) + + createEffect(() => { + if (!active()) opening = undefined + untrack(focus) + }) + createEffect(() => { + const style = config.cursorStyles[state.mode()] + if (input && !input.isDestroyed) input.cursorStyle = style + }) + onCleanup(() => { + opening = undefined + adapter.cleanup() + restoreCursor() + removeStatus() + }) + + function restoreCursor() { + if (input && !input.isDestroyed && cursorStyle) input.cursorStyle = cursorStyle + } + + function focus() { + const next = active() ? context.renderer.currentFocusedEditor : null + if (next === input) return + adapter.suspend() + restoreCursor() + input = next + cursorStyle = input?.cursorStyle + state.setMode(input ? "insert" : "normal") + if (input) input.cursorStyle = config.cursorStyles[state.mode()] + } + + function consume(event: KeyEvent | PasteEvent) { + event.preventDefault() + event.stopPropagation() + } + + function sendKey(name: string, sequence: string) { + // Form actions are inline host bindings, without dispatchable command IDs. + forwarding = true + try { + context.renderer.keyInput.emit("keypress", new KeyEvent({ + name, sequence, raw: sequence, ctrl: false, meta: false, shift: false, + option: false, number: false, eventType: "press", source: "raw", + })) + } finally { + forwarding = false + } + } + + function waitForEditor() { + const queued: Array = [] + opening = queued + // OpenCode publishes a newly opened answer editor in a microtask. Keep + // burst typing here so its first keys also use the configured mappings. + queueMicrotask(() => queueMicrotask(() => { + if (opening !== queued) return + opening = undefined + focus() + for (const event of queued) { + if (!active()) break + if (event instanceof PasteEvent) context.renderer.keyInput.emit("paste", event) + else context.renderer.keyInput.emit("keypress", event) + } + })) + } + + function paste(event: PasteEvent) { + if (!active()) return + if (opening) { + opening.push(new PasteEvent(event.bytes, event.metadata)) + consume(event) + } else if (!input) { + waitForEditor() + } + } + + function handle(event: KeyEvent) { + if (!active()) return false + if (forwarding) return true + if (opening) { + opening.push(new KeyEvent(event)) + consume(event) + return true + } + if (context.keymap.pending().length) return true + const key = keyNotation(event as never) + if (!key) return true + + if (state.mode() === "insert") { + const editor = input + const handled = adapter.handle(event as never, key, ctx) + if (handled) consume(event) + if (state.mode() === "normal" && input === editor) { + // Escape closes a custom answer, but dismisses a text-only form. + // Only forward it when the host exposes the close-edit action. + if (context.keymap.active().some((item) => item.group === "Form" && item.description === "Close answer edit")) { + sendKey("escape", "\x1b") + } + } + return true + } + + if (event.ctrl || event.meta || event.option || event.super || event.hyper) { + if (key === "") { + consume(event) + sendKey("escape", "\x1b") + } + return true + } + if (key === "i") { + consume(event) + if (input) state.setMode("insert") + else { + // An empty native paste selects and opens the custom answer + // without inserting a character or submitting another option. + context.renderer.keyInput.emit("paste", new PasteEvent(new Uint8Array())) + } + return true + } + const arrows: Record = { + h: ["left", "\x1b[D"], j: ["down", "\x1b[B"], + k: ["up", "\x1b[A"], l: ["right", "\x1b[C"], + } + const arrow = arrows[key] + if (arrow) { + consume(event) + sendKey(...arrow) + return true + } + if (key === "" && !input) waitForEditor() + if (/^[1-9]$/.test(key) || key === "") { + consume(event) + // Keep the native selection shortcut, without triggering the + // custom row's printable-character interceptor. + sendKey(event.name, "") + if (!input) waitForEditor() + } else if (event.sequence && !/[\p{C}]/u.test(event.sequence) && key !== "" && key !== "") { + consume(event) + } + return true + } + + return { handle, focus, paste } +} diff --git a/src/modules/vim/edit.ts b/src/modules/vim/edit.ts index df6c1e7..483d38b 100644 --- a/src/modules/vim/edit.ts +++ b/src/modules/vim/edit.ts @@ -1,3 +1,4 @@ +import type { WidthMethod } from "@opentui/core" import { charToDisplay } from "./map" const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) @@ -10,7 +11,7 @@ type Input = { clearSelection: () => unknown } -export function editInput(input: Input, value: string) { +export function editInput(input: Input, value: string, widthMethod: WidthMethod) { const before = input.plainText if (before === value) return input.clearSelection() @@ -41,10 +42,10 @@ export function editInput(input: Input, value: string) { valueEnd += extra } - const startOffset = charToDisplay(before, start) + const startOffset = charToDisplay(before, start, widthMethod) if (start === end) input.cursorOffset = startOffset - else input.setSelection(startOffset, startOffset + charToDisplay(before.slice(start, end), end - start)) + else input.setSelection(startOffset, startOffset + charToDisplay(before.slice(start, end), end - start, widthMethod)) input.insertText(value.slice(start, valueEnd)) } diff --git a/view.tsx b/src/modules/vim/status.tsx similarity index 93% rename from view.tsx rename to src/modules/vim/status.tsx index c26dbba..58c1dd9 100644 --- a/view.tsx +++ b/src/modules/vim/status.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import type { TextRenderable } from "@opentui/core" import type { Accessor } from "solid-js" -import type { VimMode } from "./src/modules/vim/state" +import type { VimMode } from "./state" type VimStatusProps = { mode: Accessor diff --git a/src/plugin.tsx b/src/plugin.tsx new file mode 100644 index 0000000..7afeed8 --- /dev/null +++ b/src/plugin.tsx @@ -0,0 +1,351 @@ +/** @jsxImportSource @opentui/solid */ +import { Plugin } from "@opencode/plugin/tui" +import { InputRenderable, KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core" +import { createEffect, createSignal, onCleanup, untrack } from "solid-js" +import { applyVimCursorStyle, focusedInput } from "./modules/vim/actions" +import { createVimConfig } from "./modules/vim/config" +import { editInput } from "./modules/vim/edit" +import { keyNotation } from "./modules/vim/keys" +import { createVimLog } from "./modules/vim/log" +import { displayToChar } from "./modules/vim/map" +import { createVimState } from "./modules/vim/state" +import { createVimeeAdapter } from "./modules/vim/vimee" +import { VimStatus } from "./modules/vim/status" +import { SESSION_MODE, createSessionMode } from "./session" +import { createVimClipboard } from "./clipboard" +import { createFormMode } from "./form" + +type Context = Parameters[0]["setup"]>[0] + +export default Plugin.define({ + id: "opencode-vim", + setup(context) { + context.ui.slot({ append: "app", render: () => }) + }, +}) + +function VimHost(props: { context: Context }) { + const config = createVimConfig(props.context.options) + const normalMappings = Object.keys(config.keymaps.normal ?? {}) + const log = createVimLog(config) + const clipboard = createVimClipboard(props.context.renderer) + const clipboardOptions = { onYank: (text: string) => { void clipboard.write(text) }, readClipboard: clipboard.read } + const state = createVimState(config.defaultMode, log) + const vimee = createVimeeAdapter(state, config, log, clipboardOptions) + const dialogState = createVimState(config.defaultMode, log) + const dialogVimee = createVimeeAdapter(dialogState, config, log, clipboardOptions) + const [dialogFocused, setDialogFocused] = createSignal(false) + const promptVim = { state, vimee } + const dialogVim = { state: dialogState, vimee: dialogVimee } + let dialogInput: typeof props.context.renderer.currentFocusedEditor = null + const [saved, setSaved] = props.context.storage.store("state", { initial: { enabled: true } }) + const enabled = () => saved.enabled + const form = createFormMode(props.context, config, log, enabled) + const ctx = createCompatContext(props.context) + let cursorMode = "" + let cursorInput: typeof props.context.renderer.currentFocusedEditor = null + let originalCursorStyle: CursorStyleOptions | undefined + const session = createSessionMode(props.context, config, clipboard) + let pendingKeys: Array | undefined + + const removeStatus = props.context.ui.slot({ + prepend: "prompt.footer", + render: (footer) => + footer.mode === "normal" ? session.active() && !dialogFocused() ? : ( + dialogFocused() ? dialogState.mode() : state.mode()} + enabled={enabled} + theme={compatTheme(props.context)} + /> + ) : null, + }) + + props.context.keymap.layer(() => ({ + mode: "global", + commands: [ + { + id: "opencode-vim.toggle", + title: "Toggle Vim Mode", + description: "Enable or disable Vim key handling", + group: "Vim", + palette: true, + slash: { name: "vim" }, + run() { + const next = !enabled() + void setSaved((draft) => { + draft.enabled = next + }) + if (!next) { + pendingKeys = undefined + session.close() + vimee.suspend() + dialogVimee.suspend() + } + props.context.ui.toast.show({ message: `Vim mode ${next ? "enabled" : "disabled"}`, variant: "info" }) + props.context.renderer.requestRender() + }, + }, + ], + })) + + const onKey = (event: KeyEvent) => { + if (!enabled() || event.defaultPrevented) return + if (form.handle(event)) return + if (pendingKeys) { + pendingKeys.push(new KeyEvent(event)) + event.preventDefault() + event.stopPropagation() + return + } + if (props.context.keymap.mode.current() === SESSION_MODE) return + const kind = inputKind(props.context) + if (!kind) return + if (props.context.keymap.pending().length) return + const { state, vimee } = kind === "dialog" ? dialogVim : promptVim + const key = keyNotation(event as never) + if (!key) return + const mapped = normalMappings.some((sequence) => sequence.startsWith(key)) + if (kind === "prompt" && key === config.sessionKey && !mapped && state.mode() === "normal" && !vimee.isPending()) { + if (session.enter()) { + event.preventDefault() + event.stopPropagation() + return + } + } + if (passThroughKey(event, key, state.mode(), vimee.isPending(), mapped)) return + if (key === "" && state.mode() === "normal" && !vimee.isPending()) return + + if (kind === "dialog" && !vimee.isPending() && !mapped && state.mode() === "normal") { + if (key === "" || key === "" || key === "" || key === "" || key === "") return + const command = key === "j" ? "dialog.select.next" : key === "k" ? "dialog.select.prev" : undefined + if (command && props.context.keymap.commands().some((item) => item.id === command)) { + event.preventDefault() + event.stopPropagation() + props.context.keymap.dispatch(command) + return + } + } + + if (kind === "prompt" && !vimee.isPending() && sendCompletionKey(event, props.context, key, state.mode())) { + syncCursor(true) + return + } + + const before = state.mode() + let consumed = false + try { + const result = vimee.handle(event as never, key, ctx as never) + if (typeof result === "boolean") consumed = result + else { + consumed = true + const queued: Array = [] + pendingKeys = queued + const input = props.context.renderer.currentFocusedEditor + void result.then((handled) => { + if (pendingKeys !== queued) return + pendingKeys = undefined + if (!handled) return + syncCursor(true) + // Replay through the host too: a queued i may be followed by native typing. + for (const event of queued) { + if (!enabled() || props.context.renderer.currentFocusedEditor !== input) break + if (event instanceof PasteEvent) props.context.renderer.keyInput.emit("paste", event) + else props.context.renderer.keyInput.emit("keypress", event) + } + }).catch((error) => { + if (pendingKeys === queued) pendingKeys = undefined + log("vim.clipboard.paste.error", { error: String(error) }) + }) + } + } finally { + if (consumed || state.mode() !== before) { + event.preventDefault() + event.stopPropagation() + } + } + if (consumed) syncCursor(true) + } + + // Focus events can fire inside the host's prompt effects. Do not subscribe + // those effects to Vim state or the keymap registry while syncing the editor. + const onFocus = () => untrack(() => { + pendingKeys = undefined + vimee.suspend() + dialogVimee.suspend() + syncCursor() + form.focus() + }) + const onPaste = (event: PasteEvent) => { + if (event.defaultPrevented) return + if (!pendingKeys) { + form.paste(event) + return + } + pendingKeys.push(new PasteEvent(event.bytes, event.metadata)) + event.preventDefault() + event.stopPropagation() + } + props.context.renderer.keyInput.prependListener("keypress", onKey) + props.context.renderer.keyInput.prependListener("paste", onPaste) + props.context.renderer.on("focused_editor", onFocus) + createEffect(() => syncCursor()) + let route = props.context.ui.router.current() + createEffect(() => { + const next = props.context.ui.router.current() + if (next !== route && pendingKeys) onFocus() + route = next + }) + onCleanup(() => { + pendingKeys = undefined + removeStatus() + session.close() + props.context.renderer.keyInput.off("keypress", onKey) + props.context.renderer.keyInput.off("paste", onPaste) + props.context.renderer.off("focused_editor", onFocus) + vimee.cleanup() + dialogVimee.cleanup() + void clipboard.dispose() + restoreCursor() + }) + + return null + + function syncCursor(force = false) { + const input = props.context.renderer.currentFocusedEditor + const kind = inputKind(props.context) + setDialogFocused(kind === "dialog") + if (!enabled() || !kind || !input) { + restoreCursor() + return + } + const { state, vimee } = kind === "dialog" ? dialogVim : promptVim + if (kind === "dialog" && dialogInput !== input) { + dialogInput = input + dialogVimee.suspend() + dialogState.setMode(promptVim.state.mode()) + } + const mode = state.mode() + vimee.attach(ctx) + const inputChanged = cursorInput !== input + if (inputChanged) { + restoreCursor() + cursorInput = input + originalCursorStyle = input.cursorStyle + } + if (!force && !inputChanged && cursorMode === mode) return + if (applyVimCursorStyle(ctx as never, config.cursorStyles[mode])) { + cursorMode = mode + props.context.renderer.requestRender() + } + } + + function restoreCursor() { + if (cursorInput && !cursorInput.isDestroyed && originalCursorStyle) cursorInput.cursorStyle = originalCursorStyle + cursorInput = null + cursorMode = "" + } +} + +function createCompatContext(context: Context) { + const prompt = { + get current() { + return { input: focusedInputValue(context), mode: "normal", parts: [] } + }, + get focused() { + return inputKind(context) !== undefined + }, + set(value: { input: string }) { + const input = context.renderer.currentFocusedEditor + if (input instanceof InputRenderable) input.value = value.input + else if (input) editInput(input, value.input, context.renderer.widthMethod) + }, + submit() { + if (inputKind(context) === "dialog") { + const command = context.keymap.commands().some((item) => item.id === "dialog.select.submit") + ? "dialog.select.submit" + : "dialog.prompt.submit" + context.keymap.dispatch(command) + } else { + context.keymap.dispatch("prompt.submit") + } + }, + blur() { + context.renderer.currentFocusedEditor?.blur() + }, + } + + return { + api: { + renderer: context.renderer, + keymap: { + dispatchCommand(command: string) { + const ok = context.keymap.commands().some((item) => item.id === command) + if (ok) context.keymap.dispatch(command) + return { ok } + }, + }, + theme: { current: compatTheme(context) }, + }, + kind: context.ui.router.current().type === "session" ? "session" : "home", + prompt: () => (inputKind(context) ? prompt : undefined), + requestRender: () => context.renderer.requestRender(), + } +} + +function inputKind(context: Context): "prompt" | "dialog" | undefined { + if (context.keymap.mode.current() === SESSION_MODE) return + if (!context.renderer.currentFocusedEditor) return + const commands = context.keymap.commands() + if (context.keymap.mode.current() === "modal") { + if (commands.some((item) => item.id === "dialog.select.submit" || item.id === "dialog.prompt.submit")) return "dialog" + return + } + if (commands.some( + (item) => + item.id === "prompt.submit" || item.id === "prompt.autocomplete.next" || item.id === "prompt.history.previous", + )) return "prompt" +} + +function focusedInputValue(context: Context) { + return context.renderer.currentFocusedEditor?.plainText ?? "" +} + +function compatTheme(context: Context) { + return { + get background() { return context.theme.background.base }, + get info() { return context.theme.text.feedback.info.base }, + get success() { return context.theme.text.feedback.success.base }, + get warning() { return context.theme.text.feedback.warning.base }, + get textMuted() { return context.theme.text.muted }, + } +} + +function passThroughKey(event: KeyEvent, key: string, mode: string, pending: boolean, mapped: boolean) { + if (key === "" && event.shift) return true + if (mode !== "normal") return false + if (mapped) return false + if (event.ctrl && key !== "" && key !== "") return true + return event.super === true || event.meta === true || (isArrowKey(key) && !pending) +} + +function sendCompletionKey(event: KeyEvent, context: Context, key: string, mode: string) { + if (mode !== "normal") return false + const command = key === "j" ? "prompt.autocomplete.next" : key === "k" ? "prompt.autocomplete.prev" : undefined + if (!command || !isCompletionToken(context) || !context.keymap.commands().some((item) => item.id === command)) return false + event.preventDefault() + event.stopPropagation() + context.keymap.dispatch(command) + return true +} + +function isCompletionToken(context: Context) { + const input = context.renderer.currentFocusedEditor + const text = input?.plainText ?? "" + const index = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0)) + const before = text.slice(0, Math.min(index + 1, text.length)) + return /^\/\S*$/.test(before) || /(?:^|\s)@\S*$/.test(before) +} + +function isArrowKey(key: string) { + return key === "" || key === "" || key === "" || key === "" +} diff --git a/src/session.tsx b/src/session.tsx index b03ed51..cd4c51c 100644 --- a/src/session.tsx +++ b/src/session.tsx @@ -174,7 +174,7 @@ export function createSessionMode(context: Context, config: VimConfig, clipboard return { active, enter, close, Status() { - return + return {notice() || `SESSION · Enter open · yy copy · ${config.sessionKey} prompt`} }, diff --git a/src/transcript-items.tsx b/src/transcript-items.tsx index 59de94f..3439586 100644 --- a/src/transcript-items.tsx +++ b/src/transcript-items.tsx @@ -204,10 +204,17 @@ export function transcriptItems(rows: Renderable[], messages: Message[], pending lastSource = cursor++ } else if (source.group && container && anchor.getChildren().length) { // With grouping disabled, the GroupAnchor holds individual EntryAnchors. - for (const child of anchor.getChildren()) { - add(child, sources[cursor]) - cursor++ + // Permission-blocked entries still render after the ordinary entries. + const children = anchor.getChildren() + const ordinary: Part[] = [] + const pending: Part[] = [] + for (const part of sources.slice(cursor, cursor + children.length)) { + if (part.source?.type === "tool" && pendingTools.has(part.source.id)) pending.push(part) + else ordinary.push(part) } + const ordered = [...ordinary, ...pending] + for (const [index, child] of children.entries()) add(child, ordered[index]) + cursor += children.length lastSource = cursor - 1 } else { add(row, source) diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..312338e --- /dev/null +++ b/test/README.md @@ -0,0 +1,72 @@ +# Test boundaries + +- **Unit:** parsing, configuration, mappings, editing, and Vim state. +- **Integration:** our editor, clipboard and reader components with real OpenTUI + renderables. Fixtures control component inputs and external clipboard responses, + and record outgoing calls. They never mount the plugin in a fake host or build + imitation OpenCode transcript trees, modes, dialogs, history, or commands. +- **E2E:** the source or packed plugin running in real OpenCode, including host keyboard + handling, focus, dialogs, tabs, transcript layout, grouping and virtualization. + +## Layout + +- `unit/`: focused logic tests. +- `integration/`: component tests, with reader cases grouped in `readers/`. +- `helpers/`: shared component fixtures and Neovim reference support. +- `e2e/run.ts`: E2E entrypoint and scenario registration. +- `e2e/scenarios/`: real-host test cases. +- `e2e/data/`: prepared conversations and tool results. +- `e2e/support/`: host setup, terminal driver, services, assertions and probe plugin. +- `benchmark.ts`: standalone editor performance checks. + +## Plugin coverage map + +Host-dependent assertions live in real OpenCode scenarios; component assertions +stay local. + +Configuration variants cover their distinct host behavior rather than replaying +every reader or editing assertion. The default session key, dialog startup mode, +agent bindings and diff view run the full flows; alternate configurations check +key routing, mode inheritance, initial views and focus restoration. Invalid +configuration fallback stays in unit tests. + +| Behavior | Local coverage | Real OpenCode coverage | +| --- | --- | --- | +| Prompt focus, mode/status/theme, mappings, native key passthrough, cleanup | | `prompt-input`, `runtime-*`, `tab-switching`, `agent-switching*` | +| Clipboard Unicode/CRLF/counts, undo/redo, registers, async ordering, fallback, adapter cancellation | `integration/clipboard.test.ts` | `prompt-clipboard`, `session-copy`, `clipboard-cancel-*` (late response and queued keys across focus/route/toggle/unload) | +| Dialog query changes and command selection | | `dialog-focus`, `dialog-focus-normal`, `dialog-mappings`, `dialog-multikey-mappings`, `dialog-scope`, `prompt-dialog` (mode inheritance, filtered results after edit/undo/redo, insert/normal mappings, undo isolation, mapping-prefix priority, native navigation, pending motions and unrelated extension input) | +| Question form navigation, custom answers, insert mappings, submission/cancellation and prompt restoration | | `question-forms`, `question-forms-kj`, `question-forms-zz` | +| Native prompt history, exit on movement, Unicode edits and mapped-command cursor preservation | | `prompt-history`, `prompt-command-cursor` | +| Session entry/exit, toggle key scope, mappings, control chords, narrow status layout | `unit/session-keymaps.test.ts` | `session-key-*`, `session-empty`, `session-keymaps`, `session-agent-binding` | +| Session and reader lifecycle, route changes, replacement dialogs and disable | `integration/readers/text.test.ts` | `session-lifecycle`, `runtime-*`, `message-reader` (including delayed host refocus) | +| Message reader motions, exact copy, read-only mappings/paste, streaming snapshot, paging and remembered positions | `integration/readers/text.test.ts` | `message-reader`, `reader-layout`, `session-copy` | +| Shell sections, panel mappings, whitespace, Unicode and delayed syntax/diff highlights | `integration/readers/shell.test.ts` | `shell-reader*`, `background-shell*`, `session-keymaps` | +| File reader ranges, gutters, partial/empty/non-file results, highlighting and disposal | `integration/readers/read.test.ts`, `unit/read-reader.test.ts` | `read-reader*` (including native modal resize) | +| Change views, per-view cursors, signs/colors, exact copy, mouse tabs, added/deleted/unknown patches | `integration/readers/diff.test.ts`, `unit/diff-reader.test.ts` | `diff-reader*`, `file-changes*` (including native modal resize) | +| Selection marker and yank colors, Unicode, viewport clipping, unchanged geometry | | `session-copy` (flash expiry and cancellation), `transcript-layout-*`, `transcript-partial-*` | +| Transcript source matching, anonymous/grouped/permission-blocked tool rows and patch files | | `background-shell*`, `permission-tools*`, `file-changes*`, `patch-running`, `transcript-grouped-*`, `transcript-low-detail-*`, `transcript-ungrouped-*`, `transcript-running-*`, `transcript-parts-*` | +| Exact whole-message/part clipboard payloads, including offscreen text, Markdown and Unicode | | `session-copy`, `transcript-layout-*`, `transcript-partial-*`, `transcript-parts-*` (captured OSC52 writes) | +| Messages arriving while browsing, streaming reader snapshot/selection and refreshed transcript | `integration/readers/text.test.ts` | `transcript-live-*` (real server with a controlled local model stream) | +| Visible and offscreen navigation, separate parts, latest/reasoning selection, natural bottom, virtualization and history compensation | | `transcript-layout-*`, `transcript-partial-*`, `transcript-parts-*`, `transcript-history-*` (animations on/off) | +| Source and published-package shared runtime | | `runtime-source`, `runtime-npm` (published `./tui` export, private Solid copies, reactive theme/status, manager and active-reader config unload/reload) | + +Most transcripts are imported saved messages. Live-response and prompt-history +scenarios use a controlled local OpenAI-compatible stream; OpenCode owns message +creation, history, events and rendering. Permission scenarios create real pending +requests through the server API. Clipboard assertions decode actual OSC52 output. + +Selected scenarios load `e2e/support/probe/tui.tsx`, a small driver inside the real host. It calls +OpenCode's public dialog/command/router APIs and inspects real focused editors and +theme values. Config changes exercise native plugin disposal without replacing +an active reader first. No host state or behavior is implemented by the driver. + +Clipboard cancellation scenarios use a source copy with only the external +`createHostClipboard` factory replaced by `e2e/support/clipboard-boundary.ts`. Its HTTP read +waits for an explicit release, even after disposal. The actual Vim clipboard, +input queue, focus callbacks and cleanup still execute inside real OpenCode. +Other scenarios use the packed published export (or the unmodified source in +`runtime-source`). + +Run `bun run typecheck`, `bun run test`, and `bun run test:e2e`. To work on one +host regression, pass scenario names, for example: +`bun run test:e2e message-reader session-lifecycle`. diff --git a/test/e2e/data/file-changes.ts b/test/e2e/data/file-changes.ts index f931fdd..8046158 100644 --- a/test/e2e/data/file-changes.ts +++ b/test/e2e/data/file-changes.ts @@ -1,4 +1,4 @@ -import type { Message } from "../fixture" +import type { Message } from "../support/fixture" const files = [ { file: "first.ts", status: "modified", additions: 2, deletions: 2, @@ -32,3 +32,12 @@ export const fileChangeMessages: Message[] = [ { type: "text", text: "Change fixtures ready" }, ] }, ] + +export const pendingChangeMessages: Message[] = [ + { id: "msg_patch_user", type: "user", text: "Apply the pending patch", time: { created: 1 } }, + { id: "msg_patch_running", type: "assistant", agent: "build", model: { providerID: "test", id: "fixture" }, time: { created: 2, completed: 3 }, content: [ + { type: "tool", id: "pending-patch", name: "patch", time: { created: 2 }, state: { + status: "running", input: { patchText: "*** Begin Patch\n*** Add File: pending.ts\n+pending content\n*** End Patch" }, metadata: {}, + } }, + ] }, +] diff --git a/test/e2e/data/navigation.ts b/test/e2e/data/navigation.ts new file mode 100644 index 0000000..9ee1dd5 --- /dev/null +++ b/test/e2e/data/navigation.ts @@ -0,0 +1,44 @@ +import type { Message } from "../support/fixture" + +function user(id: string, text: string, created: number): Message { + return { id, type: "user", text, time: { created } } +} + +function assistant(id: string, content: Extract["content"], created: number): Message { + return { id, type: "assistant", agent: "build", model: { providerID: "test", id: "fixture" }, content, + time: { created, completed: created + 1 } } +} + +export const layoutMessages: Message[] = [ + user("msg_old", Array.from({ length: 40 }, (_, i) => `Earlier line ${i}`).join("\n"), 1), + assistant("msg_parts", [{ type: "text", text: "First part\nFirst end" }, { type: "text", text: "Second part\nSecond end" }], 2), + user("msg_question", "Latest question", 4), + assistant("msg_latest", [{ type: "text", text: "Latest reply\nLatest middle\nLatest end" }], 5), +] + +export const partialMessages: Message[] = [ + user("msg_question", "Question", 1), + assistant("msg_partial", [{ type: "text", text: Array.from({ length: 80 }, (_, i) => `Reply line ${i} 中 👍🏽 é`).join("\n") }], 2), +] + +export const partMessages: Message[] = [ + user("msg_question", "Inspect individual parts", 1), + assistant("msg_parts", [ + { type: "text", text: "Start" }, + { type: "reasoning", text: "Check something", time: { created: 2, completed: 3 } }, + { type: "tool", id: "part-shell", name: "shell", time: { created: 3, completed: 4 }, state: { + status: "completed", input: { command: "pwd" }, content: [{ type: "text", text: "/work" }], + } }, + { type: "text", text: "Finished" }, + ], 2), + assistant("msg_thinking", [{ type: "reasoning", text: "Still thinking", time: { created: 5, completed: 6 } }], 5), +] + +export const copyMessages: Message[] = [ + assistant("msg_markdown", [{ type: "text", text: "# Heading\n\n```ts\nconst 中 = '👍🏽'\n```" }], 1), + user("msg_quote", "quoted 中 👩‍💻\nsecond line", 3), +] + +export const longReaderMessages: Message[] = [ + user("msg_long", Array.from({ length: 100 }, (_, i) => `Reader line ${String(i + 1).padStart(3, "0")}`).join("\n"), 1), +] diff --git a/test/e2e/data/permission.ts b/test/e2e/data/permission.ts new file mode 100644 index 0000000..06df8ef --- /dev/null +++ b/test/e2e/data/permission.ts @@ -0,0 +1,13 @@ +import type { Message } from "../support/fixture" + +export const permissionMessages: Message[] = [ + { id: "msg_permission_1", type: "user", text: "Check blocked and ordinary tools", time: { created: 1 } }, + { id: "msg_permission_2", type: "assistant", agent: "build", model: { providerID: "test", id: "fixture" }, time: { created: 2, completed: 3 }, content: [ + { type: "tool", id: "blocked", name: "read", time: { created: 2 }, state: { + status: "running", input: { path: "blocked.ts" }, metadata: {}, + } }, + { type: "tool", id: "ordinary", name: "read", time: { created: 2, completed: 3 }, state: { + status: "completed", input: { path: "ordinary.ts" }, content: [{ type: "text", text: "Read file ordinary.ts, lines 1-1\n1: ordinary-output" }], + } }, + ] }, +] diff --git a/test/e2e/data/read.ts b/test/e2e/data/read.ts index 0629124..7a0b1ae 100644 --- a/test/e2e/data/read.ts +++ b/test/e2e/data/read.ts @@ -1,4 +1,4 @@ -import type { Message } from "../fixture" +import type { Message } from "../support/fixture" const lines = ['const greeting = "你好 👩‍💻";', `const wrapped = "${"wrapped text ".repeat(12)}";`] for (let number = 43; number <= 100; number++) lines.push(`const value${number} = ${number};`) diff --git a/test/e2e/data/shell.ts b/test/e2e/data/shell.ts index 4b7396e..cdca59f 100644 --- a/test/e2e/data/shell.ts +++ b/test/e2e/data/shell.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict" -import type { FixtureAPI, Message } from "../fixture" +import type { FixtureAPI, Message } from "../support/fixture" export async function shellMessages({ request, workspace }: FixtureAPI): Promise { const command = "printf '\\033[32mcaptured stdout\\033[0m\\n'; printf 'captured stderr\\n' >&2; exit 7" @@ -41,6 +41,8 @@ export async function backgroundShellMessages(api: FixtureAPI): Promise message.type === "shell")! messages.push({ + id: "msg_shell_hidden", type: "synthetic", text: "Hidden fixture context", time: { created: 5 }, metadata: { source: "fixture" }, + }, { id: "msg_shell_4", type: "synthetic", description: shell.command, time: { created: 6 }, metadata: { source: "shell", shellID: shell.shellID, jobID: "background-job", state: "completed", exit: 7 }, text: `\nSaved completion preview\n`, diff --git a/test/e2e/data/transcript.ts b/test/e2e/data/transcript.ts index 8bb3648..5c176c5 100644 --- a/test/e2e/data/transcript.ts +++ b/test/e2e/data/transcript.ts @@ -1,4 +1,4 @@ -import type { Message } from "../fixture" +import type { Message } from "../support/fixture" export const readerMessages: Message[] = [ { id: "msg_e2e_reader", type: "user", text: "one two\nsecond line", time: { created: 1 } }, diff --git a/test/e2e/plugin.ts b/test/e2e/plugin.ts deleted file mode 100644 index aa79ecd..0000000 --- a/test/e2e/plugin.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { execFileSync } from "node:child_process" -import { mkdir, symlink } from "node:fs/promises" -import path from "node:path" - -export async function packPlugin(root: string, directory: string, artifacts: string) { - const manifest = await Bun.file(path.join(root, "package.json")).json() - const packed = execFileSync("npm", ["pack", "--quiet", "--pack-destination", directory], { - cwd: root, - encoding: "utf8", - timeout: 120_000, - }) - await Bun.write(path.join(artifacts, "pack.log"), packed) - const plugin = path.join(directory, "node_modules", manifest.name) - await mkdir(plugin, { recursive: true }) - execFileSync("tar", ["-xzf", path.join(directory, `${manifest.name}-${manifest.version}.tgz`), "--strip-components=1", "-C", plugin]) - await symlink(path.join(root, "node_modules"), path.join(plugin, "node_modules"), "dir") - return path.join(plugin, "dist") -} diff --git a/test/e2e/run.ts b/test/e2e/run.ts index ec4b72e..5983325 100644 --- a/test/e2e/run.ts +++ b/test/e2e/run.ts @@ -1,13 +1,13 @@ import { execFileSync } from "node:child_process" import { mkdir, mkdtemp, rm } from "node:fs/promises" import path from "node:path" -import { runWithFixture, type Fixture, type FixtureSetup } from "./fixture" -import { installOpenCode } from "./opencode" -import { packPlugin } from "./plugin" +import { runWithFixture, type Fixture, type FixtureSetup } from "./support/fixture" +import { installOpenCode } from "./support/opencode" +import { copySourcePlugin, packPlugin } from "./support/plugin" import { agentSwitching } from "./scenarios/agent-switching" -import { dialogFocus } from "./scenarios/dialog-focus" +import { dialogFocus, dialogMappings, dialogModeInheritance, dialogScope, promptDialog } from "./scenarios/dialog-focus" import { tabSwitching } from "./scenarios/tab-switching" -import { messageReader } from "./scenarios/message-reader" +import { messageReader, readerLayout } from "./scenarios/message-reader" import { transcriptGrouped, transcriptLowDetail, transcriptUngrouped, transcriptRunning, transcriptHistory } from "./scenarios/transcript" import { readerMessages, transcriptMessages, historyMessages } from "./data/transcript" import { shellMessages, backgroundShellMessages } from "./data/shell" @@ -16,15 +16,47 @@ import { backgroundShell } from "./scenarios/background-shell" import { sessionKeymaps, sessionAgentBinding } from "./scenarios/session-keymaps" import { readMessages } from "./data/read" import { readReader } from "./scenarios/read-reader" -import { fileChangeMessages } from "./data/file-changes" -import { fileChanges } from "./scenarios/file-changes" +import { fileChangeMessages, pendingChangeMessages } from "./data/file-changes" +import { fileChanges, pendingPatch } from "./scenarios/file-changes" import { diffReader } from "./scenarios/diff-reader" +import { emptySession, sessionControls, sessionKeyConflict, sessionLifecycle } from "./scenarios/session-controls" +import { transcriptLayout, transcriptPartial, transcriptParts, sessionCopy } from "./scenarios/transcript-layout" +import { layoutMessages, partialMessages, partMessages, copyMessages, longReaderMessages } from "./data/navigation" +import { transcriptLive } from "./scenarios/transcript-live" +import { pluginLifecycle } from "./scenarios/plugin-lifecycle" +import { promptClipboard, promptCommandCursor, promptHistory, promptInput } from "./scenarios/prompt-input" +import { permissionMessages } from "./data/permission" +import { permissionTools } from "./scenarios/permission-tools" +import { clipboardCancellation } from "./scenarios/clipboard-cancel" +import { questionForms } from "./scenarios/question-forms" -const scenarios: Array<{ name: string; run: (fixture: Fixture) => Promise; setup?: FixtureSetup }> = [ +const scenarios: Array<{ name: string; run: (fixture: Fixture) => Promise; source?: boolean; setup?: FixtureSetup }> = [ + { name: "prompt-input", run: promptInput, setup: { vim: { defaultMode: "normal", keymaps: { insert: { kj: "normal" }, normal: { "": "x" } } } } }, + { name: "prompt-clipboard", run: promptClipboard, setup: { vim: { keymaps: { normal: { Q: "yiw$p" } } } } }, + { name: "prompt-history", run: promptHistory, setup: { stream: "history response" } }, + { name: "prompt-command-cursor", run: promptCommandCursor, setup: { + stream: "history response", vim: { keymaps: { normal: { Q: "command:prompt.history.next" } } }, + } }, { name: "tab-switching", run: tabSwitching }, - { name: "dialog-focus", run: dialogFocus }, + { name: "dialog-focus", run: dialogFocus, setup: { probe: true } }, + { name: "dialog-focus-normal", run: dialogModeInheritance, setup: { vim: { defaultMode: "normal" } } }, + { name: "prompt-dialog", run: promptDialog }, + { name: "dialog-mappings", run: dialogMappings, setup: { probe: true, vim: { keymaps: { normal: { j: "x" } } } } }, + { name: "dialog-multikey-mappings", run: (fixture) => dialogMappings(fixture, "jj"), setup: { + probe: true, vim: { keymaps: { normal: { jj: "x" }, insert: { kj: "normal" } } }, + } }, + { name: "dialog-scope", run: dialogScope, setup: { probe: true, cli: { keybinds: { "dialog.select.next": ["down", "tab"], "dialog.select.prev": ["up", "shift+tab"] } } } }, + { name: "question-forms", run: questionForms() }, + { name: "question-forms-kj", run: questionForms("kj"), setup: { vim: { keymaps: { insert: { kj: "normal", "": "submit" } } } } }, + { name: "question-forms-zz", run: questionForms("zz"), setup: { vim: { keymaps: { insert: { zz: "", "": "submit" } } } } }, { name: "agent-switching", run: agentSwitching }, + { name: "agent-switching-tab-mapping", run: (fixture) => agentSwitching(fixture, true), setup: { vim: { keymaps: { normal: { "": "x" } } } } }, { name: "message-reader", run: messageReader, setup: { messages: readerMessages } }, + { name: "reader-layout", run: readerLayout, setup: { messages: longReaderMessages } }, + { name: "session-empty", run: emptySession }, + { name: "session-lifecycle", run: sessionLifecycle, setup: { messages: readerMessages, + vim: { keymaps: { normal: { Q: "command:opencode-vim.toggle" } } } } }, + { name: "session-copy", run: sessionCopy, setup: { messages: copyMessages, probe: true } }, { name: "shell-reader", run: shellReader, setup: { messages: shellMessages } }, { name: "shell-reader-low-detail", run: shellReader, setup: { messages: shellMessages, cli: { session: { verbosity: "low" } } } }, { name: "background-shell", run: backgroundShell, setup: { messages: backgroundShellMessages } }, @@ -33,6 +65,10 @@ const scenarios: Array<{ name: string; run: (fixture: Fixture) => Promise; { name: "read-reader-low-detail", run: readReader, setup: { messages: readMessages, cli: { session: { verbosity: "low" } } } }, { name: "file-changes", run: fileChanges, setup: { messages: fileChangeMessages, cli: { diffs: { view: "unified" } } } }, { name: "file-changes-low-detail", run: fileChanges, setup: { messages: fileChangeMessages, cli: { session: { verbosity: "low" }, diffs: { view: "split" } } } }, + { name: "file-changes-ungrouped", run: fileChanges, setup: { messages: fileChangeMessages, cli: { session: { grouping: "none" } } } }, + { name: "patch-running", run: pendingPatch, setup: { messages: pendingChangeMessages } }, + { name: "permission-tools", run: permissionTools(true), setup: { messages: permissionMessages, cli: { session: { grouping: "auto" } } } }, + { name: "permission-tools-ungrouped", run: permissionTools(false), setup: { messages: permissionMessages, cli: { session: { grouping: "none" } } } }, { name: "diff-reader", run: diffReader(), setup: { messages: fileChangeMessages } }, { name: "diff-reader-before", run: diffReader("before", true), setup: { messages: fileChangeMessages, vim: { diffView: "before", keymaps: { session: { "": "passthrough", "w": "switch-panel" } }, @@ -48,6 +84,25 @@ const scenarios: Array<{ name: string; run: (fixture: Fixture) => Promise; vim: { keymaps: { session: { "": "passthrough", "w": "switch-panel" } } }, } }, ] +for (const action of ["focus", "route", "toggle", "unload"] as const) { + scenarios.push({ name: `clipboard-cancel-${action}`, run: clipboardCancellation(action), setup: { probe: true, delayedClipboard: true } }) +} +for (const source of [false, true]) { + scenarios.push({ name: source ? "runtime-source" : "runtime-npm", run: pluginLifecycle, source, setup: { + messages: readerMessages, probe: true, + cli: { cursor: { style: "underline", blinking: true }, theme: { name: "opencode", mode: "dark" }, + keybinds: { "plugins.list": ["f6", "ctrl+g"], "theme.switch_mode": "f7" } }, + } }) +} +for (const key of ["s", "q", "", ""]) { + scenarios.push({ name: `session-key-${key.replace("", "")}`, run: sessionControls(key), + setup: { messages: readerMessages, vim: { sessionKey: key } } }) +} +for (const key of ["s", "q"]) { + scenarios.push({ name: `session-key-${key}-mapping`, run: sessionKeyConflict(key), setup: { + messages: readerMessages, vim: { sessionKey: key, keymaps: { normal: { [key + key]: "x" } } }, + } }) +} for (const animations of [true, false]) { const suffix = animations ? "animated" : "static" for (const [name, run, session, running] of [ @@ -61,6 +116,18 @@ for (const animations of [true, false]) { 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 } } }) + scenarios.push({ name: `transcript-live-${suffix}`, run: transcriptLive, setup: { + messages: readerMessages, stream: "hello", cli: { animations }, + } }) + for (const [name, run, messages] of [ + ["layout", transcriptLayout, layoutMessages], + ["partial", transcriptPartial, partialMessages], + ["parts", transcriptParts, partMessages], + ] as const) { + scenarios.push({ name: `transcript-${name}-${suffix}`, run, setup: { + messages, probe: name === "partial", cli: { animations, session: { grouping: "none", thinking: "show" } }, + } }) + } } const requested = Bun.argv.slice(2) @@ -84,6 +151,8 @@ try { const opencode = await installOpenCode() version = opencode.version const plugin = await packPlugin(root, temporary, artifacts) + const sourcePlugin = await copySourcePlugin(root, temporary) + const clipboardPlugin = await copySourcePlugin(root, temporary, true) for (const scenario of scenarios) { if (requested.length && !requested.includes(scenario.name)) continue @@ -92,7 +161,7 @@ try { await runWithFixture({ ...scenario.setup, opencode, - plugin, + plugin: scenario.setup?.delayedClipboard ? clipboardPlugin : scenario.source ? sourcePlugin : plugin, directory: path.join(temporary, scenario.name), artifacts: path.join(artifacts, scenario.name), }, scenario.run) diff --git a/test/e2e/scenarios/agent-switching.ts b/test/e2e/scenarios/agent-switching.ts index a340ec3..2701a72 100644 --- a/test/e2e/scenarios/agent-switching.ts +++ b/test/e2e/scenarios/agent-switching.ts @@ -1,6 +1,7 @@ -import type { Fixture } from "../fixture" +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" -export async function agentSwitching({ terminal }: Fixture) { +export async function agentSwitching({ terminal }: Fixture, mapped = false) { const { keys, type, screen } = terminal const draft = "agent switching draft" let agent = "" @@ -12,16 +13,32 @@ export async function agentSwitching({ terminal }: Fixture) { await keys("Escape") await screen("initial-normal", (text) => text.includes("NORMAL")) - for (const [mode, sequence] of [ + if (mapped) { + await type("0") + await keys("Tab") + await screen("mapped-tab", (text) => text.includes("gent switching draft") && !text.includes(draft) + && text.includes(`${agent} ·`) && text.includes("NORMAL")) + await type("u") + await screen("mapped-tab-undo", (text) => text.includes(draft) && text.includes("NORMAL")) + } + const modes = mapped ? [["NORMAL", ""]] : [ ["INSERT", "i"], ["NORMAL", ""], ["VISUAL", "0vl"], ["VISUAL LINE", "V"], ["SESSION", "s"], - ]) { + ] + for (const [mode, sequence] of modes) { for (let cycle = 1; cycle <= 2; cycle++) { const label = `${mode.toLowerCase().replaceAll(" ", "-")}-${cycle}` if (sequence) await type(sequence) await screen(`${label}-before`, (text) => text.includes(mode) && text.includes(draft)) + const cursor = terminal.cursor() await keys("BTab") agent = agent === "Build" ? "Plan" : "Build" await screen(`${label}-after`, (text) => text.includes(`${agent} ·`) && text.includes(mode) && text.includes(draft)) + if (mode !== "SESSION") assert.deepEqual(terminal.cursor(), cursor) + if (mode.startsWith("VISUAL")) { + await type("y") + await screen(`${label}-selection-preserved`, (text) => text.includes("NORMAL")) + assert.equal(terminal.clipboard(), mode === "VISUAL" ? "ag" : draft + "\n") + } if (mode !== "NORMAL") await keys("Escape") await screen(`${label}-prompt`, (text) => text.includes("NORMAL") && text.includes(draft)) } diff --git a/test/e2e/scenarios/background-shell.ts b/test/e2e/scenarios/background-shell.ts index eaad852..f6753df 100644 --- a/test/e2e/scenarios/background-shell.ts +++ b/test/e2e/scenarios/background-shell.ts @@ -1,5 +1,5 @@ -import type { Fixture } from "../fixture" -import { readerContains, selected } from "../screens" +import type { Fixture } from "../support/fixture" +import { readerContains, selected } from "../support/screens" export async function backgroundShell({ terminal }: Fixture) { const { keys, type, screen } = terminal diff --git a/test/e2e/scenarios/clipboard-cancel.ts b/test/e2e/scenarios/clipboard-cancel.ts new file mode 100644 index 0000000..4593f39 --- /dev/null +++ b/test/e2e/scenarios/clipboard-cancel.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" + +export function clipboardCancellation(action: "focus" | "route" | "toggle" | "unload") { + return async ({ terminal, clipboard, probe, setVimLoaded }: Fixture) => { + assert(clipboard) + const { type, keys, screen } = terminal + await type("original draft") + await keys("Escape") + await screen("original-prompt", async () => (await probe()).editor?.text === "original draft") + await probe("remember") + await type("p") + await clipboard.waitForRead() + // These keys must be discarded as well as the late clipboard response. + await type("iQUEUED") + if (action === "focus") await probe("dispatch", { command: "command.palette.show" }) + if (action === "route") await probe("route", { type: "home" }) + if (action === "toggle") await probe("dispatch", { command: "opencode-vim.toggle" }) + if (action === "unload") await setVimLoaded(false) + await screen("host-transition", async (text) => { + const state = await probe() + if (action === "focus") return text.includes("Commands") && state.editor?.text === "" + if (action === "route") return state.route.type === "home" && state.editor?.text === "" + return !text.includes("NORMAL") && !text.includes("INSERT") + }) + await clipboard.release() + const state = await probe() + assert(state.remembered.destroyed || state.remembered.text === "original draft") + assert.equal(state.editor.text, action === "focus" || action === "route" ? "" : "original draft") + // A subsequent real input turn also proves that queued keys didn't leak. + if (action === "focus" || action === "route") await type("i") + else await keys("C-e") + await type("fresh") + await screen("fresh-input", async () => (await probe()).editor?.text === + (action === "focus" || action === "route" ? "fresh" : "original draftfresh")) + } +} diff --git a/test/e2e/scenarios/dialog-focus.ts b/test/e2e/scenarios/dialog-focus.ts index 25ef936..3d0fd89 100644 --- a/test/e2e/scenarios/dialog-focus.ts +++ b/test/e2e/scenarios/dialog-focus.ts @@ -1,7 +1,86 @@ -import type { Fixture } from "../fixture" +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" -export async function dialogFocus({ terminal }: Fixture) { +export async function dialogMappings({ terminal, probe }: Fixture, mapping = "j") { const { keys, type, screen } = terminal + await type("prompt stays here") + await keys("Escape", "C-p") + await screen("mapped-dialog", (text) => text.includes("Commands") && text.includes("NORMAL")) + await type("ijOpen settings") + if (mapping === "jj") { + await type("kj") + await screen("insert-mapping-normal", async (text) => text.includes("NORMAL") && (await probe()).editor.text === "jOpen settings") + } else { + await keys("Escape") + } + // A multi-key prefix must reach Vim instead of navigating the host's list. + await type("0" + mapping) + // Inspect the input so a matching command-list label cannot satisfy the check. + await screen("mapped-query-edit", async (text) => (await probe()).editor.text === "Open settings" && text.includes("NORMAL")) + await type("u") + await screen("query-undo-events", async () => (await probe()).editor.text === "jOpen settings") + await keys("C-r") + await screen("query-redo-events", async () => (await probe()).editor.text === "Open settings") + await keys("Escape") + await screen("mapped-dialog-dismissed", (text) => !text.includes("Commands") && text.includes("prompt stays here") && text.includes("NORMAL")) +} + +export async function dialogScope({ terminal, probe }: Fixture) { + const { keys, type, screen } = terminal + await type("prompt draft") + await keys("Escape") + // Use OpenCode's actual select dialog with multiple known choices. Submit + // after a pending Vim motion so accidental host navigation is observable. + for (const motion of ["fj", "fk", "dj", "dk"]) { + await probe("select") + await screen(`${motion}-opened`, (text) => text.includes("E2E choices") && text.includes("NORMAL")) + // Start away from the boundary so either next or previous is detectable. + await keys("Down") + await type(motion) + await keys("Enter") + await screen(`${motion}-submitted`, async () => (await probe()).result === "2") + } + for (const [key, expected] of [["Down", "2"], ["End", "40"], ["Home", "1"], ["Up", "1"], ["Tab", "2"], ["BTab", "1"]]) { + await probe("select") + await screen(`${key}-opened`, (text) => text.includes("E2E choices")) + if (key === "Home" || key === "Up" || key === "BTab") await keys("Down") + await keys(key, "Enter") + await screen(`${key}-submitted`, async () => (await probe()).result === expected) + } + await probe("select") + await screen("page-opened", (text) => text.includes("E2E choices") && text.includes("Choice 01")) + await keys("NPage") + await screen("page-down", (text) => !text.includes("Choice 01") && text.includes("E2E choices")) + await keys("PPage", "Enter") + await screen("page-up", async () => (await probe()).result === "1") + + await probe("select") + await screen("arrows-opened", (text) => text.includes("E2E choices")) + await type("iChoice") + await keys("Escape") + const cursor = (await probe()).editor.cursor + await keys("Left") + await screen("left-native", async () => (await probe()).editor.cursor === cursor - 1) + await keys("Right") + await screen("right-native", async () => (await probe()).editor.cursor === cursor) + await keys("Escape") + await screen("escape-native", (text) => !text.includes("E2E choices") && text.includes("prompt draft")) + + // A real extension dialog without select/prompt commands must stay native. + await probe("native") + await screen("native-opened", async (text) => text.includes("Native extension input") && (await probe()).editor?.id === "e2e-native") + await keys("End") + await type("xj") + await screen("native-literal-input", async () => (await probe()).editor?.text === "nativexj") + await keys("Escape") + await screen("native-closed", (text) => !text.includes("Native extension input")) + assert.equal((await probe()).editor.text, "prompt draft") +} + +export async function dialogModeInheritance({ terminal }: Fixture) { + const { keys, type, screen } = terminal + await keys("Escape") + await type("i") for (const mode of ["INSERT", "NORMAL"]) { const label = `dialog-${mode.toLowerCase()}` await type("original draft") @@ -18,12 +97,6 @@ export async function dialogFocus({ terminal }: Fixture) { await screen(`${label}-filtered`, (text) => text.includes("Open settings") && !text.includes("New session")) await keys("Escape") 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") { @@ -32,17 +105,18 @@ export async function dialogFocus({ terminal }: Fixture) { } await type(" restored") await screen(`${label}-restored`, (text) => text.includes("original draft restored")) - await keys("Escape") - await screen(`${label}-prompt-normal`, (text) => text.includes("NORMAL")) - await type("0dw") - await screen(`${label}-prompt-edited`, (text) => text.includes("draft restored") && !text.includes("original")) - await type("i") - await screen(`${label}-prompt-insert`, (text) => text.includes("INSERT")) await keys("C-c") - await screen(`${label}-cleared`, (text) => !text.includes("draft restored")) + await screen(`${label}-cleared`, (text) => !text.includes("original draft restored")) } +} - await keys("C-p", "Escape") +export async function dialogFocus(fixture: Fixture) { + await dialogModeInheritance(fixture) + const { terminal, probe } = fixture + const { keys, type, screen } = terminal + await keys("C-p") + await screen("dialog-navigation-opened", (text) => text.includes("Commands") && text.includes("INSERT")) + await keys("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")) @@ -52,6 +126,15 @@ export async function dialogFocus({ terminal }: Fixture) { 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")) + // Clearing the query must update the results, not just the editor's text. + await type("0d$") + await screen("dialog-query-cleared", async (text) => (await probe()).editor.text === "" && text.includes("Switch session")) + await type("u") + await screen("dialog-query-undo", async (text) => (await probe()).editor.text === "Open settings" && !text.includes("Switch session")) + await keys("C-r") + await screen("dialog-query-redo", async (text) => (await probe()).editor.text === "" && text.includes("Switch session")) + await type("u") + await screen("dialog-query-restored", async (text) => (await probe()).editor.text === "Open settings" && !text.includes("Switch session")) await keys("Enter") await screen("dialog-submitted", (text) => text.includes("Settings") && !text.includes("Commands") && text.includes("INSERT")) await keys("Escape") @@ -60,4 +143,42 @@ export async function dialogFocus({ terminal }: Fixture) { 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")) + + await keys("C-c") + await type("original draft") + await keys("Escape") + await type("0x") + await screen("undo-prompt-edited", (text) => text.includes("riginal draft")) + await keys("C-p") + await screen("undo-dialog-opened", (text) => text.includes("Commands") && text.includes("NORMAL")) + await type("iOpen settings") + await keys("Escape") + await type("0x") + await screen("undo-dialog-edited", (text) => /^\s+pen settings\s*$/m.test(text)) + await keys("Escape") + await screen("undo-dialog-closed", (text) => !text.includes("Commands") && text.includes("NORMAL")) + await type("u") + await screen("undo-prompt-restored", (text) => text.includes("original draft")) +} + +export async function promptDialog({ terminal, request, sessionID }: Fixture) { + const { keys, type, screen } = terminal + await type("unsent draft") + for (const mode of ["INSERT", "NORMAL"]) { + if (mode === "NORMAL") await keys("Escape") + await keys("C-p") + await screen(`rename-${mode}-palette`, (text) => text.includes("Commands") && text.includes(mode)) + if (mode === "NORMAL") await type("i") + await type("Rename session") + await screen(`rename-${mode}-command`, (text) => text.includes("Rename session") && !text.includes("New session")) + if (mode === "NORMAL") await keys("Escape") + await keys("Enter") + await screen(`rename-${mode}-opened`, (text) => text.includes("Rename") && !text.includes("Commands") && text.includes(mode)) + if (mode === "INSERT") await keys("Escape") + await type("0d$iRenamed fixture") + await keys("Escape", "Enter") + await screen(`rename-${mode}-submitted`, (text) => text.includes("Renamed fixture") && text.includes("unsent draft") && text.includes(mode) && !text.includes("submit")) + } + const exported = (await request(`/api/experimental/session/${sessionID}/export`)).data + if (exported.messages.length !== 0) throw new Error("Renaming submitted the main prompt") } diff --git a/test/e2e/scenarios/diff-reader.ts b/test/e2e/scenarios/diff-reader.ts index 6629f40..df02de9 100644 --- a/test/e2e/scenarios/diff-reader.ts +++ b/test/e2e/scenarios/diff-reader.ts @@ -1,6 +1,6 @@ import type { DiffView } from "../../../src/modules/vim/config" -import type { Fixture } from "../fixture" -import { reader, readerContains, selected } from "../screens" +import type { Fixture } from "../support/fixture" +import { reader, readerContains, selected } from "../support/screens" export function diffReader(initial: DiffView = "after", remapped = false) { return async ({ terminal }: Fixture) => { @@ -25,8 +25,10 @@ export function diffReader(initial: DiffView = "after", remapped = false) { } await keys("Enter") const views: DiffView[] = ["after", "before", "diff"] + // The default view owns the full reader flow; variants check their wiring. + const full = initial === "after" let view = initial - for (let step = 0; step < 3; step++) { + for (let step = 0; step < (full ? 3 : 1); step++) { await screen(`view-${view}`, (text, ansi) => { const modal = reader(text) if (!modal) return false @@ -57,26 +59,33 @@ export function diffReader(initial: DiffView = "after", remapped = false) { await switchView() view = views[(views.indexOf(view) + 1) % views.length] } - await screen("initial-view-restored", (text) => readerContains(text, description(initial))) - const clicked = initial === "before" ? "after" : "before" - let target = { x: 0, y: 0 } - await screen("click-target", (text) => { - const rows = text.split("\n") - const y = rows.findIndex((row) => /View.*After.*Before.*Diff/.test(row)) - if (y < 0) return false - target = { x: rows[y].indexOf(label(clicked)), y } - return target.x >= 0 - }) - await click(target.x, target.y) - await screen("clicked-view", (text) => readerContains(text, description(clicked)) - && readerContains(text, clicked === "after" ? "new first" : "old first")) - await type("G") - await screen("last-change", (text) => /20\s+const last/.test(reader(text)?.content ?? "") - && text.split("\n")[terminal.cursor().y]?.includes("last") === true) - await type("Vy") - await screen("last-change-copied", (text) => text.includes("Copied")) - await type("gg") - await screen("first-change", (text) => text.split("\n")[terminal.cursor().y]?.includes("first") === true) + await screen("cycled-view", (text) => readerContains(text, description(view))) + if (full) { + let target = { x: 0, y: 0 } + await screen("click-target", (text) => { + const rows = text.split("\n") + const y = rows.findIndex((row) => /View.*After.*Before.*Diff/.test(row)) + if (y < 0) return false + target = { x: rows[y].indexOf("Before"), y } + return target.x >= 0 + }) + await click(target.x, target.y) + await screen("clicked-view", (text) => readerContains(text, description("before")) && readerContains(text, "old first")) + await type("G") + await screen("last-change", (text) => /20\s+const last/.test(reader(text)?.content ?? "") + && text.split("\n")[terminal.cursor().y]?.includes("last") === true) + await type("Vy") + await screen("last-change-copied", (text) => text.includes("Copied")) + await type("gg") + await screen("first-change", (text) => text.split("\n")[terminal.cursor().y]?.includes("first") === true) + terminal.resize(44, 20) + await screen("narrow-change-reader", (text) => { + const frame = reader(text) + return !!frame && frame.top >= 0 && frame.bottom < 20 && frame.content.includes(description("before")) + }) + terminal.resize(120, 38) + await screen("wide-change-reader", (text) => readerContains(text, description("before"))) + } await type("s") await screen("prompt-restored", (text) => text.includes("diff draft") && text.includes("NORMAL") && !text.includes("SESSION")) await type("A restored") @@ -90,10 +99,6 @@ export function diffReader(initial: DiffView = "after", remapped = false) { } } -function label(view: DiffView) { - return view === "after" ? "After" : view === "before" ? "Before" : "Diff" -} - function description(view: DiffView) { return view === "diff" ? "Changes" : `Code ${view} change` } diff --git a/test/e2e/scenarios/file-changes.ts b/test/e2e/scenarios/file-changes.ts index 1c72f94..6aba25d 100644 --- a/test/e2e/scenarios/file-changes.ts +++ b/test/e2e/scenarios/file-changes.ts @@ -1,9 +1,23 @@ -import type { Fixture } from "../fixture" -import { reader, selected } from "../screens" +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" +import { reader, readerContains, selected } from "../support/screens" + +export async function pendingPatch({ terminal }: Fixture) { + const { keys, type, screen } = terminal + await keys("Escape") + await type("s") + await screen("patch-running", (text) => selected(text, "Patching")) + await keys("Enter") + await screen("patch-fallback-reader", (text) => readerContains(text, "Patching pending.ts") && !text.includes("Saved excerpts")) + await type("yy") + await screen("patch-fallback-copied", (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), "Patching pending.ts\n") +} export async function fileChanges({ terminal }: Fixture) { const { keys, type, screen } = terminal const names = ["first.ts", "added.ts", "deleted.ts", "edited-one.ts", "edited-two.ts"] + const contents = ['const first = "new first";\n\nconst last = "new last";\n', "added content\n", "deleted content\n", "after\n", "after\n"] await type("change draft") await keys("Escape") await screen("normal", (text) => text.includes("NORMAL")) @@ -35,6 +49,9 @@ export async function fileChanges({ terminal }: Fixture) { } return true }) + await type("ggVGy") + await screen(`file-${index}-copied`, (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), contents[index]) await keys("Escape") await screen(`file-${index}-returned`, (text) => selectionIs(text, name) && !text.includes("v select")) await type("j") diff --git a/test/e2e/scenarios/message-reader.ts b/test/e2e/scenarios/message-reader.ts index dc0d337..6c5afc6 100644 --- a/test/e2e/scenarios/message-reader.ts +++ b/test/e2e/scenarios/message-reader.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" -import type { Fixture } from "../fixture" -import { reader, readerContains } from "../screens" +import type { Fixture } from "../support/fixture" +import { reader, readerContains } from "../support/screens" export async function messageReader({ terminal }: Fixture) { const { keys, type, screen, cursor } = terminal @@ -34,14 +34,23 @@ export async function messageReader({ terminal }: Fixture) { await keys("C-c") await screen("reader-ctrl-c", (text) => text.includes("SESSION") && !text.includes("v select")) + for (const dismissal of ["button", "backdrop"]) { + await keys("Enter") + await screen(`reader-${dismissal}-opened`, (text) => { + frame = reader(text) + return frame?.content.includes("one two") ?? false + }) + assert.deepEqual(cursor(), remembered) + assert(frame) + await terminal.click(dismissal === "button" ? frame.right - 2 : 0, dismissal === "button" ? frame.top : 0) + await screen(`reader-${dismissal}-closed`, (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") - } + // Back-to-back keys catch delayed host refocus stealing prompt focus. + await keys("Escape", exit) await screen(`reader-${exit}-prompt`, (text) => text.includes("NORMAL") && !text.includes("SESSION") && !text.includes("v select")) await keys("PageUp") await type("A restored") @@ -54,3 +63,33 @@ export async function messageReader({ terminal }: Fixture) { await screen(`reader-${exit}-session`, (text) => text.includes("SESSION")) } } + +export async function readerLayout({ terminal }: Fixture) { + const { type, keys, screen } = terminal + await keys("Escape") + await type("s") + await keys("Enter") + await screen("long-reader", (text) => readerContains(text, "Reader line 001")) + await keys("C-d", "C-f") + let remembered = "" + await screen("reader-paged", (text) => { + const line = text.split("\n")[terminal.cursor().y] ?? "" + remembered = /Reader line \d+/.exec(line)?.[0] ?? "" + return !!remembered && remembered !== "Reader line 001" + }) + terminal.resize(44, 20) + await screen("reader-resized", (text) => { + const frame = reader(text) + return !!frame && frame.left > 0 && frame.right < 44 && frame.top >= 0 && frame.bottom < 20 + }) + await keys("Escape", "Enter") + await screen("reader-position-restored", (text) => text.split("\n")[terminal.cursor().y]?.includes(remembered) === true) + await keys("C-u") + await screen("reader-page-up", (text) => { + const line = text.split("\n")[terminal.cursor().y] ?? "" + const number = /Reader line (\d+)/.exec(line)?.[1] + return !!number && Number(number) < Number(remembered.slice(-3)) + }) + await type("siresized prompt") + await screen("resized-prompt-focus", (text) => text.includes("resized prompt") && text.includes("INSERT")) +} diff --git a/test/e2e/scenarios/permission-tools.ts b/test/e2e/scenarios/permission-tools.ts new file mode 100644 index 0000000..fc60444 --- /dev/null +++ b/test/e2e/scenarios/permission-tools.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" +import { readerContains, selected } from "../support/screens" + +export function permissionTools(grouped: boolean) { + return async ({ terminal, request, sessionID }: Fixture) => { + const { keys, type, screen } = terminal + await request(`/api/session/${sessionID}`, { permissions: [{ action: "*", resource: "*", effect: "ask" }] }, "PATCH") + await keys("Escape") + await type("s") + await screen("session-before-permission", (text) => text.includes("SESSION")) + const permission = (await request(`/api/session/${sessionID}/permission`, { + action: "read", resources: ["blocked.ts"], source: { type: "tool", messageID: "msg_permission_2", id: "blocked" }, + })).data + assert.equal(permission.effect, "ask") + assert.equal((await request(`/api/session/${sessionID}/permission`)).data[0].source.id, "blocked") + await type("gg") + await screen("first-message", (text) => selected(text, "Check blocked and ordinary tools")) + await type("j") + if (grouped) { + await screen("collapsed-group", (text) => selected(text, "read") && text.includes("blocked.ts")) + await type("j") + await screen("collapsed-blocked-selected", (text) => selected(text, "blocked.ts") && !text.includes("ordinary.ts")) + await keys("Enter") + await screen("collapsed-blocked-reader", (text) => readerContains(text, "Read blocked.ts") && !readerContains(text, "ordinary")) + await type("ggVGy") + await screen("collapsed-blocked-copied", (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), "Read blocked.ts\n") + await keys("Escape") + await type("k") + await screen("collapsed-group-return", (text) => selected(text, "read") && !text.includes("ordinary.ts")) + await keys("Enter") + await screen("expanded", (text) => text.includes("ordinary.ts")) + await type("j") + } + // OpenCode moves blocked reads after ordinary reads, even with grouping + // disabled. Source order is deliberately the reverse of display order. + const paths = ["ordinary.ts", "blocked.ts"] + for (const name of paths) { + const other = name === "ordinary.ts" ? "blocked.ts" : "ordinary.ts" + await screen(`${name}-selected`, (text) => selected(text, name) && !selected(text, other)) + await keys("Enter") + await screen(`${name}-reader`, (text) => text.includes("v select") && readerContains(text, "ordinary-output") === (name === "ordinary.ts") && !readerContains(text, other)) + await type("ggVGy") + await screen(`${name}-copied`, (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), name === "ordinary.ts" ? "ordinary-output\n" : "Read blocked.ts\n") + await keys("Escape") + await type("j") + } + await request(`/api/session/${sessionID}/permission/${permission.id}/reply`, { decision: "reject" }) + assert.deepEqual((await request(`/api/session/${sessionID}/permission`)).data, []) + } +} diff --git a/test/e2e/scenarios/plugin-lifecycle.ts b/test/e2e/scenarios/plugin-lifecycle.ts new file mode 100644 index 0000000..abe6236 --- /dev/null +++ b/test/e2e/scenarios/plugin-lifecycle.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" +import { readerContains, selected } from "../support/screens" + +export async function pluginLifecycle({ terminal, probe, setVimLoaded }: Fixture) { + const { keys, type, screen } = terminal + await type("runtime draft") + await screen("insert", (text) => text.includes("runtime draft") && text.includes("INSERT")) + await keys("Escape") + await type("i") + await screen("insert-cursor", (text) => text.includes("INSERT") && terminal.cursorStyle() === 5) + assert.equal(terminal.cursorStyle(), 5) + await keys("Escape") + await type("0x") + await screen("normal-edit", (text) => text.includes("untime draft") && text.includes("NORMAL")) + assert.equal(terminal.cursorStyle(), 1) + await type("u") + await screen("undo", (text) => text.includes("runtime draft")) + await type("yiw") + await screen("yank", () => terminal.clipboard() === "runtime") + + // A real theme command must update the existing Solid status contribution. + let dark = "" + await screen("dark-status", (text, ansi) => { + dark = statusColor(ansi) + return text.includes("NORMAL") && !!dark + }) + await keys("F7") + await screen("light-status", (text, ansi) => text.includes("NORMAL") && !!statusColor(ansi) && statusColor(ansi) !== dark) + + let nativeListeners = 0 + for (const state of ["visual", "session", "reader"]) { + if (state === "visual") { + await type("0vl") + await screen(`${state}-active`, (text) => text.includes("VISUAL")) + } else { + await type("s") + await screen(`${state}-active`, (text) => selected(text, "one two")) + if (state === "reader") { + await keys("Enter") + await screen("reader-active-modal", (text) => readerContains(text, "one two")) + } + } + // Deactivate through OpenCode's plugin manager, exercising real cleanup. + await keys(state === "visual" ? "F6" : "C-g") + await screen(`${state}-manager`, (text) => text.includes("Plugins") && text.includes("opencode-vim")) + await keys("Enter") + await screen(`${state}-unloaded`, (text) => text.includes("inactive") && !text.includes("NORMAL") && !text.includes("VISUAL") && !text.includes("SESSION")) + await keys("Escape") + await screen(`${state}-native`, (text) => !text.includes("Plugins") && !text.includes("▎") && !text.includes("v select")) + nativeListeners = (await probe()).listeners + assert.equal(terminal.cursorStyle(), 3, "Unloading restores the host's underline cursor") + await keys("C-a") + await screen(`${state}-native-line-start`, (text) => text.split("\n")[terminal.cursor().y]?.indexOf("runtime draft") === terminal.cursor().x) + await type("x") + await screen(`${state}-native-edit`, (text) => text.includes("xruntime draft")) + await keys("BSpace") + await screen(`${state}-native-restored`, (text) => text.split("\n")[terminal.cursor().y]?.trim() === "┃ runtime draft") + await keys("F6", "Enter") + await screen(`${state}-reloaded`, (text) => text.includes("Plugins") && !text.includes("inactive") && text.includes("INSERT")) + await keys("Escape", "Escape") + await screen(`${state}-prompt-restored`, (text) => !text.includes("Plugins") && text.includes("runtime draft") && text.includes("INSERT")) + await keys("Escape") + await type("0") + await screen(`${state}-normal-restored`, (text) => text.includes("NORMAL") && !text.includes("SESSION")) + } + + // Config hot-reload unloads Vim without opening/replacing its active reader. + await type("s") + await keys("Enter") + await screen("direct-reader-open", (text) => readerContains(text, "one two")) + await type("vl") + await screen("direct-reader-selection", async (text) => text.includes("VISUAL") && (await probe()).editor?.selected) + const before = await probe("remember") + assert(before.listeners > nativeListeners) + await setVimLoaded(false) + await screen("direct-reader-unloaded", async (text) => { + const state = await probe() + return state.remembered?.destroyed && state.mode === "base" && state.listeners === nativeListeners + && !text.includes("▎") && !text.includes("v select") && !text.includes("SESSION") + }) + assert.equal(terminal.cursorStyle(), 3) + await keys("C-e") + await type(" after unload") + await screen("direct-unload-native-input", async () => (await probe()).editor?.text === "runtime draft after unload") +} + +function statusColor(ansi: string) { + const row = ansi.split("\n").find((line) => line.includes("NORMAL")) ?? "" + const before = row.slice(0, row.indexOf("NORMAL")) + let color = "" + for (const match of before.matchAll(/\x1b\[([\d;]*)m/g)) { + const foreground = /(?:^|;)(38;2;\d+;\d+;\d+|38;5;\d+)(?:;|$)/.exec(match[1]) + if (foreground) color = foreground[1] + } + return color +} diff --git a/test/e2e/scenarios/prompt-input.ts b/test/e2e/scenarios/prompt-input.ts new file mode 100644 index 0000000..1262737 --- /dev/null +++ b/test/e2e/scenarios/prompt-input.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" + +export async function promptInput({ terminal }: Fixture) { + const { keys, type, screen } = terminal + // This scenario starts NORMAL, so the already-focused native prompt must be intercepted. + await type("x") + await screen("startup-intercepted", (text) => text.includes("NORMAL") && promptLine(text, terminal.cursor().y) === "") + await type("ihello") + await screen("native-insert", (text) => text.includes("hello") && text.includes("INSERT")) + await type("kj") + await screen("mapped-normal", (text) => text.includes("NORMAL") && text.includes("hello") && !text.includes("hellok")) + await type("0") + await keys("Tab") + await screen("mapped-tab", (text) => text.includes("ello") && !text.includes("hello")) + await type("u") + await screen("mapped-tab-undone", (text) => text.includes("hello")) + const cursor = terminal.cursor() + await type("d") + await screen("pending-operator", (text) => text.includes("NORMAL") && text.includes("hello")) + await keys("Escape") + assert.deepEqual(terminal.cursor(), cursor) + // The host's leader sequence must survive even when its suffix is a Vim command. + await keys("C-x", "l") + await screen("host-leader-dialog", (text) => text.includes("Sessions") && text.includes("NORMAL")) + await keys("Escape") + await type("A restored") + await screen("focus-restored", (text) => text.includes("hello restored") && text.includes("INSERT")) +} + +export async function promptClipboard({ terminal }: Fixture) { + const { keys, type, screen } = terminal + await type("hello") + await keys("Escape") + await type("0ddp") + await screen("rapid-cut-put", (text) => promptLine(text, terminal.cursor().y) === "hello" && text.includes("NORMAL")) + assert.equal(terminal.clipboard(), "hello\n") + await type("0xxp") + await screen("ordered-cuts", (text) => promptLine(text, terminal.cursor().y) === "lelo") + assert.equal(terminal.clipboard(), "e") + + await type("i") + await keys("C-c") + await type("A") + await keys("Escape") + await type("yiw") + await screen("seed-register", () => terminal.clipboard() === "A") + await type("i") + await keys("C-c", "Escape") + // One input batch exercises the plugin's async queue, including native paste. + await type("ppiB\x1b[200~C\x1b[201~") + await screen("queued-native-input", (text) => promptLine(text, terminal.cursor().y) === "ABCA" && text.includes("INSERT")) + // OpenCode resolves pasted attachments asynchronously before inserting text. + await keys("Escape") + + await type("i") + await keys("C-c") + await type("hello") + await keys("Escape") + await type('0"ayiw$"ap') + await screen("named-register", (text) => promptLine(text, terminal.cursor().y) === "hellohello") + assert.equal(terminal.clipboard(), "A") + await type("p") + await screen("unnamed-register", (text) => promptLine(text, terminal.cursor().y) === "hellohelloA") + await type("0Q") + await screen("mapped-yank-put", (text) => promptLine(text, terminal.cursor().y) === "hellohelloAhellohelloA") + + // A native dialog and the prompt use separate editors but share clipboard fallback. + await type("i") + await keys("C-c", "Escape", "C-p") + await screen("clipboard-dialog", (text) => text.includes("Commands") && text.includes("NORMAL")) + await type("iOpen settings") + await keys("Escape") + await type("0yiw") + await screen("dialog-yank", () => terminal.clipboard() === "Open") + await keys("Escape") + await type("p") + await screen("dialog-to-prompt", (text) => !text.includes("Commands") && promptLine(text, terminal.cursor().y) === "Open") + await type("A shared") + await keys("Escape") + await type("0y$") + await screen("prompt-yank", () => terminal.clipboard() === "Open shared") + await keys("C-p") + await screen("clipboard-dialog-again", (text) => text.includes("Commands") && text.includes("NORMAL")) + await type("p") + await screen("prompt-to-dialog", (text) => text.includes("Commands") && /^\s+Open shared\s*$/m.test(text)) +} + +export async function promptHistory(fixture: Fixture) { + const { terminal } = fixture + const { keys, type, screen } = terminal + await seedHistory(fixture) + const start = terminal.cursor() + await type("k") + await screen("history-older", (text) => promptLine(text, terminal.cursor().y) === "older 中中") + await type("j") + await screen("history-forward", (text) => promptLine(text, terminal.cursor().y) === "newer 文文") + assert.equal(terminal.cursor().x, start.x + 10, "History forward restores display-width cursor offsets") + await type("j") + await screen("history-empty", (text) => promptLine(text, terminal.cursor().y) === "") + await type("k") + await screen("history-recalled", (text) => promptLine(text, terminal.cursor().y) === "newer 文文") + // Movement alone must end history navigation. Edit only after the second k + // so the edit cannot hide a missing reset, and wait for all keys to finish. + await type("hkiX") + await screen("movement-ended-history", (text) => promptLine(text, terminal.cursor().y) === "Xnewer 文文" && text.includes("INSERT")) + await keys("Escape") + await type("k") + await screen("edited-history-is-draft", (text) => promptLine(text, terminal.cursor().y) === "Xnewer 文文" && text.includes("NORMAL")) +} + +export async function promptCommandCursor(fixture: Fixture) { + const { terminal } = fixture + const { type, screen } = terminal + await seedHistory(fixture) + await type("k") + await screen("history-older", (text) => promptLine(text, terminal.cursor().y) === "older 中中") + // Q dispatches history-next. The adapter must retain the host's new cursor, + // including the display width of Unicode text, instead of restoring its old one. + await type("QiX") + await screen("mapped-command-retained-cursor", (text) => promptLine(text, terminal.cursor().y) === "newer 文文X" && text.includes("INSERT")) +} + +async function seedHistory({ terminal, stream }: Fixture) { + assert(stream) + const { keys, type, screen } = terminal + for (const [index, value] of ["older 中中", "newer 文文"].entries()) { + await type(value) + await keys("Enter") + await screen(`submitted-${index}`, (text) => text.split("history response").length === index + 2) + stream.write("", true) + } + await keys("Escape") + await type("k") + await screen("history-newest", (text) => promptLine(text, terminal.cursor().y) === "newer 文文") +} + +function promptLine(text: string, row: number) { + return (text.split("\n")[row] ?? "").replace(/^\s*┃\s?/, "").trim() +} diff --git a/test/e2e/scenarios/question-forms.ts b/test/e2e/scenarios/question-forms.ts new file mode 100644 index 0000000..319a95b --- /dev/null +++ b/test/e2e/scenarios/question-forms.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" + +export function questionForms(mapping?: string) { + return async ({ terminal, request, sessionID }: Fixture) => { + const { keys, type, screen } = terminal + const endpoint = `/api/session/${sessionID}/form` + async function normal() { + if (mapping) await type(mapping) + else await keys("Escape") + } + function row(ansi: string, label: string) { + return ansi.split("\n").find((line) => line.includes(label)) + } + + await type("original draft") + for (const multiple of [false, true]) { + const label = multiple ? "multiple" : "single" + if (multiple) await type("i") + else await keys("Escape") + const promptMode = multiple ? "INSERT" : "NORMAL" + await screen(`${label}-prompt`, (text) => text.includes("original draft") && text.includes(promptMode)) + const form = (await request(endpoint, { + title: `Vim question ${label}`, + metadata: { kind: "question" }, + fields: [{ + key: "answer", title: "Choice", description: "Choose an option or type a response", + type: multiple ? "multiselect" : "string", + options: [{ value: "alpha", label: "Alpha" }, { value: "beta", label: "Beta" }], + custom: true, + }], + })).data + let firstSelected: string | undefined + let secondUnselected: string | undefined + let customUnselected: string | undefined + await screen(`${label}-opened`, (text, ansi) => { + if (!text.includes("Type your own answer") || !text.includes("NORMAL")) return false + firstSelected = row(ansi, "Alpha") + secondUnselected = row(ansi, "Beta") + customUnselected = row(ansi, "Type your own answer") + return true + }) + await type("j") + let secondSelected: string | undefined + await screen(`${label}-next`, (text, ansi) => { + secondSelected = row(ansi, "Beta") + return text.includes("NORMAL") && secondSelected !== secondUnselected + }) + await type("j") + await screen(`${label}-custom`, (text, ansi) => text.includes("NORMAL") + && row(ansi, "Type your own answer") !== customUnselected) + await type("k") + await screen(`${label}-leave-custom`, (text, ansi) => text.includes("Type your own answer") + && text.includes("NORMAL") && row(ansi, "Beta") === secondSelected) + await type("j") + await screen(`${label}-custom-again`, (text, ansi) => text.includes("NORMAL") + && row(ansi, "Type your own answer") !== customUnselected) + + // Open and leave insert mode in a single burst, before the host's + // editor-focus microtask runs. Custom mappings must work here too. + if (mapping) await type(`i${mapping}`) + else await keys("i", "Escape") + await screen(`${label}-burst-normal`, (text) => text.includes("NORMAL") + && text.includes("Type your own answer") && text.includes("esc dismiss")) + await type("iresponse") + await screen(`${label}-typing`, (text) => text.includes("INSERT") && /3\..*response/.test(text)) + await normal() + await screen(`${label}-mapped-normal`, (text) => text.includes("NORMAL") + && /3\..*response/.test(text) && text.includes("esc dismiss")) + await type("k") + await screen(`${label}-leave-answer`, (text, ansi) => text.includes("NORMAL") + && row(ansi, "Beta") === secondSelected && /3\..*response/.test(text)) + await type("j") + if (multiple) await type("i") + else await keys("Enter") + await screen(`${label}-enter-edits`, (text) => text.includes("INSERT") && text.includes("esc close")) + let answer = "response" + if (!mapping) { + await type("kj") + answer += "kj" + await screen(`${label}-unmapped-kj-is-text`, (text) => text.includes("INSERT") && text.includes(answer)) + } else { + await type(mapping[0]) + answer += mapping[0] + await screen(`${label}-mapping-timeout`, (text) => text.includes("INSERT") && text.includes(answer)) + await type(mapping[0] + "x") + answer += mapping[0] + "x" + await screen(`${label}-mapping-mismatch`, (text) => text.includes("INSERT") && text.includes(answer)) + } + await keys("Escape") + await screen(`${label}-escape-normal`, (text) => text.includes("NORMAL") + && text.includes(answer) && text.includes("esc dismiss")) + // Navigation remains usable after leaving a nonempty answer. + await type("kk") + await screen(`${label}-back-to-first`, (text, ansi) => text.includes("NORMAL") + && row(ansi, "Alpha") === firstSelected && text.includes(answer)) + await type("jj") + if (multiple) await type("i") + else await keys("Enter") + await screen(`${label}-ready-to-submit`, (text) => text.includes("INSERT") && text.includes(answer)) + await keys(mapping ? "C-s" : "Enter") + if (multiple) { + await screen(`${label}-committed`, (text) => text.includes("NORMAL") && text.includes("enter toggle")) + await keys("Tab") + await screen(`${label}-review`, (text) => text.includes(`Choice: ${answer}`) && text.includes("NORMAL")) + await keys("Enter") + } + await screen(`${label}-restored`, (text) => !text.includes(`Vim question ${label}`) + && text.includes("original draft") && text.includes(promptMode)) + const detail = (await request(`${endpoint}/${form.id}`)).data + assert.deepEqual(detail.state, { status: "answered", answer: { answer: multiple ? [answer] : answer } }) + } + if (mapping) return + + const fields = (await request(endpoint, { + title: "Several questions", + metadata: { kind: "question" }, + fields: [ + { key: "targets", title: "Targets", type: "multiselect", custom: true, + options: [{ value: "alpha", label: "Alpha" }, { value: "beta", label: "Beta" }] }, + { key: "other", title: "Other", type: "string", custom: true, + options: [{ value: "one", label: "One" }, { value: "two", label: "Two" }] }, + ], + })).data + await screen("fields-open", (text) => text.includes("Several questions") && text.includes("Alpha") && text.includes("NORMAL")) + await type("jj1") + await screen("fields-number-from-custom", (text) => text.includes("[✓] Alpha") && text.includes("Type your own answer") && text.includes("NORMAL")) + await keys("Space") + await screen("fields-space-toggle", (text) => text.includes("[ ] Alpha") && text.includes("NORMAL")) + await keys("Enter") + await screen("fields-enter-toggle", (text) => text.includes("[✓] Alpha") && text.includes("NORMAL")) + await type("iextra") + await screen("fields-i-opens-custom", (text) => text.includes("[✓] extra") && text.includes("INSERT")) + await keys("Escape") + await type("l") + await screen("fields-next", (text) => text.includes("One") && !text.includes("Alpha") && text.includes("NORMAL")) + await type("1") + await screen("fields-review", (text) => text.includes("Targets:") && text.includes("extra") && text.includes("Other:") && text.includes("NORMAL")) + await type("hh") + await screen("fields-draft-restored", (text) => text.includes("[✓] Alpha") && text.includes("[✓] extra") && text.includes("NORMAL")) + await keys("Tab", "Tab", "Enter") + await screen("fields-submitted", (text) => !text.includes("Several questions") && text.includes("original draft") && text.includes("INSERT")) + assert.deepEqual((await request(`${endpoint}/${fields.id}`)).data.state, { + status: "answered", answer: { targets: ["alpha", "extra"], other: "one" }, + }) + + const cancel = (await request(endpoint, { + title: "Cancel question", fields: [{ key: "answer", type: "string", custom: true, + options: [{ value: "one", label: "One" }] }], + })).data + await screen("cancel-open", (text) => text.includes("Cancel question") && text.includes("NORMAL")) + await type("idraft") + await screen("cancel-editing", (text) => /2\..*draft/.test(text) && text.includes("INSERT")) + await keys("Escape") + await screen("cancel-normal", (text) => /2\..*draft/.test(text) && text.includes("NORMAL")) + await keys("Escape") + await screen("cancel-restored", (text) => !text.includes("Cancel question") && text.includes("original draft") && text.includes("INSERT")) + assert.equal((await request(`${endpoint}/${cancel.id}`)).data.state.status, "cancelled") + + // Forms also have text-only fields, where native Escape dismisses the + // whole form. Leaving insert mode must consume that first Escape. + const textual = (await request(endpoint, { + title: "Text-only form", fields: [{ key: "answer", type: "string", default: "seed" }], + })).data + await screen("textual-open", (text) => text.includes("Text-only form") && text.includes("INSERT")) + await type(" more") + await keys("Escape") + await screen("textual-normal", (text) => text.includes("Text-only form") && text.includes("seed more") && text.includes("NORMAL")) + await keys("Enter") + await screen("textual-review", (text) => text.includes("answer: seed more") && text.includes("NORMAL")) + await keys("Enter") + await screen("textual-submitted", (text) => !text.includes("Text-only form") && text.includes("original draft") && text.includes("INSERT")) + assert.deepEqual((await request(`${endpoint}/${textual.id}`)).data.state, { + status: "answered", answer: { answer: "seed more" }, + }) + } +} diff --git a/test/e2e/scenarios/read-reader.ts b/test/e2e/scenarios/read-reader.ts index 62f5866..2c0d998 100644 --- a/test/e2e/scenarios/read-reader.ts +++ b/test/e2e/scenarios/read-reader.ts @@ -1,5 +1,5 @@ -import type { Fixture } from "../fixture" -import { readerContains, selected } from "../screens" +import type { Fixture } from "../support/fixture" +import { reader, readerContains, selected } from "../support/screens" export async function readReader({ terminal }: Fixture) { const { keys, type, screen } = terminal @@ -43,6 +43,14 @@ export async function readReader({ terminal }: Fixture) { await keys("Enter") await screen("position-restored", (text) => readerContains(text, "value100") && text.split("\n")[terminal.cursor().y]?.includes("value100")) + terminal.resize(44, 20) + await type("gg") + await screen("narrow-file-reader", (text) => { + const frame = reader(text) + return !!frame && frame.top >= 0 && frame.bottom < 20 && /41\s+const greeting/.test(frame.content) + }) + terminal.resize(120, 38) + await screen("wide-file-reader", (text) => readerContains(text, "Lines 41–100")) await keys("Escape") await screen("session-again", (text) => selected(text, "Read src/sample.ts") && !text.includes("Lines 41–100")) await type("j") diff --git a/test/e2e/scenarios/session-controls.ts b/test/e2e/scenarios/session-controls.ts new file mode 100644 index 0000000..e459fa3 --- /dev/null +++ b/test/e2e/scenarios/session-controls.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" +import { readerContains, selected } from "../support/screens" + +export function sessionControls(key = "s") { + return async ({ terminal }: Fixture) => { + const { type, keys, screen } = terminal + const press = () => key.startsWith(" text.includes(draft) && text.includes("NORMAL")) + const cursor = terminal.cursor() + let answerRow = -1 + if (full) { + await screen("before-session", (text) => { + answerRow = text.split("\n").findIndex((line) => line.includes("one two")) + return answerRow >= 0 + }) + } + await press() + await screen("browsing", (text) => selected(text, "one two") && text.replace(/\s+/g, " ").includes(`${key} prompt`) && !text.includes("v select")) + if (full) { + await screen("unchanged-layout", (text) => text.split("\n")[answerRow]?.includes("one two") === true) + await terminal.paste("MUTATION") + } + await press() + await screen("returned-prompt", (text) => text.includes("NORMAL") && text.includes(draft) && !text.includes("SESSION") && !text.includes("MUTATION")) + assert.deepEqual(terminal.cursor(), cursor) + if (full) { + await type("u") + await screen("prompt-undo", (text) => text.includes("original draft")) + await terminal.resize(32, 20) + let promptRow = -1 + await screen("narrow-prompt", (text) => { + promptRow = text.split("\n").findIndex((line) => line.includes("original draft")) + return promptRow >= 0 && text.split("\n")[18]?.includes("NORMAL") === true + }) + await press() + await screen("narrow-session", (text) => text.includes("SESSION") && text.split("\n")[promptRow]?.includes("original draft") === true) + await press() + await terminal.resize(120, 38) + await screen("wide-prompt", (text) => text.includes("NORMAL") && text.includes("original draft")) + } + await press() + await keys("Enter") + await screen("reader", (text) => readerContains(text, "one two") && text.includes(`${key} prompt`)) + if (full) { + await type("iaAoOdDcxpru.") + await terminal.paste("MUTATION") + await screen("read-only", (text) => readerContains(text, "one two") && !text.includes("MUTATION") && !text.includes("INSERT")) + } + await press() + await screen("reader-to-prompt", (text) => text.includes("NORMAL") && !text.includes("SESSION") && !text.includes("v select")) + await type("A restored") + await screen("prompt-focus", (text) => text.includes("original draft restored") && text.includes("INSERT")) + } +} + +export function sessionKeyConflict(key: string) { + return async ({ terminal }: Fixture) => { + const { type, keys, screen } = terminal + await type("hello") + await keys("Escape") + await type(`0${key}${key}`) + await screen("mapped-key", (text) => text.includes("ello") && !text.includes("SESSION")) + await type(`i${key}`) + await screen("insert-key", (text) => text.includes(`${key}ello`) && text.includes("INSERT")) + await keys("Escape") + await type(`f${key}`) + await keys("C-p") + await screen("dialog-key", (text) => text.includes("Commands") && text.includes("NORMAL") && !text.includes("SESSION")) + await type(key) + await screen("dialog-key-stays-modal", (text) => text.includes("Commands") && !text.includes("SESSION")) + // The first Escape cancels the pending doubled-key mapping. + await keys("Escape", "Escape") + await screen("dialog-still-native", (text) => !text.includes("Commands") && text.includes("NORMAL")) + } +} + +export async function emptySession({ terminal }: Fixture) { + await terminal.keys("Escape") + await terminal.type("s") + await terminal.screen("empty-session", (text) => text.includes("SESSION · Enter open · yy copy · s prompt")) + await terminal.type("k") + await terminal.keys("Enter") + await terminal.screen("empty-no-reader", (text) => text.includes("SESSION") && !text.includes("v select")) + await terminal.type("siempty restored") + await terminal.screen("empty-exit", (text) => text.includes("empty restored") && text.includes("INSERT") && !text.includes("SESSION")) +} + +export async function sessionLifecycle({ terminal }: Fixture) { + const { type, keys, screen } = terminal + await type("lifecycle draft") + await keys("Escape") + await type("s") + await screen("browsing", (text) => selected(text, "one two")) + // Session switching is a global host command, available while browsing. + await keys("C-x", "l") + await screen("browsing-dialog", (text) => text.includes("Sessions") && text.includes("NORMAL") && !selected(text, "one two")) + await type("iE2E") + await screen("dialog-editing", (text) => text.includes("Sessions") && text.includes("INSERT")) + await keys("Escape", "Escape") + await screen("browsing-restored", (text) => selected(text, "one two") && text.includes("SESSION")) + await keys("Enter") + await screen("reader-opened", (text) => readerContains(text, "one two")) + await keys("C-x", "l") + await screen("replacement-dialog", (text) => text.includes("Sessions") && !text.includes("v select")) + await type("Q") + await screen("disabled-keeps-replacement", (text) => text.includes("Sessions") && !text.includes("SESSION") && !text.includes("NORMAL") && !selected(text, "one two")) + await keys("Escape") + await screen("disabled", (text) => !text.includes("Sessions") && !text.includes("SESSION")) + await keys("C-p") + await screen("disabled-palette", (text) => text.includes("Commands")) + await type("Toggle Vim Mode") + await screen("reenable-selected", (text) => text.includes("Toggle Vim Mode") && !text.includes("New session")) + await keys("Enter") + await screen("reenabled", (text) => !text.includes("Commands") && text.includes("NORMAL")) + await type("s") + await keys("Enter") + await screen("reader-reopened", (text) => readerContains(text, "one two")) + await keys("C-x", "n") + await screen("route-changed", (text) => text.split("\n")[0].includes("New session") && !text.includes("v select") && !text.includes("SESSION")) + await keys("C-x", "1") + await screen("route-restored", (text) => text.includes("lifecycle draft") && !text.includes("v select") && !text.includes("SESSION")) +} diff --git a/test/e2e/scenarios/session-keymaps.ts b/test/e2e/scenarios/session-keymaps.ts index 2f405c5..da307db 100644 --- a/test/e2e/scenarios/session-keymaps.ts +++ b/test/e2e/scenarios/session-keymaps.ts @@ -1,5 +1,5 @@ -import type { Fixture } from "../fixture" -import { readerContains } from "../screens" +import type { Fixture } from "../support/fixture" +import { readerContains } from "../support/screens" export async function sessionKeymaps({ terminal }: Fixture) { const { keys, type, screen } = terminal diff --git a/test/e2e/scenarios/shell-reader.ts b/test/e2e/scenarios/shell-reader.ts index 77ea421..3cffb1c 100644 --- a/test/e2e/scenarios/shell-reader.ts +++ b/test/e2e/scenarios/shell-reader.ts @@ -1,5 +1,5 @@ -import type { Fixture } from "../fixture" -import { reader, readerContains, selected } from "../screens" +import type { Fixture } from "../support/fixture" +import { reader, readerContains, selected } from "../support/screens" export async function shellReader({ terminal }: Fixture) { const { keys, type, screen } = terminal diff --git a/test/e2e/scenarios/tab-switching.ts b/test/e2e/scenarios/tab-switching.ts index b2c66bc..e479982 100644 --- a/test/e2e/scenarios/tab-switching.ts +++ b/test/e2e/scenarios/tab-switching.ts @@ -1,4 +1,4 @@ -import type { Fixture } from "../fixture" +import type { Fixture } from "../support/fixture" export async function tabSwitching({ terminal, sessionTitle }: Fixture) { const { keys, type, screen } = terminal diff --git a/test/e2e/scenarios/transcript-layout.ts b/test/e2e/scenarios/transcript-layout.ts new file mode 100644 index 0000000..e11bf9d --- /dev/null +++ b/test/e2e/scenarios/transcript-layout.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" +import { readerContains, selected } from "../support/screens" +import { yankFlashMatches } from "../support/colors" + +export async function transcriptLayout({ terminal }: Fixture) { + const { keys, type, screen } = terminal + await keys("Escape") + let before: string[] = [] + await screen("natural-bottom", (text) => { + before = text.split("\n") + return text.includes("NORMAL") && text.includes("Latest end") && text.includes("Latest question") + }) + await type("s") + await screen("latest-selected", (text) => selected(text, "Latest reply") && selected(text, "Latest end")) + const position = (lines: string[], value: string) => lines.findIndex((line) => line.includes(value)) + await screen("entry-keeps-layout", (text) => { + const lines = text.split("\n") + return position(lines, "Latest end") === position(before, "Latest end") && position(lines, "Latest question") === position(before, "Latest question") + }) + await type("k") + await screen("visible-selection", (text) => selected(text, "Latest question") && position(text.split("\n"), "Latest end") === position(before, "Latest end")) + for (let cycle = 0; cycle < 2; cycle++) { + await type("gg") + await screen(`oldest-${cycle}`, (text) => selected(text, "Earlier line 0") && !text.includes("Latest reply")) + for (const value of ["First part", "Second part", "Latest question", "Latest reply"]) { + await type("j") + await screen(`next-${cycle}-${value}`, (text) => selected(text, value)) + await keys("Enter") + await screen(`reader-${cycle}-${value}`, (text) => readerContains(text, value)) + await keys("Escape") + await screen(`closed-${cycle}-${value}`, (text) => !text.includes("v select") && selected(text, value)) + } + await screen(`natural-bottom-${cycle}`, (text) => position(text.split("\n"), "Latest end") === position(before, "Latest end")) + } + await type("gg") + await screen("oldest-again", (text) => selected(text, "Earlier line 0")) + await type("ss") + await screen("reentry-selects-latest", (text) => selected(text, "Latest end") && position(text.split("\n"), "Latest end") === position(before, "Latest end")) + await type("3k") + await screen("first-part-only", (text) => selected(text, "First part") && !selected(text, "Second part") && !selected(text, "Second end")) + await type("yy") + await screen("first-part-copied", (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), "First part\nFirst end") + await type("j") + await screen("second-part-only", (text) => selected(text, "Second part") && selected(text, "Second end") && !selected(text, "First part")) + await type("yy") + await screen("second-part-copied", (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), "Second part\nSecond end") +} + +export async function transcriptPartial({ terminal, probe }: Fixture) { + const { keys, type, screen } = terminal + await keys("Escape") + let rows: string[] = [] + await screen("partial-bottom", (text) => { + rows = text.split("\n") + return text.includes("NORMAL") && text.includes("Reply line 79") && !text.includes("Reply line 0\n") + }) + await type("s") + await screen("partial-selected", (text) => selected(text, "Reply line 79")) + await screen("partial-layout", (text) => { + const marked = text.split("\n") + for (let y = 0; y < rows.length; y++) { + if (rows[y].includes("Reply line")) { + if (marked[y].replaceAll("▎", " ") !== rows[y]) return false + } else if (marked[y].includes("▎")) return false + } + return true + }) + let normal: string[] = [] + const colors = (await probe()).colors + await screen("partial-before-flash", (_text, ansi) => { normal = ansi.split("\n"); return true }) + await type("yy") + await screen("partial-copied", (text, ansi) => { + if (!text.includes("Copied")) return false + return yankFlashMatches(normal.join("\n"), ansi, colors) + }) + const lines: string[] = [] + for (let i = 0; i < 80; i++) lines.push(`Reply line ${i} 中 👍🏽 é`) + assert.equal(terminal.clipboard(), lines.join("\n")) + await screen("partial-flash-expired", (_text, ansi) => { + for (let y = 0; y < rows.length; y++) { + if (rows[y].includes("Reply line") && ansi.split("\n")[y] !== normal[y]) return false + } + return true + }) + await keys("Enter") + await screen("partial-whole-reader", (text) => readerContains(text, "Reply line 0")) + await type("G") + await screen("partial-reader-end", (text) => readerContains(text, "Reply line 79")) +} + +export async function transcriptParts({ terminal }: Fixture) { + const { keys, type, screen } = terminal + await keys("Escape") + // No frame wait between entry and open: the latest reasoning row must be + // available immediately, including when it has no following text response. + await keys("s", "Enter") + await screen("latest-reasoning", (text) => readerContains(text, "Still thinking")) + await keys("Escape") + await type("yy") + await screen("latest-reasoning-copied", (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), "Still thinking") + for (const [label, value, copied] of [ + ["answer", "Finished", "Finished"], + ["tool", "pwd", "$ pwd\n/work"], + ["reasoning", "Check something", "Check something"], + ["start", "Start", "Start"], + ]) { + await type("k") + await screen(`${label}-selected`, (text) => selected(text, value)) + await type("yy") + await screen(`${label}-copied`, (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), copied) + await keys("Enter") + await screen(`${label}-reader`, (text) => readerContains(text, value)) + await keys("Escape") + await screen(`${label}-closed`, (text) => !text.includes("v select")) + } + await type("3j") + await screen("counted-parts", (text) => selected(text, "Finished")) +} + +export async function sessionCopy({ terminal, probe }: Fixture) { + const { keys, type, screen } = terminal + await type("draft") + await keys("Escape") + await type("sgg") + let unselected = "" + await screen("markdown-selected", (text, ansi) => { + const row = text.split("\n").findIndex((line) => line.includes("quoted 中 👩‍💻")) + unselected = ansi.split("\n")[row] + return selected(text, "Heading") && !!unselected + }) + await type("yy") + await screen("markdown-copied", (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), "# Heading\n\n```ts\nconst 中 = '👍🏽'\n```") + await type("s$p") + await screen("markdown-pasted", (text) => text.includes("draft# Heading") && text.includes("```ts") && /const 中 = '👍🏽\s*'/.test(text)) + await type("i") + await keys("C-c", "Escape") + await type("s") + await screen("quote-selected", (text) => selected(text, "quoted 中 👩‍💻")) + let normal = "" + let beforeFlash = "" + const colors = (await probe()).colors + await screen("quote-before-flash", (text, ansi) => { + beforeFlash = ansi + const row = text.split("\n").findIndex((line) => line.includes("quoted 中 👩‍💻")) + normal = ansi.split("\n")[row] + return !!normal + }) + await type("yy") + await screen("quote-flashed", (text, ansi) => { + return text.includes("Copied") && yankFlashMatches(beforeFlash, ansi, colors) + }) + assert.equal(terminal.clipboard(), "quoted 中 👩‍💻\nsecond line") + await screen("quote-flash-expired", (text, ansi) => { + const row = text.split("\n").findIndex((line) => line.includes("quoted 中 👩‍💻")) + return ansi.split("\n")[row] === normal + }) + for (const [key, label] of [["k", "navigation"], ["s", "exit"]]) { + await type(`yy${key}`) + await screen(`quote-flash-cleared-by-${label}`, (text, ansi) => { + const row = text.split("\n").findIndex((line) => line.includes("quoted 中 👩‍💻")) + return !selected(text, "quoted 中 👩‍💻") && ansi.split("\n")[row] === unselected + }) + await type(key === "k" ? "j" : "s") + await screen(`quote-reselected-after-${label}`, (text) => selected(text, "quoted 中 👩‍💻")) + } + await keys("Enter") + await screen("quote-reader", (text) => readerContains(text, "quoted 中 👩‍💻")) + await type("Vjy") + await screen("quote-lines-copied", (text) => text.includes("Copied")) + assert.equal(terminal.clipboard(), "quoted 中 👩‍💻\nsecond line\n") + await type("sp") + await screen("quote-pasted", (text) => text.includes("NORMAL") && !text.includes("SESSION") && text.split("\n")[terminal.cursor().y]?.slice(terminal.cursor().x).startsWith("quoted 中 👩‍💻") === true) + const cursor = terminal.cursor() + await type("u") + await screen("quote-undone", (text) => text.includes("NORMAL") && text.split("\n")[terminal.cursor().y]?.trim() === "┃") + await keys("C-r") + await screen("quote-redone", (text) => text.split("\n")[terminal.cursor().y]?.includes("quoted 中 👩‍💻") === true) + assert.equal(terminal.cursor().x, cursor.x) +} diff --git a/test/e2e/scenarios/transcript-live.ts b/test/e2e/scenarios/transcript-live.ts new file mode 100644 index 0000000..af376bf --- /dev/null +++ b/test/e2e/scenarios/transcript-live.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict" +import type { Fixture } from "../support/fixture" +import { reader, readerContains, selected } from "../support/screens" + +export async function transcriptLive({ terminal, request, sessionID, stream }: Fixture) { + assert(stream) + const { keys, type, screen, cursor, clipboard } = terminal + await type("live draft") + await keys("Escape") + await type("s") + await screen("original-selected", (text) => selected(text, "one two")) + + await request(`/api/session/${sessionID}/prompt`, { text: "A message arriving during browsing" }) + await screen("message-arrived", (text) => text.includes("hello") && text.includes("A message arriving during browsing") + && selected(text, "one two"), 15_000) + await type("G") + await screen("new-latest-selected", (text) => selected(text, "hello")) + await type("k") + await screen("new-user-selected", (text) => selected(text, "A message arriving during browsing")) + await type("j") + await screen("stream-selected", (text) => selected(text, "hello")) + await keys("Enter") + let frame: ReturnType + await screen("stream-reader", (text) => { + frame = reader(text) + return frame?.content.includes("hello") ?? false + }) + assert(frame) + const bounds = frame + await type("v2l") + await screen("stream-selection", (text) => text.includes("VISUAL") && text.includes("y copy · Esc cancel")) + const position = cursor() + + stream.write(" world", true) + await screen("stream-updated-behind-reader", (text) => { + const snapshot = text.split("\n").slice(bounds.top, bounds.bottom).join("\n") + return text.includes("hello world") && text.includes("VISUAL") && snapshot.includes("hello") && !snapshot.includes("world") + }) + assert.deepEqual(cursor(), position) + await type("y") + await screen("snapshot-selection-copied", (text) => { + const snapshot = text.split("\n").slice(bounds.top, bounds.bottom).join("\n") + return snapshot.includes("Copied") && snapshot.includes("hello") && !snapshot.includes("world") + }) + assert.equal(clipboard(), "hel") + + await keys("Escape") + await screen("browsing-refreshed", (text) => !text.includes("v select") && selected(text, "hello world")) + await type("yy") + await screen("updated-message-copied", (text) => text.includes("Copied")) + assert.equal(clipboard(), "hello world") + await keys("Enter") + await screen("updated-reader", (text) => readerContains(text, "hello world")) + await type("sA restored") + await screen("live-prompt-restored", (text) => text.includes("INSERT") && text.includes("live draft restored")) +} diff --git a/test/e2e/scenarios/transcript.ts b/test/e2e/scenarios/transcript.ts index 5fe4df6..0f0c6b7 100644 --- a/test/e2e/scenarios/transcript.ts +++ b/test/e2e/scenarios/transcript.ts @@ -1,5 +1,5 @@ -import type { Fixture } from "../fixture" -import { readerContains, selected } from "../screens" +import type { Fixture } from "../support/fixture" +import { readerContains, selected } from "../support/screens" async function openSelected({ terminal }: Fixture, label: string, value: string) { await terminal.screen(`${label}-selected`, (text) => selected(text, value)) @@ -100,6 +100,11 @@ export async function transcriptRunning(fixture: Fixture) { export async function transcriptHistory(fixture: Fixture) { const { type, screen } = fixture.terminal await enterSession(fixture) + let latestRow = -1 + await screen("history-natural-bottom", (text) => { + latestRow = text.split("\n").findIndex((line) => line.includes("History entry 239")) + return latestRow >= 0 + }) await openSelected(fixture, "history-latest", "History entry 239") await type("130k") await screen("history-loaded-older", (text) => selected(text, "History entry 109"), 15_000) @@ -107,7 +112,10 @@ export async function transcriptHistory(fixture: Fixture) { 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("kj") + await screen("history-oldest-boundary", (text) => selected(text, "History entry 001"), 15_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") + await screen("history-no-navigation-padding", (text) => text.split("\n")[latestRow]?.includes("History entry 239") === true) } diff --git a/test/e2e/support/clipboard-boundary.ts b/test/e2e/support/clipboard-boundary.ts new file mode 100644 index 0000000..4dc76bb --- /dev/null +++ b/test/e2e/support/clipboard-boundary.ts @@ -0,0 +1,21 @@ +import type { HostClipboardService } from "@opentui/core" + +// A controlled external I/O response, used only by clipboard cancellation E2E. +// The production clipboard adapter and OpenCode's event handling are unchanged. +export function desktopClipboard(): HostClipboardService { + const url = process.env.VIM_E2E_CLIPBOARD! + return { + maxWriteBytes: 1_000_000, + async read() { + const response = await fetch(url) + const bytes = new Uint8Array(await response.arrayBuffer()) + // Acknowledge after the returned promise and its consumers settle, + // so assertions cannot race the adapter's late-paste continuation. + setImmediate(() => { void fetch(url + "/delivered", { method: "POST" }) }) + return { status: "read", representation: { mimeType: "text/plain", bytes } } + }, + async writeText() { return { status: "written" } }, + async clear() { return { status: "cleared" } }, + async dispose() {}, + } +} diff --git a/test/e2e/support/clipboard-server.ts b/test/e2e/support/clipboard-server.ts new file mode 100644 index 0000000..a54e6c2 --- /dev/null +++ b/test/e2e/support/clipboard-server.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" + +export function createDelayedClipboard() { + const requested = Promise.withResolvers() + const response = Promise.withResolvers() + const delivered = Promise.withResolvers() + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch(request) { + if (new URL(request.url).pathname === "/delivered") { + delivered.resolve() + return new Response("ok") + } + requested.resolve() + return response.promise + }, + }) + return { + url: server.url.origin, + async waitForRead() { + await Promise.race([requested.promise, Bun.sleep(5_000).then(() => assert.fail("No desktop clipboard read"))]) + }, + async release() { + response.resolve(new Response("STALE CLIPBOARD")) + await Promise.race([delivered.promise, Bun.sleep(5_000).then(() => assert.fail("Clipboard response was not delivered"))]) + }, + stop() { server.stop(true) }, + } +} diff --git a/test/e2e/support/colors.ts b/test/e2e/support/colors.ts new file mode 100644 index 0000000..ebcfda4 --- /dev/null +++ b/test/e2e/support/colors.ts @@ -0,0 +1,68 @@ +type Cell = { text: string; foreground: string; background: string } + +// Decode captured SGR colors, preserving graphemes rather than splitting emoji. +function cells(ansi: string) { + const lines: Cell[][] = [] + let foreground = "default" + let background = "default" + const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + for (const line of ansi.split("\n")) { + const row: Cell[] = [] + for (const part of line.split(/(\x1b\[[\d;]*m)/)) { + if (part.startsWith("\x1b[")) { + const codes = part.slice(2, -1).split(";").map(Number) + for (let i = 0; i < codes.length; i++) { + const code = codes[i] + if (code === 0 || code === 39) foreground = "default" + if (code === 0 || code === 49) background = "default" + if (code !== 38 && code !== 48) continue + let color: string + if (codes[i + 1] === 2) { + color = "#" + codes.slice(i + 2, i + 5).map((n) => n.toString(16).padStart(2, "0")).join("") + i += 4 + } else { + color = `indexed:${codes[i + 2]}` + i += 2 + } + if (code === 38) foreground = color + else background = color + } + } else { + for (const { segment } of graphemes.segment(part)) row.push({ text: segment, foreground, background }) + } + } + lines.push(row) + } + return lines +} + +function trim(row: Cell[]) { + let end = row.length + while (row[end - 1]?.text === " ") end-- + return row.slice(0, end) +} + +export function yankFlashMatches(before: string, after: string, colors: { foreground: string; background: string }) { + const original = cells(before) + const flashed = cells(after) + let painted = false + for (let y = 0; y < original.length; y++) { + const row = original[y] + const selected = row.some((cell) => cell.text === "▎") + const actual = flashed[y] + if (selected) { + if (row.map((c) => c.text).join("").trimEnd() !== actual.map((c) => c.text).join("").trimEnd()) return false + for (const cell of actual) { + if (!cell.text.trim() || cell.text === "▎") continue + if (cell.foreground !== colors.foreground || cell.background !== colors.background) return false + painted = true + } + } else { + if (actual.some((cell) => cell.foreground === colors.foreground && cell.background === colors.background)) return false + // Tabs animate independently; the session status becomes "Copied". + if (y === 0 || row.map((c) => c.text).join("").includes("SESSION")) continue + if (JSON.stringify(trim(row)) !== JSON.stringify(trim(actual))) return false + } + } + return painted +} diff --git a/test/e2e/fixture.ts b/test/e2e/support/fixture.ts similarity index 71% rename from test/e2e/fixture.ts rename to test/e2e/support/fixture.ts index bf97df0..a7db522 100644 --- a/test/e2e/fixture.ts +++ b/test/e2e/support/fixture.ts @@ -4,13 +4,16 @@ 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 type { VimOptions } from "../../src/modules/vim/config" +import type { VimOptions } from "../../../src/modules/vim/config" import { createTerminal, type Terminal } from "./terminal" +import { createStreamingModel } from "./model" +import { createDelayedClipboard } from "./clipboard-server" -export type Fixture = { terminal: Terminal; sessionTitle: string } +export type Fixture = FixtureAPI & { terminal: Terminal; sessionTitle: string; sessionID: string; stream?: ReturnType; + clipboard?: ReturnType; probe: (action?: string, body?: unknown) => Promise; setVimLoaded: (loaded: boolean) => Promise } export type Message = ReturnType[number] -export type FixtureAPI = { request: (endpoint: string, body?: unknown) => Promise; workspace: string } -export type FixtureSetup = { messages?: Message[] | ((api: FixtureAPI) => Promise); cli?: Record; vim?: VimOptions } +export type FixtureAPI = { request: (endpoint: string, body?: unknown, method?: string) => Promise; workspace: string } +export type FixtureSetup = { messages?: Message[] | ((api: FixtureAPI) => Promise); cli?: Record; vim?: VimOptions; stream?: string; probe?: boolean; delayedClipboard?: boolean } type Options = FixtureSetup & { opencode: { binary: string; version: string } @@ -25,10 +28,16 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = const password = crypto.randomUUID() const sessionTitle = "E2E existing session" const terminal = createTerminal(path.join(directory, "tmux.sock"), artifacts) + const stream = options.stream === undefined ? undefined : createStreamingModel(options.stream) + const clipboard = options.delayedClipboard ? createDelayedClipboard() : undefined + const configFile = path.join(directory, "tui/config/cli.json") + const probeFile = path.join(artifacts, "probe-url") let server: Bun.Subprocess | undefined let url = "" function stop() { + stream?.stop() + clipboard?.stop() terminal.stop() if (server) { try { @@ -51,8 +60,10 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = await mkdir(path.join(directory, home, "config"), { recursive: true }) } assert.equal(execFileSync(opencode.binary, ["--version"], { env: environment("server"), encoding: "utf8" }).trim(), `opencode v${opencode.version}`) - await Bun.write(path.join(directory, "tui/config/cli.json"), JSON.stringify({ - plugins: [{ package: plugin, options: { vim: options.vim ?? {} } }], + const plugins: unknown[] = [{ package: plugin, options: { vim: options.vim ?? {} } }] + if (options.probe) plugins.push(path.join(import.meta.dir, "probe")) + await Bun.write(configFile, JSON.stringify({ + plugins, tabs: { mode: "on" }, attention: { notifications: false, sound: false }, ...options.cli, @@ -102,9 +113,10 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = for (const [key, value] of Object.entries(environment("tui"))) command.push(`${key}=${value}`) command.push(opencode.binary, "--server", url, "--session", session.id, workspace) terminal.start(command, workspace) - await terminal.screen("startup", (text) => text.includes(sessionTitle) && text.includes("INSERT"), 60_000) + const initialMode = options.vim?.defaultMode === "normal" ? "NORMAL" : "INSERT" + await terminal.screen("startup", (text) => text.includes(sessionTitle) && text.includes(initialMode), 60_000) - await run({ terminal, sessionTitle }) + await run({ terminal, sessionTitle, sessionID: session.id, request, workspace, stream, clipboard, probe, setVimLoaded }) assert.equal((await request(`/api/session/${session.id}`)).data.id, session.id) } finally { process.off("SIGINT", interrupt) @@ -130,7 +142,7 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = COLORTERM: "truecolor", OPENCODE_TEST_HOME: directory, OPENCODE_CONFIG_DIR: path.join(directory, "config"), - OPENCODE_CONFIG_CONTENT: "{}", + OPENCODE_CONFIG_CONTENT: JSON.stringify(stream?.config ?? {}), OPENCODE_DISABLE_PROJECT_CONFIG: "true", OPENCODE_DISABLE_FILEWATCHER: "true", OPENCODE_DISABLE_MODELS_FETCH: "true", @@ -139,17 +151,35 @@ export async function runWithFixture(options: Options, run: (fixture: Fixture) = XDG_DATA_HOME: path.join(directory, "data"), XDG_CACHE_HOME: path.join(directory, "cache"), XDG_STATE_HOME: path.join(directory, "state"), + VIM_E2E_PROBE: probeFile, + ...(clipboard ? { VIM_E2E_CLIPBOARD: clipboard.url } : {}), } } - async function request(endpoint: string, body?: unknown) { + async function probe(action = "state", body?: unknown) { + const url = await Bun.file(probeFile).text() + const response = await fetch(`${url}/${action}`, { method: body === undefined ? "GET" : "POST", + body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json" } }) + assert(response.ok, await response.clone().text()) + return response.json() + } + + async function setVimLoaded(loaded: boolean) { + const config = await Bun.file(configFile).json() + config.plugins = config.plugins.filter((entry: unknown) => entry !== "-opencode-vim") + if (!loaded) config.plugins.push("-opencode-vim") + await Bun.write(configFile, JSON.stringify(config)) + } + + async function request(endpoint: string, body?: unknown, method = body === undefined ? "GET" : "POST") { const response = await fetch(url + endpoint, { - method: body === undefined ? "GET" : "POST", + method, headers: { authorization: `Basic ${btoa(`opencode:${password}`)}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(3_000), }) if (!response.ok) throw new Error(`${endpoint}: ${response.status} ${await response.text()}`) + if (response.status === 204) return return response.json() } } diff --git a/test/e2e/support/model.ts b/test/e2e/support/model.ts new file mode 100644 index 0000000..e806818 --- /dev/null +++ b/test/e2e/support/model.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict" + +// Only the model response is controlled; OpenCode owns message creation, +// streaming events, transcript rendering and reader focus. +export function createStreamingModel(initial: string) { + let controller: ReadableStreamDefaultController | undefined + const encoder = new TextEncoder() + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + assert.equal(new URL(request.url).pathname, "/v1/chat/completions") + const body = new ReadableStream({ + start(stream) { + controller = stream + write(initial) + }, + }) + return new Response(body, { headers: { "content-type": "text/event-stream" } }) + }, + }) + + function write(text: string, finish = false) { + assert(controller, "OpenCode has not requested the model response") + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + id: "completion-fixture", object: "chat.completion.chunk", created: 1, model: "stream", + choices: [{ index: 0, delta: { content: text }, finish_reason: finish ? "stop" : null }], + })}\n\n`)) + if (finish) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")) + controller.close() + } + } + + return { + config: { + model: "fixture/stream", + providers: { fixture: { + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: `${server.url.origin}/v1`, apiKey: "fixture" }, + models: { stream: { limit: { context: 128_000, output: 4096 } } }, + } }, + }, + write, + stop() { server.stop(true) }, + } +} diff --git a/test/e2e/opencode.ts b/test/e2e/support/opencode.ts similarity index 96% rename from test/e2e/opencode.ts rename to test/e2e/support/opencode.ts index 85d4782..fc92324 100644 --- a/test/e2e/opencode.ts +++ b/test/e2e/support/opencode.ts @@ -13,7 +13,7 @@ export async function installOpenCode() { console.log(`OpenCode E2E version: ${version} (latest stable)`) const architecture = process.arch === "x64" ? "x64-baseline" : "arm64" const target = `${process.platform}-${architecture}` - const cache = path.resolve(import.meta.dir, "../../node_modules/.cache/opencode") + const cache = path.resolve(import.meta.dir, "../../../node_modules/.cache/opencode") const directory = path.join(cache, `${version}-${target}`) const binary = path.join(directory, "opencode") if (await Bun.file(binary).exists()) return { binary, version } diff --git a/test/e2e/support/plugin.ts b/test/e2e/support/plugin.ts new file mode 100644 index 0000000..6a72b18 --- /dev/null +++ b/test/e2e/support/plugin.ts @@ -0,0 +1,56 @@ +import { execFileSync } from "node:child_process" +import { cp, mkdir, symlink } from "node:fs/promises" +import path from "node:path" +import { createRequire } from "node:module" + +export async function packPlugin(root: string, directory: string, artifacts: string) { + const manifest = await Bun.file(path.join(root, "package.json")).json() + const packed = execFileSync("npm", ["pack", "--quiet", "--pack-destination", directory], { + cwd: root, + encoding: "utf8", + timeout: 120_000, + }) + await Bun.write(path.join(artifacts, "pack.log"), packed) + const plugin = path.join(directory, "node_modules", manifest.name) + await mkdir(plugin, { recursive: true }) + execFileSync("tar", ["-xzf", path.join(directory, `${manifest.name}-${manifest.version}.tgz`), "--strip-components=1", "-C", plugin]) + await dependencies(root, plugin) + // Resolve the published export, rather than bypassing it via dist/tui.js. + const exported = createRequire(path.join(plugin, "package.json")).resolve(`${manifest.name}/tui`) + // Local CLI plugins are directories with a conventional tui entrypoint. + const entry = path.join(directory, "packed-entry") + await mkdir(entry) + await Bun.write(path.join(entry, "tui.ts"), `export { default } from ${JSON.stringify(exported)}\n`) + return entry +} + +export async function copySourcePlugin(root: string, directory: string, delayedClipboard = false) { + const plugin = path.join(directory, delayedClipboard ? "clipboard-plugin" : "source-plugin") + await mkdir(plugin) + for (const file of ["tui.tsx", "src"]) { + await cp(path.join(root, file), path.join(plugin, file), { recursive: true }) + } + const manifest = await Bun.file(path.join(root, "package.json")).json() + manifest.exports = { "./tui": "./tui.tsx" } + await Bun.write(path.join(plugin, "package.json"), JSON.stringify(manifest)) + await dependencies(root, plugin) + if (delayedClipboard) { + // Substitute only the external desktop clipboard boundary. OpenCode, + // the plugin's clipboard logic, and all lifecycle handlers stay real. + const file = path.join(plugin, "src/clipboard.tsx") + const source = await Bun.file(file).text() + const boundary = path.join(import.meta.dir, "clipboard-boundary.ts") + await Bun.write(file, `import { desktopClipboard } from ${JSON.stringify(boundary)}\n` + source.replace("host: createHostClipboard()", "host: desktopClipboard()")) + } + return plugin +} + +async function dependencies(root: string, plugin: string) { + const modules = path.join(plugin, "node_modules") + await mkdir(modules) + // A private Solid copy must not split the plugin from OpenCode's reactive runtime. + await cp(path.join(root, "node_modules/solid-js"), path.join(modules, "solid-js"), { recursive: true }) + for (const name of ["@vimee", "diff"]) { + await symlink(path.join(root, "node_modules", name), path.join(modules, name)) + } +} diff --git a/test/e2e/support/probe/tui.tsx b/test/e2e/support/probe/tui.tsx new file mode 100644 index 0000000..a9a1d64 --- /dev/null +++ b/test/e2e/support/probe/tui.tsx @@ -0,0 +1,49 @@ +import { Plugin } from "@opencode/plugin/tui" +import { InputRenderable, rgbToHex } from "@opentui/core" + +// Runs inside real OpenCode. This is a driver/inspector, not a host substitute: +// all focus, modes, dialogs, routes, commands and plugin disposal belong to it. +export default Plugin.define({ + id: "zz-vim-e2e-probe", + setup(context) { + let remembered: typeof context.renderer.currentFocusedEditor + let result: string | undefined + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(request) { + const action = new URL(request.url).pathname + const body = request.method === "POST" ? await request.json() : undefined + if (action === "/remember") remembered = context.renderer.currentFocusedEditor + else if (action === "/dispatch") context.keymap.dispatch(body.command) + else if (action === "/route") context.ui.router.navigate(body) + else if (action === "/dismiss") context.ui.dialog.clear() + else if (action === "/select") { + result = undefined + const options = [] + for (let i = 1; i <= 40; i++) options.push({ title: `Choice ${String(i).padStart(2, "0")}`, value: String(i) }) + void context.ui.dialog.select({ title: "E2E choices", options }).then((value) => { result = value }) + } else if (action === "/native") { + context.ui.dialog.show(() => Native extension input) + } + // Observe after Solid/focus callbacks and clipboard continuations. + await new Promise((resolve) => setImmediate(resolve)) + const editor = context.renderer.currentFocusedEditor + function inspect(input: typeof editor) { + if (!input) return null + if (input.isDestroyed) return { destroyed: true } + return { destroyed: false, text: input instanceof InputRenderable ? input.value : input.plainText, + cursor: input.cursorOffset, selected: input.hasSelection(), id: input.id } + } + return Response.json({ editor: inspect(editor), remembered: inspect(remembered), result, + mode: context.keymap.mode.current(), route: context.ui.router.current(), + listeners: context.renderer.keyInput.listenerCount("keypress"), + colors: { foreground: rgbToHex(context.theme.background.base), background: rgbToHex(context.theme.text.feedback.info.base) }, + }) + }, + }) + void Bun.write(process.env.VIM_E2E_PROBE!, server.url.origin) + return () => server.stop(true) + }, +}) diff --git a/test/e2e/screens.ts b/test/e2e/support/screens.ts similarity index 100% rename from test/e2e/screens.ts rename to test/e2e/support/screens.ts diff --git a/test/e2e/terminal.ts b/test/e2e/support/terminal.ts similarity index 59% rename from test/e2e/terminal.ts rename to test/e2e/support/terminal.ts index afe086d..907547b 100644 --- a/test/e2e/terminal.ts +++ b/test/e2e/support/terminal.ts @@ -1,4 +1,6 @@ import { execFileSync, spawnSync } from "node:child_process" +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" import { appendFile } from "node:fs/promises" import path from "node:path" import { stripVTControlCharacters } from "node:util" @@ -7,6 +9,7 @@ export type Terminal = ReturnType export function createTerminal(socket: string, artifacts: string) { let pid: number | undefined + const output = path.join(artifacts, "terminal.raw") function tmux(...args: string[]) { return execFileSync("tmux", ["-S", socket, ...args], { encoding: "utf8", timeout: 5_000 }) @@ -16,6 +19,8 @@ export function createTerminal(socket: string, artifacts: string) { pid = Number(tmux("-u", "-f", "/dev/null", "new-session", "-d", "-s", "e2e", "-x", "120", "-y", "38", "-c", workspace, "-P", "-F", "#{pane_pid}", ...command).trim()) tmux("set-option", "-t", "e2e", "remain-on-exit", "on") + // Capture raw output, including clipboard escapes that capture-pane drops. + tmux("pipe-pane", "-t", "e2e", `cat > '${output.replaceAll("'", "'\\''")}'`) } function stop() { @@ -31,6 +36,13 @@ export function createTerminal(socket: string, artifacts: string) { async function keys(...keys: string[]) { await appendFile(path.join(artifacts, "keys.log"), `${Date.now()} ${keys.join(" ")}\n`) + // CSI-u makes Escape unambiguous when another key immediately follows. + // A bare ESC byte plus "s", for example, is decoded as Alt+s. + if (keys[0] !== "-l") { + for (let i = 0; i < keys.length; i++) { + if (keys[i] === "Escape") keys[i] = "\x1b[27u" + } + } tmux("send-keys", "-t", "e2e", ...keys) } @@ -47,12 +59,38 @@ export function createTerminal(socket: string, artifacts: string) { 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, ansi: string) => boolean, timeout = 5_000) { + async function paste(text: string) { + await keys("-l", `\x1b[200~${text}\x1b[201~`) + } + + function resize(width: number, height: number) { + tmux("resize-window", "-t", "e2e", "-x", String(width), "-y", String(height)) + } + + function clipboard() { + let payload: string | undefined + // OpenTUI doubles escape bytes inside tmux passthrough sequences. + const contents = readFileSync(output, "utf8").replaceAll("\x1b\x1b", "\x1b") + for (const match of contents.matchAll(/\x1b\]52;[^;]*;([A-Za-z0-9+/=]*)(?:\x07|\x1b\\)/g)) { + payload = match[1] + } + if (payload === undefined) return + return Buffer.from(payload, "base64").toString("utf8") + } + + function cursorStyle() { + let style: number | undefined + for (const match of readFileSync(output, "utf8").matchAll(/\x1b\[(\d+) q/g)) style = Number(match[1]) + assert(style !== undefined, "No terminal cursor style captured") + return style + } + + async function screen(label: string, matches: (text: string, ansi: string) => boolean | Promise, timeout = 5_000) { const deadline = Date.now() + timeout while (true) { const ansi = tmux("capture-pane", "-p", "-e", "-t", "e2e") const text = stripVTControlCharacters(ansi) - const matched = matches(text, ansi) + const matched = await matches(text, ansi) if (matched || Date.now() >= deadline) { await Bun.write(path.join(artifacts, `${label}.txt`), text) await Bun.write(path.join(artifacts, `${label}.ansi`), ansi) @@ -68,5 +106,5 @@ export function createTerminal(socket: string, artifacts: string) { } } - return { start, stop, keys, type, screen, cursor, click } + return { start, stop, keys, type, screen, cursor, cursorStyle, click, paste, resize, clipboard } } diff --git a/test/helpers/fixture.ts b/test/helpers/fixture.ts index 44876f0..21132a2 100644 --- a/test/helpers/fixture.ts +++ b/test/helpers/fixture.ts @@ -7,7 +7,7 @@ import { keyNotation } from "../../src/modules/vim/keys" import { createVimState } from "../../src/modules/vim/state" import { createVimeeAdapter } from "../../src/modules/vim/vimee" -export async function createFixture(text = "", options: VimOptions = {}, width = 80) { +export async function createFixture(text = "", options: VimOptions = {}, width = 80, adapterOptions: Parameters[3] = {}) { const screen = await createTestRenderer({ width, height: 12, kittyKeyboard: true }) const input = new TextareaRenderable(screen.renderer, { id: "prompt", width, height: 10, initialValue: text, wrapMode: "word", @@ -17,12 +17,12 @@ export async function createFixture(text = "", options: VimOptions = {}, width = await screen.renderOnce() const config = createVimConfig({ defaultMode: "normal", ...options }) const state = createVimState(config.defaultMode) - const adapter = createVimeeAdapter(state, config, () => {}) + const adapter = createVimeeAdapter(state, config, () => {}, adapterOptions) const commands: string[] = [] let submissions = 0 const prompt = { get current() { return { input: input.plainText, mode: "normal", parts: [] } }, - set(value: { input: string }) { editInput(input, value.input) }, + set(value: { input: string }) { editInput(input, value.input, screen.renderer.widthMethod) }, submit() { submissions++ }, blur() { input.blur() }, } @@ -35,9 +35,11 @@ export async function createFixture(text = "", options: VimOptions = {}, width = prompt: () => input.focused ? prompt : undefined, requestRender: () => screen.renderer.requestRender(), } + let handled: boolean | Promise = false const onKey = (event: KeyEvent) => { const key = keyNotation(event) - if (key && adapter.handle(event, key, context)) { + handled = key ? adapter.handle(event, key, context) : false + if (handled) { event.preventDefault() event.stopPropagation() } @@ -46,9 +48,13 @@ export async function createFixture(text = "", options: VimOptions = {}, width = screen.renderer.keyInput.prependListener("keypress", onKey) return { ...screen, input, state, adapter, commands, + settled: () => handled, get submissions() { return submissions }, async keys(keys: string) { - for (const key of keys) screen.mockInput.pressKey(key, { shift: key !== key.toLowerCase() }) + for (const key of keys) { + screen.mockInput.pressKey(key, { shift: key !== key.toLowerCase() }) + if (typeof handled !== "boolean") await handled + } await screen.renderOnce() }, dispose() { diff --git a/test/helpers/reader.tsx b/test/helpers/reader.tsx new file mode 100644 index 0000000..6c40338 --- /dev/null +++ b/test/helpers/reader.tsx @@ -0,0 +1,63 @@ +import { afterEach, mock } from "bun:test" +import type { Context } from "@opencode/plugin/tui/context" +import type { TextareaRenderable } from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { render } from "@opentui/solid" +import { createSignal, Show } from "solid-js" +import { createVimConfig, type VimOptions } from "../../src/modules/vim/config" +import { Reader } from "../../src/readers" +import type { TranscriptItem, TranscriptSource } from "../../src/transcript-items" +import { testTheme } from "./theme" + +export function textItem(text: string): TranscriptItem { + return { id: "message", author: "Assistant", text } +} + +export function toolItem(name: string, text: string, state: Extract["state"]): TranscriptItem { + return { id: "tool", author: name, text, source: { type: "tool", id: "call", name, time: { created: 1 }, state } } +} + +export function useReaderFixture() { + let dispose: (() => void) | undefined + afterEach(() => { dispose?.(); dispose = undefined }) + + return async function mount(message: TranscriptItem, options: VimOptions = {}, size = { width: 80, height: 16 }) { + const screen = await createTestRenderer({ ...size, kittyKeyboard: true }) + dispose = () => screen.renderer.destroy() + const [mounted, setMounted] = createSignal(true) + const [item, setItem] = createSignal(message) + const [pending, setPending] = createSignal>([]) + const back = mock(() => {}) + const close = mock(() => {}) + const copied: string[] = [] + let offset = 0 + const setDialog = mock(() => {}) + const context = { + renderer: screen.renderer, theme: testTheme(), + keymap: { mode: { current: () => "modal" }, pending }, + ui: { format: { path: (value: string) => value }, dialog: { set: setDialog } }, + } as unknown as Context + // Render our component directly. Modal placement, dismissal and focus + // restoration are exercised by real OpenCode in test/e2e/. + await render(() => + copied.push(text)} notice={() => ""} + remember={(value) => { offset = value }} back={back} close={close} /> + , screen.renderer) + async function renderOnce() { + await screen.renderOnce() + await screen.renderOnce() + } + await renderOnce() + return { + ...screen, renderOnce, copied, back, close, setDialog, setPending, setItem, + reader: () => screen.renderer.root.findDescendantById("vim-session-message") as TextareaRenderable, + unmount: () => setMounted(false), + async reopen() { setMounted(false); setMounted(true); await renderOnce() }, + async keys(keys: string) { + for (const key of keys) screen.mockInput.pressKey(key, { shift: key !== key.toLowerCase() }) + await renderOnce() + }, + } + } +} diff --git a/test/helpers/theme.ts b/test/helpers/theme.ts new file mode 100644 index 0000000..2d347b1 --- /dev/null +++ b/test/helpers/theme.ts @@ -0,0 +1,15 @@ +import { RGBA } from "@opentui/core" + +export function testTheme(success = RGBA.fromHex("#00ff00")) { + const tokens = { + background: { base: RGBA.fromHex("#000000"), raised: { high: RGBA.fromHex("#222222") }, action: { primary: { focused: RGBA.fromHex("#006699") } } }, + text: { base: RGBA.fromHex("#ffffff"), muted: RGBA.fromHex("#888888"), action: { primary: { focused: RGBA.fromHex("#ffffff") } }, feedback: { + success: { base: success }, warning: { base: RGBA.fromHex("#ffff00") }, info: { base: RGBA.fromHex("#00ffff") }, + } }, + syntax: { function: RGBA.fromHex("#00ffff"), string: RGBA.fromHex("#00ff00") }, + diff: { text: { added: success, removed: RGBA.fromHex("#ff0000"), hunkHeader: RGBA.fromHex("#00ffff") }, + background: { added: RGBA.fromHex("#003300"), removed: RGBA.fromHex("#330000") }, + lineNumber: { background: { added: RGBA.fromHex("#004400"), removed: RGBA.fromHex("#440000") } } }, + } + return { ...tokens, surface: () => tokens } +} diff --git a/test/integration/clipboard.test.ts b/test/integration/clipboard.test.ts new file mode 100644 index 0000000..7531792 --- /dev/null +++ b/test/integration/clipboard.test.ts @@ -0,0 +1,230 @@ +import { afterAll, afterEach, expect, spyOn, test } from "bun:test" +import * as OpenTUI from "@opentui/core" +import { createFixture } from "../helpers/fixture" +import { createClipboardFixture } from "../helpers/clipboard-fixture" +import type { VimOptions } from "../../src/modules/vim/config" +import type { VimClipboard } from "../../src/clipboard" + +// Exercise our clipboard boundary and editor adapter directly. Only the desktop +// clipboard is controlled; there is no OpenCode plugin context or host UI here. +let clipboard = createClipboardFixture() +const factory = spyOn(OpenTUI, "createHostClipboard").mockImplementation(() => clipboard.host) +afterAll(() => factory.mockRestore()) +let fixture: Awaited> | undefined +let vimClipboard: VimClipboard | undefined +afterEach(async () => { + await vimClipboard?.dispose() + fixture?.dispose() + fixture = undefined + vimClipboard = undefined +}) + +async function mount(options: VimOptions = {}) { + clipboard = createClipboardFixture() + const { createVimClipboard } = await import("../../src/clipboard") + const f = await createFixture("hello", options, 80, { + onYank: (text) => { void vimClipboard!.write(text) }, + readClipboard: () => vimClipboard!.read(), + }) + fixture = f + const copied: string[] = [] + Object.defineProperty(f.renderer, "capabilities", { value: { remote: false, osc52_support: "supported" }, configurable: true }) + f.renderer.copyToClipboardOSC52 = (text) => { copied.push(text); return true } + vimClipboard = createVimClipboard(f.renderer) + return { ...f, clipboard, copied, async keys(keys: string) { + await f.keys(keys) + await new Promise((resolve) => setImmediate(resolve)) + } } +} + +test.each(["p", "P", "2p", "2P"])("%s pastes the current desktop clipboard with Vim counts and undo", async (key) => { + const f = await mount() + f.input.setText("") + f.clipboard.text = " 中 👩‍💻\r\nsecond\r\n" + await f.keys(key) + const text = " 中 👩‍💻\nsecond" + expect(f.input.plainText).toBe(key.startsWith("2") ? text + "\n" + text : text) + await f.keys("u") + expect(f.input.plainText).toBe("") + f.mockInput.pressKey("r", { ctrl: true }) + expect(f.input.plainText).toContain(text) +}) + +test("p reads external changes instead of reusing an earlier yank", async () => { + const f = await mount() + await f.keys("yiw") + expect(f.clipboard.text).toBe("hello") + f.clipboard.text = "outside" + await f.keys("$p") + expect(f.input.plainText).toBe("hellooutside") + f.clipboard.text = "new" + await f.keys("p") + expect(f.input.plainText).toBe("hellooutsidenew") + f.clipboard.text = "" + await f.keys("p") + expect(f.input.plainText).toBe("hellooutsidenew") +}) + +test("linewise clipboard text fills an empty prompt with no leading blank line and supports undo/redo", async () => { + const f = await mount() + const text = "quoted 中 👩‍💻\nsecond line" + f.input.setText("") + f.clipboard.text = text + "\n" + await f.keys("p") + expect(f.input.plainText).toBe(text) + expect(f.input.cursorOffset).toBe(0) + await f.keys("u") + expect(f.input.plainText).toBe("") + f.mockInput.pressKey("r", { ctrl: true }) + expect(f.input.plainText).toBe(text) +}) + +test.each(["yiw", "dd", "ciw", "x", "vlld"])("%s writes the system clipboard", async (key) => { + const f = await mount() + await f.keys(key) + const expected = key === "dd" ? "hello\n" : key === "x" ? "h" : key === "vlld" ? "hel" : "hello" + expect(f.clipboard.text).toBe(expected) + expect(f.copied).toEqual([expected]) +}) + +test("rapid ddp waits for the cut to reach the clipboard", async () => { + const f = await mount() + let finish!: () => void + const writing = new Promise((resolve) => { finish = resolve }) + f.clipboard.text = "unrelated" + f.clipboard.host.writeText.mockImplementationOnce(async (text) => { + await writing + f.clipboard.text = text + return { status: "written" } + }) + await f.keys("dd") + f.mockInput.pressKey("p") + const put = f.settled() + expect(f.input.plainText).toBe("") + expect(f.clipboard.host.read).not.toHaveBeenCalled() + finish() + expect(await put).toBe(true) + expect(f.input.plainText).toBe("hello") + await f.keys("u") + expect(f.input.plainText).toBe("") +}) + +test("rapid cuts reach the clipboard in order before a put reads it", async () => { + const f = await mount() + let finish!: () => void + const writing = new Promise((resolve) => { finish = resolve }) + f.clipboard.host.writeText.mockImplementationOnce(async (text) => { + await writing + f.clipboard.text = text + return { status: "written" } + }) + await f.keys("xx") + f.mockInput.pressKey("p") + const put = f.settled() + expect(f.clipboard.host.writeText).toHaveBeenCalledTimes(1) + finish() + expect(await put).toBe(true) + expect(f.input.plainText).toBe("lelo") + expect(f.clipboard.text).toBe("e") + expect(f.copied).toEqual(["h", "e"]) +}) + +test("mapped puts and dot repeat refresh the clipboard", async () => { + const f = await mount({ keymaps: { normal: { Q: "2p" } } }) + f.input.setText("") + f.clipboard.text = "a" + await f.keys("Q") + expect(f.input.plainText).toBe("aa") + f.clipboard.text = "b" + await f.keys(".") + expect(f.input.plainText).toBe("aabb") + await f.keys("u") + expect(f.input.plainText).toBe("aa") +}) + +test("a mapping can yank and put without pasting stale clipboard text", async () => { + const f = await mount({ keymaps: { normal: { Q: "yiw$p" } } }) + f.clipboard.text = "external" + await f.keys("Q") + expect(f.input.plainText).toBe("hellohello") + expect(f.clipboard.text).toBe("hello") +}) + +test("named yanks and puts stay separate from the clipboard", async () => { + const f = await mount() + f.clipboard.text = "external" + await f.keys('"ayiw$"ap') + expect(f.input.plainText).toBe("hellohello") + expect(f.clipboard.text).toBe("external") + expect(f.copied).toEqual([]) + expect(f.clipboard.host.read).not.toHaveBeenCalled() + await f.keys("p") + expect(f.input.plainText).toBe("hellohelloexternal") +}) + +test("failed clipboard reads and writes retain the shared fallback", async () => { + const f = await mount() + f.clipboard.host.read.mockRejectedValue(new Error("clipboard unavailable")) + f.clipboard.host.writeText.mockRejectedValue(new Error("clipboard unavailable")) + f.renderer.copyToClipboardOSC52 = () => false + expect(await vimClipboard!.write("dialog")).toBe(false) + await f.keys("$p") + expect(f.input.plainText).toBe("hellodialog") + await f.keys("0yiw") + expect(await vimClipboard!.read()).toBe("hellodialog") +}) + +test("remote puts use shared yanks without reading the server's clipboard", async () => { + const f = await mount() + Object.defineProperty(f.renderer, "capabilities", { value: { remote: true, osc52_support: "supported" } }) + f.clipboard.text = "server clipboard" + await f.keys("yiw$p") + expect(f.input.plainText).toBe("hellohello") + expect(f.clipboard.host.read).not.toHaveBeenCalled() + expect(f.clipboard.host.writeText).not.toHaveBeenCalled() + expect(f.copied).toEqual(["hello"]) +}) + +test("an unavailable clipboard falls back, but an empty clipboard does not paste old text", async () => { + const f = await mount() + await f.keys("yiw") + f.clipboard.host.read.mockResolvedValueOnce({ status: "unsupported" }) + await f.keys("$p") + expect(f.input.plainText).toBe("hellohello") + f.clipboard.host.read.mockResolvedValueOnce({ status: "empty" }) + await f.keys("p") + expect(f.input.plainText).toBe("hellohello") +}) + +test.each(["edit", "cursor", "suspend", "cleanup"])("an adapter's pending put is cancelled on %s", async (change) => { + const f = await mount() + let finish!: () => void + const reading = new Promise((resolve) => { finish = resolve }) + const read = f.clipboard.host.read.getMockImplementation()! + f.clipboard.text = "late" + f.clipboard.host.read.mockImplementationOnce(async () => { await reading; return read() }) + f.mockInput.pressKey("p") + const put = f.settled() + if (change === "edit") f.input.setText("edited") + if (change === "cursor") f.input.cursorOffset = 2 + if (change === "suspend") f.adapter.suspend() + if (change === "cleanup") f.adapter.cleanup() + finish() + expect(await put).toBe(false) + expect(f.input.plainText).toBe(change === "edit" ? "edited" : "hello") +}) + +test("a newer yank wins over a read already in flight", async () => { + const f = await mount() + let finish!: () => void + const reading = new Promise((resolve) => { finish = resolve }) + f.clipboard.host.read.mockImplementationOnce(async () => { + await reading + return { status: "read", representation: { mimeType: "text/plain", bytes: new TextEncoder().encode("stale") } } + }) + const read = vimClipboard!.read() + await new Promise((resolve) => setImmediate(resolve)) + await vimClipboard!.write("newer") + finish() + expect(await read).toBe("newer") +}) diff --git a/test/integration/plugin.test.tsx b/test/integration/plugin.test.tsx deleted file mode 100644 index 6072011..0000000 --- a/test/integration/plugin.test.tsx +++ /dev/null @@ -1,1944 +0,0 @@ -import { afterAll, afterEach, expect, spyOn, test } from "bun:test" -import * as OpenTUI from "@opentui/core" -import { InputRenderable, RGBA, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" -import { createTestRenderer } from "@opentui/core/testing" -import { render, useTerminalDimensions, type JSX } from "@opentui/solid" -import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" -import { Plugin } from "@opencode/plugin/tui" -import { createEffect, createRoot, createSignal, For, Show } from "solid-js" -import type { VimOptions } from "../../src/modules/vim/config" -import type { ShellSource } from "../../src/readers/shell/data" -import { createClipboardFixture } from "../helpers/clipboard-fixture" - -// Install before the runtime loader snapshots OpenTUI's shared exports. -let activeClipboard = createClipboardFixture() -const clipboardFactory = spyOn(OpenTUI, "createHostClipboard").mockImplementation(() => activeClipboard.host) -afterAll(() => clipboardFactory.mockRestore()) - -const entrypoint = process.env.OPENCODE_VIM_TEST_ENTRYPOINT -if (entrypoint) ensureRuntimePluginSupport({ additional: { "@opencode/plugin/tui": { Plugin } } }) -const { default: plugin }: typeof import("../../tui") = await import(entrypoint ?? "../../tui") - -let dispose: (() => void) | undefined -afterEach(() => { dispose?.(); dispose = undefined }) - -type TestMessage = { id: string; type: "user" | "assistant"; time: { created: number }; text?: string; content?: Array<{ type: string; text: string; id?: string; name?: string; state?: Extract["state"] }> } -function message(id: number, text: string, type: "user" | "assistant" = "assistant"): TestMessage { - return { id: String(id), type, time: { created: id }, ...(type === "user" ? { text } : { content: [{ type: "text", text }] }) } -} - -async function mount(options: VimOptions = {}, session?: { messages: TestMessage[]; older?: TestMessage[]; window?: { start: number; end?: number }; group?: string[] }) { - const screen = await createTestRenderer({ width: session ? 80 : 40, height: session ? 16 : 8, kittyKeyboard: true }) - const input = new TextareaRenderable(screen.renderer, { id: "prompt", height: 3, width: 40, initialValue: "hello" }) - screen.renderer.root.add(input) - if (session) { input.position = "absolute"; input.top = 11 } - input.focus() // Plugin loading must also work after the prompt already has focus. - const [enabled, setEnabled] = createSignal(true) - const [success, setSuccess] = createSignal(RGBA.fromHex("#00ff00")) - const [pending, setPending] = createSignal>([]) - const [mounted, setMounted] = createSignal(true) - const [hostMode, setHostMode] = createSignal("base") - const [commands, setCommands] = createSignal([{ id: "prompt.submit" }]) - const [route, setRoute] = createSignal<{ type: "home" } | { type: "session"; sessionID: string }>(session ? { type: "session", sessionID: "session-1" } : { type: "home" }) - const [messages, setMessages] = createSignal(session?.messages ?? []) - const [dialog, setDialog] = createSignal<{ render: () => JSX.Element; onClose?: () => void }>() - const [dialogOptions, setDialogOptions] = createSignal<{ size?: string; centered?: boolean }>({}) - let modalReturnMode = "base" - let modalReturnFocus = screen.renderer.currentFocusedRenderable - const [slack, setSlack] = createSignal(0) - const [windowStart, setWindowStart] = createSignal(session?.window?.start ?? 0) - const [windowEnd, setWindowEnd] = createSignal(session?.window?.end) - const [groupExpanded, setGroupExpanded] = createSignal(false) - let transcript: ScrollBoxRenderable | undefined - let targetID: string | undefined - let navigationID: string | undefined - let pendingScroll: { position: number | "bottom" } | { height?: number; delta: number } | undefined - let older = session?.older ?? [] - const copied: string[] = [] - // Never touch the real desktop clipboard during tests. - const clipboard = createClipboardFixture() - activeClipboard = clipboard - Object.defineProperty(screen.renderer, "capabilities", { value: { remote: false, osc52_support: "supported" }, configurable: true }) - screen.renderer.copyToClipboardOSC52 = (text) => { copied.push(text); return true } - const dispatched: string[] = [] - const [footer, setFooter] = createSignal<() => JSX.Element>(() => null) - let toggle: () => void = () => {} - const context = { - renderer: screen.renderer, - options: { defaultMode: "normal", pendingDisplayDelay: 0, ...options }, - get theme() { - const tokens = { background: { base: RGBA.fromHex("#000000"), raised: { high: RGBA.fromHex("#222222") }, action: { primary: { focused: RGBA.fromHex("#006699") } } }, text: { base: RGBA.fromHex("#ffffff"), muted: RGBA.fromHex("#888888"), action: { primary: { focused: RGBA.fromHex("#ffffff") } }, feedback: { - success: { base: success() }, warning: { base: RGBA.fromHex("#ffff00") }, info: { base: RGBA.fromHex("#00ffff") }, - } }, syntax: { function: RGBA.fromHex("#00ffff"), string: RGBA.fromHex("#00ff00") }, - diff: { text: { added: success(), removed: RGBA.fromHex("#ff0000"), hunkHeader: RGBA.fromHex("#00ffff") }, - background: { added: RGBA.fromHex("#003300"), removed: RGBA.fromHex("#330000") }, - lineNumber: { background: { added: RGBA.fromHex("#004400"), removed: RGBA.fromHex("#440000") } } } } - return { ...tokens, surface: () => tokens } - }, - storage: { store: () => [{ get enabled() { return enabled() } }, (fn: (draft: { enabled: boolean }) => void) => { - const draft = { enabled: enabled() }; fn(draft); setEnabled(draft.enabled) - }] }, - keymap: { - pending, - mode: { current: hostMode, push(mode: string) { const previous = hostMode(); setHostMode(mode); return () => setHostMode(previous) } }, - commands, dispatch(command: string) { - dispatched.push(command) - if (command === "session.first" || command === "session.last" || command.startsWith("session.line.")) { - targetID = navigationID = undefined - setSlack(0) - const delta = command === "session.line.up" ? -1 : 1 - const before = transcript?.scrollHeight ?? 0 - if (command === "session.first") { - setMessages([...older, ...messages()]); older = [] - setWindowStart(0); setWindowEnd(undefined) - pendingScroll = { position: 0 } - } else if (command === "session.last") { - setWindowEnd(undefined) - pendingScroll = { position: "bottom" } - } else if (delta < 0 && transcript && transcript.scrollTop <= transcript.viewport.height) { - if (windowStart()) setWindowStart(0) - else if (older.length) { setMessages([...older, ...messages()]); older = [] } - // OpenCode compensates for prepended rows after layout. - pendingScroll = { height: before, delta } - } else if (delta > 0 && (windowEnd() ?? messages().length) < messages().length) { - setWindowEnd(undefined) - pendingScroll = { delta } - } else transcript?.scrollBy(delta) - return - } - const index = messages().findIndex((item) => item.id === navigationID) - if ((command === "session.first" || (command === "session.message.previous" && index === 0)) && older.length) { - setMessages([...older, ...messages()]); older = [] - } - const navigable = messages().filter((item) => item.type === "user" ? item.text?.trim() - : item.content?.some((part) => part.type === "text" && part.text.trim())) - const current = navigable.findIndex((item) => item.id === navigationID) - if (command === "session.message.previous") navigationID = navigable[Math.max(0, current - 1)]?.id - else if (command === "session.message.next") navigationID = navigable[Math.min(navigable.length - 1, current + 1)]?.id - else if (command === "session.first") navigationID = navigable[0]?.id - else if (command === "session.last") navigationID = navigable.at(-1)?.id - else if (command === "session.messages_last_user") navigationID = navigable.findLast((item) => item.type === "user")?.id - else return - targetID = navigationID - }, - layer(fn: () => { commands?: Array<{ run: () => void }> }) { - const command = fn().commands?.[0] - if (command) toggle = command.run - }, - }, - data: { session: { message: { list: messages }, permission: { list: () => [] } } }, - ui: { - format: { path: (value: string) => value }, - router: { current: route }, toast: { show() {} }, - dialog: { - show(view: () => JSX.Element, onClose?: () => void) { - if (!dialog()) { - modalReturnMode = hostMode() - modalReturnFocus = screen.renderer.currentFocusedRenderable - modalReturnFocus?.blur() - } - dialog()?.onClose?.() - setHostMode("modal") - setDialog({ render: view, onClose }) - }, - set: setDialogOptions, - clear() { - if (dialog()) { - dialog()?.onClose?.() - setDialog(undefined) - setHostMode(modalReturnMode) - } - setTimeout(() => { - if (!dialog() && modalReturnFocus && !modalReturnFocus.isDestroyed) modalReturnFocus.focus() - }, 1) - }, - }, - slot(slot: { append?: string; render: (props: any) => JSX.Element }) { - if (slot.append === "app") void render(() => <> - - { transcript = value }} id="native-transcript" height={10} width={76} left={2}> - {(item) => ( - - - - setGroupExpanded(!groupExpanded())}> - →Explored: 2 reads - - - session?.group?.includes(entry.id))}>{(entry) => ( - {entry.content![0].text} - )} - - - - - }> - {(part, index) => ( - other.type === part.type).length}`}`} - marginTop={1} flexShrink={0}> - {part.text} - - )} - - )} - {(height) => } - - - {mounted() ? slot.render({ mode: "normal" }) : null}{footer()()} - {(item) => {item.render()}} - , screen.renderer) - else setFooter(() => () => slot.render({ mode: "normal" })) - return () => setFooter(() => () => null) - }, - }, - } - function Modal(props: { children: JSX.Element }) { - const dimensions = useTerminalDimensions() - return context.ui.dialog.clear()}> - event.stopPropagation()}> - {props.children} - - - } - screen.renderer.keyInput.on("keypress", (event) => { - if (event.defaultPrevented || !dialog()) return - if (event.name === "escape") context.ui.dialog.clear() - // Native Ctrl+C clears a focused editor. - if (event.ctrl && event.name === "c") screen.renderer.currentFocusedEditor?.setText("") - }) - plugin.setup(context as unknown as Parameters[0]) - await screen.renderOnce() - transcript?.scrollTo(transcript.scrollHeight) - async function renderOnce() { - await screen.renderOnce() - if (pendingScroll && transcript) { - const action = pendingScroll - pendingScroll = undefined - if ("delta" in action) transcript.scrollBy((action.height === undefined ? 0 : transcript.scrollHeight - action.height) + action.delta) - else transcript.scrollTo(action.position === "bottom" ? transcript.scrollHeight : action.position) - } - if (targetID && transcript) { - const row = transcript.getRenderable(targetID) - if (row) { - const top = transcript.scrollTop + row.y - transcript.viewport.y - setSlack(Math.max(0, top + transcript.viewport.height - (transcript.scrollHeight - slack()))) - await screen.renderOnce() - transcript.scrollTo(top) - } - targetID = undefined - } - await screen.renderOnce() - await screen.renderOnce() - } - await renderOnce() - dispose = () => screen.renderer.destroy() - let dialogReturnMode = "base" - let dialogReturnInput = screen.renderer.currentFocusedEditor - function openDialog(value = "", kind: "select" | "prompt" | "other" = "select") { - dialogReturnMode = hostMode() - dialogReturnInput = screen.renderer.currentFocusedEditor - input.blur() - setHostMode("modal") - setCommands(kind === "select" - ? ["dialog.select.next", "dialog.select.prev", "dialog.select.submit"].map((id) => ({ id })) - : kind === "prompt" ? [{ id: "dialog.prompt.submit" }] : []) - const editor = new InputRenderable(screen.renderer, { id: "filter", width: 30, value }) - screen.renderer.root.add(editor) - editor.focus() - return editor - } - function closeDialog(editor: InputRenderable) { - editor.destroy() - setHostMode(dialogReturnMode) - setCommands([{ id: "prompt.submit" }]) - dialogReturnInput?.focus() - } - return { ...screen, renderOnce, input, toggle, setSuccess, setPending, openDialog, closeDialog, dispatched, hostMode, setRoute, setCommands, - setMessages, copied, clipboard, dialog, dialogOptions, clearModal: () => context.ui.dialog.clear(), - replaceModal: () => context.ui.dialog.show(() => Another dialog), transcript: () => transcript!, - async scrollTranscript(position: number | "bottom") { - navigationID = undefined - setSlack(0) - await screen.renderOnce() - transcript!.scrollTo(position === "bottom" ? transcript!.scrollHeight : position) - await renderOnce() - }, - async keys(keys: string) { - for (const key of keys) screen.mockInput.pressKey(key, { shift: key !== key.toLowerCase() }) - await renderOnce() - }, - reader: () => screen.renderer.root.findDescendantById("vim-session-message") as TextareaRenderable, - unmount: () => setMounted(false) } -} - -test("focus callbacks do not subscribe host tab effects to Vim or keymap state", async () => { - const f = await mount({ defaultMode: "insert" }) - const other = new TextareaRenderable(f.renderer, { id: "other-tab", height: 3, width: 40 }) - f.renderer.root.add(other) - const [tab, setTab] = createSignal(other) - let focusRuns = 0 - const stop = createRoot((dispose) => { - createEffect(() => { - focusRuns++ - tab().focus() - }) - return dispose - }) - try { - expect(f.renderer.currentFocusedEditor).toBe(other) - expect(focusRuns).toBe(1) - f.setCommands([{ id: "prompt.submit" }, { id: "prompt.history.previous" }]) - expect(focusRuns).toBe(1) - f.mockInput.pressEscape() - await f.renderOnce() - expect(other.cursorStyle.style).toBe("block") - expect(focusRuns).toBe(1) - setTab(f.input) - expect(f.renderer.currentFocusedEditor).toBe(f.input) - expect(focusRuns).toBe(2) - await f.keys("i") - expect(f.input.cursorStyle.style).toBe("line") - expect(focusRuns).toBe(2) - } finally { - stop() - } -}) - -test("plugin intercepts keys before an already-focused textarea", async () => { - const f = await mount() - f.mockInput.pressKey("x") - expect(f.input.plainText).toBe("ello") - expect(f.input.cursorStyle.style).toBe("block") -}) - -test.each(["p", "P", "2p", "2P"])("%s pastes the current desktop clipboard with Vim counts and undo", async (key) => { - const f = await mount() - f.input.setText("") - f.clipboard.text = " 中 👩‍💻\r\nsecond\r\n" - await f.keys(key) - const text = " 中 👩‍💻\nsecond" - expect(f.input.plainText).toBe(key.startsWith("2") ? text + "\n" + text : text) - await f.keys("u") - expect(f.input.plainText).toBe("") - f.mockInput.pressKey("r", { ctrl: true }) - expect(f.input.plainText).toContain(text) -}) - -test("p reads external changes instead of reusing an earlier yank", async () => { - const f = await mount() - await f.keys("yiw") - expect(f.clipboard.text).toBe("hello") - f.clipboard.text = "outside" - await f.keys("$p") - expect(f.input.plainText).toBe("hellooutside") - f.clipboard.text = "new" - await f.keys("p") - expect(f.input.plainText).toBe("hellooutsidenew") - f.clipboard.text = "" - await f.keys("p") - expect(f.input.plainText).toBe("hellooutsidenew") -}) - -test.each(["yiw", "dd", "ciw", "x", "vlld"])("%s writes the system clipboard", async (key) => { - const f = await mount() - await f.keys(key) - const expected = key === "dd" ? "hello\n" : key === "x" ? "h" : key === "vlld" ? "hel" : "hello" - expect(f.clipboard.text).toBe(expected) - expect(f.copied).toEqual([expected]) -}) - -test("rapid ddp waits for the cut to reach the clipboard", async () => { - const f = await mount() - let finish!: () => void - const writing = new Promise((resolve) => { finish = resolve }) - f.clipboard.text = "unrelated" - f.clipboard.host.writeText.mockImplementationOnce(async (text) => { - await writing - f.clipboard.text = text - return { status: "written" } - }) - await f.keys("ddp") - expect(f.input.plainText).toBe("") - expect(f.clipboard.host.read).not.toHaveBeenCalled() - finish() - await f.waitFor(() => f.input.plainText === "hello") - await f.keys("u") - expect(f.input.plainText).toBe("") -}) - -test("rapid cuts reach the clipboard in order before a put reads it", async () => { - const f = await mount() - let finish!: () => void - const writing = new Promise((resolve) => { finish = resolve }) - f.clipboard.host.writeText.mockImplementationOnce(async (text) => { - await writing - f.clipboard.text = text - return { status: "written" } - }) - await f.keys("xxp") - expect(f.clipboard.host.writeText).toHaveBeenCalledTimes(1) - finish() - await f.waitFor(() => f.input.plainText === "lelo") - expect(f.clipboard.text).toBe("e") - expect(f.copied).toEqual(["h", "e"]) -}) - -test("a pending paste keeps subsequent puts, native typing and bracketed paste in order", async () => { - const f = await mount() - f.input.setText("") - f.clipboard.text = "A" - let finish!: () => void - const reading = new Promise((resolve) => { finish = resolve }) - const read = f.clipboard.host.read.getMockImplementation()! - f.clipboard.host.read.mockImplementationOnce(async () => { await reading; return read() }) - await f.keys("ppiB") - await f.mockInput.pasteBracketedText("C") - f.mockInput.pressEscape() - expect(f.input.plainText).toBe("") - finish() - await f.waitFor(() => f.input.plainText === "ABCA") - expect(f.input.cursorStyle.style).toBe("block") -}) - -test("mapped puts and dot repeat refresh the clipboard", async () => { - const f = await mount({ keymaps: { normal: { Q: "2p" } } }) - f.input.setText("") - f.clipboard.text = "a" - await f.keys("Q") - expect(f.input.plainText).toBe("aa") - f.clipboard.text = "b" - await f.keys(".") - expect(f.input.plainText).toBe("aabb") - await f.keys("u") - expect(f.input.plainText).toBe("aa") -}) - -test("a mapping can yank and put without pasting stale clipboard text", async () => { - const f = await mount({ keymaps: { normal: { Q: "yiw$p" } } }) - f.clipboard.text = "external" - await f.keys("Q") - expect(f.input.plainText).toBe("hellohello") - expect(f.clipboard.text).toBe("hello") -}) - -test("named yanks and puts stay separate from the clipboard", async () => { - const f = await mount() - f.clipboard.text = "external" - await f.keys('"ayiw$"ap') - expect(f.input.plainText).toBe("hellohello") - expect(f.clipboard.text).toBe("external") - expect(f.copied).toEqual([]) - expect(f.clipboard.host.read).not.toHaveBeenCalled() - await f.keys("p") - expect(f.input.plainText).toBe("hellohelloexternal") -}) - -test("prompt and dialog yanks share a fallback when clipboard access fails", async () => { - const f = await mount() - f.clipboard.host.read.mockRejectedValue(new Error("clipboard unavailable")) - f.clipboard.host.writeText.mockRejectedValue(new Error("clipboard unavailable")) - f.renderer.copyToClipboardOSC52 = () => false - const editor = f.openDialog("dialog") - await f.keys("0yiw") - f.closeDialog(editor) - await f.keys("$p") - expect(f.input.plainText).toBe("hellodialog") - await f.keys("0yiw") - const next = f.openDialog("") - await f.keys("p") - expect(next.value).toBe("hellodialog") -}) - -test("remote puts use shared yanks without reading the server's clipboard", async () => { - const f = await mount() - Object.defineProperty(f.renderer, "capabilities", { value: { remote: true, osc52_support: "supported" } }) - f.clipboard.text = "server clipboard" - await f.keys("yiw$p") - expect(f.input.plainText).toBe("hellohello") - expect(f.clipboard.host.read).not.toHaveBeenCalled() - expect(f.clipboard.host.writeText).not.toHaveBeenCalled() - expect(f.copied).toEqual(["hello"]) -}) - -test("an unavailable clipboard falls back, but an empty clipboard does not paste old text", async () => { - const f = await mount() - await f.keys("yiw") - f.clipboard.host.read.mockResolvedValueOnce({ status: "unsupported" }) - await f.keys("$p") - expect(f.input.plainText).toBe("hellohello") - f.clipboard.host.read.mockResolvedValueOnce({ status: "empty" }) - await f.keys("p") - expect(f.input.plainText).toBe("hellohello") -}) - -test.each(["focus", "edit", "cursor", "route", "toggle", "unmount"])("a pending put is cancelled on %s", async (change) => { - const f = await mount() - let finish!: () => void - const reading = new Promise((resolve) => { finish = resolve }) - const read = f.clipboard.host.read.getMockImplementation()! - f.clipboard.text = "late" - f.clipboard.host.read.mockImplementationOnce(async () => { await reading; return read() }) - await f.keys("p") - const editor = change === "focus" ? f.openDialog("query") : undefined - if (change === "edit") f.input.setText("edited") - if (change === "cursor") f.input.cursorOffset = 2 - if (change === "route") f.setRoute({ type: "session", sessionID: "session-2" }) - if (change === "toggle") f.toggle() - if (change === "unmount") f.unmount() - await f.renderOnce() - finish() - await f.renderOnce() - expect(f.input.plainText).toBe(change === "edit" ? "edited" : "hello") - if (editor) expect(editor.value).toBe("query") -}) - -test("mode status, colors and toggle update without polling", async () => { - const f = await mount() - expect(f.captureCharFrame()).toContain("NORMAL") - f.mockInput.pressKey("i") - await f.renderOnce() - expect(f.captureCharFrame()).toContain("INSERT") - expect(f.input.cursorStyle.style).toBe("line") - f.setSuccess(RGBA.fromHex("#ff00ff")) - await f.renderOnce() - const frame = f.captureSpans() - const status = frame.lines.flatMap((line) => line.spans).find((span) => span.text.includes("INSERT")) - expect(status?.fg).toEqual(RGBA.fromHex("#ff00ff")) - f.mockInput.pressEscape() - await f.renderOnce() - const normalFrame = f.captureCharFrame() - f.mockInput.pressKey("d") - await f.renderOnce() - expect(f.captureCharFrame()).toBe(normalFrame) - f.toggle() - await f.renderOnce() - expect(f.captureCharFrame()).not.toContain("NORMAL") - f.mockInput.pressKey("X", { shift: true }) - expect(f.input.plainText).toContain("X") -}) - -test("insert-mode startup updates the status on Escape and kj", async () => { - const f = await mount({ defaultMode: "insert", keymaps: { insert: { kj: "normal" } } }) - expect(f.captureCharFrame()).toContain("INSERT") - f.mockInput.pressEscape() - await f.renderOnce() - expect(f.captureCharFrame()).toContain("NORMAL") - expect(f.captureCharFrame()).not.toContain("INSERT") - expect(f.input.cursorStyle.style).toBe("block") - f.mockInput.pressKey("i") - await f.renderOnce() - expect(f.captureCharFrame()).toContain("INSERT") - const insertFrame = f.captureCharFrame() - f.mockInput.pressKey("k") - await f.renderOnce() - expect(f.captureCharFrame()).toBe(insertFrame) - f.mockInput.pressKey("j") - await f.renderOnce() - expect(f.captureCharFrame()).toContain("NORMAL") - expect(f.captureCharFrame()).not.toContain("INSERT") - f.mockInput.pressKey("v") - await f.renderOnce() - expect(f.captureCharFrame()).toContain("VISUAL") -}) - -test("focus changes restore the editor's original cursor", async () => { - const f = await mount() - f.mockInput.pressKey("i") - expect(f.input.cursorStyle.style).toBe("line") - f.input.blur() - expect(f.input.cursorStyle.style).toBe("block") - f.input.focus() - expect(f.input.cursorStyle.style).toBe("line") -}) - -test("host shortcuts and pending leader sequences reach the host", async () => { - const f = await mount() - const received: string[] = [] - f.renderer.keyInput.on("keypress", (event) => { - received.push(event.name) - event.preventDefault() - }) - f.mockInput.pressKey("x", { ctrl: true }) - f.setPending([{ key: "ctrl+x" }]) - f.mockInput.pressKey("p") - expect(received).toEqual(["x", "p"]) - expect(f.input.plainText).toBe("hello") -}) - -test.each([ - ["normal", ""], ["insert", "i"], ["visual", "vl"], ["visual-line", "V"], ["session", "s"], -])("Shift+Tab reaches the host without changing %s mode or selection", async (_mode, keys) => { - const f = await mount({}, { messages: [message(1, "Answer")] }) - await f.keys(keys) - const frame = f.captureCharFrame() - const offset = f.input.cursorOffset - const selection = f.input.getSelectedText() - const hostMode = f.hostMode() - let received = 0 - f.renderer.keyInput.on("keypress", (event) => { - if (event.name !== "tab" || !event.shift) return - received++ - event.preventDefault() - }) - f.mockInput.pressKey("TAB", { shift: true }) - await f.renderOnce() - expect(received).toBe(1) - expect(f.captureCharFrame()).toBe(frame) - expect(f.hostMode()).toBe(hostMode) - expect(f.input.cursorOffset).toBe(offset) - expect(f.input.getSelectedText()).toBe(selection) - expect(f.input.plainText).toBe("hello") -}) - -test("a plain Tab mapping does not capture Shift+Tab", async () => { - const f = await mount({ keymaps: { normal: { "": "x" } } }) - const received: boolean[] = [] - f.renderer.keyInput.on("keypress", (event) => { - received.push(event.shift) - event.preventDefault() - }) - f.mockInput.pressKey("TAB", { shift: true }) - expect(received).toEqual([true]) - expect(f.input.plainText).toBe("hello") - f.mockInput.pressKey("TAB") - expect(received).toEqual([true]) - expect(f.input.plainText).toBe("ello") -}) - -test("unmount removes handlers and restores native editing", async () => { - const f = await mount() - const listeners = f.renderer.keyInput.listenerCount("keypress") - f.mockInput.pressKey("v") - expect(f.input.hasSelection()).toBe(true) - f.unmount() - await f.renderOnce() - expect(f.renderer.keyInput.listenerCount("keypress")).toBe(listeners - 2) - expect(f.input.hasSelection()).toBe(false) - expect(f.captureCharFrame()).not.toContain("VISUAL") - f.mockInput.pressKey("x") - expect(f.input.plainText).toBe("xhello") -}) - -test.each(["normal", "insert"] as const)("dialogs inherit the prompt's current %s mode rather than the startup mode", async (mode) => { - const f = await mount({ defaultMode: mode === "normal" ? "insert" : "normal" }) - if (mode === "normal") f.mockInput.pressEscape() - else f.mockInput.pressKey("i") - const cursor = mode === "normal" ? "block" : "line" - - for (const kind of ["select", "prompt"] as const) { - const editor = f.openDialog("query", kind) - expect(editor.cursorStyle.style).toBe(cursor) - await f.renderOnce() - expect(f.captureCharFrame()).toContain(mode.toUpperCase()) - if (mode === "normal") { - f.mockInput.pressKey("0") - f.mockInput.pressKey("x") - expect(editor.value).toBe("uery") - f.mockInput.pressKey("i") - } else { - f.mockInput.pressKey("x") - expect(editor.value).toContain("x") - f.mockInput.pressEscape() - } - await f.renderOnce() - expect(f.captureCharFrame()).toContain(mode === "normal" ? "INSERT" : "NORMAL") - expect(f.captureCharFrame()).not.toContain(mode.toUpperCase()) - f.closeDialog(editor) - await f.renderOnce() - expect(f.captureCharFrame()).toContain(mode.toUpperCase()) - expect(f.input.cursorStyle.style).toBe(cursor) - expect(f.input.plainText).toBe("hello") - } -}) - -test("dialog search deletion and undo emit updated filter text", async () => { - const f = await mount() - const editor = f.openDialog("hello") - const queries: string[] = [] - editor.on("input", (value: string) => queries.push(value)) - f.mockInput.pressKey("0") - f.mockInput.pressKey("x") - expect(editor.value).toBe("ello") - expect(queries).toEqual(["ello"]) - f.mockInput.pressKey("u") - expect(editor.value).toBe("hello") - expect(queries).toEqual(["ello", "hello"]) - f.mockInput.pressKey("d") - f.mockInput.pressKey("w") - expect(editor.value).toBe("") - expect(queries.at(-1)).toBe("") - expect(f.input.plainText).toBe("hello") -}) - -test("dialog j/k navigate only in idle normal mode", async () => { - const f = await mount() - const editor = f.openDialog() - f.mockInput.pressKey("j") - f.mockInput.pressKey("k") - expect(f.dispatched).toEqual(["dialog.select.next", "dialog.select.prev"]) - expect(editor.value).toBe("") - f.mockInput.pressKey("i") - f.mockInput.pressKey("j") - f.mockInput.pressKey("k") - expect(editor.value).toBe("jk") - f.mockInput.pressEscape() - f.mockInput.pressKey("f") - f.mockInput.pressKey("j") - expect(f.dispatched).toHaveLength(2) - f.mockInput.pressEnter() - expect(f.dispatched.at(-1)).toBe("dialog.select.submit") -}) - -test("dialog custom mappings take precedence over list navigation", async () => { - const f = await mount({ keymaps: { normal: { jj: "x" }, insert: { kj: "normal" } } }) - const editor = f.openDialog("hello") - f.mockInput.pressKey("0") - f.mockInput.pressKey("j") - f.mockInput.pressKey("j") - expect(editor.value).toBe("ello") - expect(f.dispatched).toEqual([]) - f.mockInput.pressKey("i") - f.mockInput.pressKey("k") - f.mockInput.pressKey("j") - expect(editor.value).toBe("ello") - expect(editor.cursorStyle.style).toBe("block") -}) - -test("dialog editing preserves the prompt's mode and undo history", async () => { - const f = await mount() - f.mockInput.pressKey("x") - const editor = f.openDialog("models") - f.mockInput.pressKey("0") - f.mockInput.pressKey("x") - f.mockInput.pressKey("i") - f.closeDialog(editor) - expect(f.input.cursorStyle.style).toBe("block") - f.mockInput.pressKey("u") - expect(f.input.plainText).toBe("hello") -}) - -test("dialog Escape exits insert first and leaves closing to the host", async () => { - const f = await mount({ defaultMode: "insert" }) - const editor = f.openDialog("model") - const hostKeys: string[] = [] - f.renderer.keyInput.on("keypress", (event) => { hostKeys.push(event.name); event.preventDefault() }) - f.mockInput.pressEscape() - expect(editor.cursorStyle.style).toBe("block") - expect(hostKeys).toEqual([]) - f.mockInput.pressEscape() - for (const key of ["TAB", "HOME", "END", "\u001b[5~", "\u001b[6~", "ARROW_LEFT", "ARROW_RIGHT"]) f.mockInput.pressKey(key) - expect(hostKeys).toEqual(["escape", "tab", "home", "end", "pageup", "pagedown", "left", "right"]) -}) - -test("dialog prompt submission does not submit the main prompt", async () => { - const f = await mount() - f.openDialog("name", "prompt") - f.mockInput.pressEnter() - expect(f.dispatched).toEqual(["dialog.prompt.submit"]) -}) - -test("unrelated modal input keeps native behavior", async () => { - const f = await mount() - const editor = f.openDialog("", "other") - f.mockInput.pressKey("x") - f.mockInput.pressKey("j") - expect(editor.value).toBe("xj") - expect(f.dispatched).toEqual([]) -}) - -test("s marks the existing transcript without changing its layout or opening a reader", async () => { - const f = await mount({}, { messages: [message(1, "Question", "user"), message(2, "Answer")] }) - await f.keys("lx") - const offset = f.input.cursorOffset - const row = f.transcript().getRenderable("2")! - const layout = { y: row.y, height: row.height, width: row.width, scroll: f.transcript().scrollTop } - const before = f.captureCharFrame().split("\n").slice(0, 10).join("\n") - await f.keys("s") - expect(f.hostMode()).toBe("opencode-vim.session") - expect(f.captureCharFrame()).toContain("SESSION · Enter open · yy copy · s prompt") - expect(f.captureCharFrame()).toContain("▎Answer") - expect(f.reader()).toBeUndefined() - expect({ y: row.y, height: row.height, width: row.width, scroll: f.transcript().scrollTop }).toEqual(layout) - expect(f.captureCharFrame().split("\n").slice(0, 10).join("\n").replaceAll("▎", " ")).toBe(before) - await f.keys("s") - expect(f.hostMode()).toBe("base") - expect(f.captureCharFrame()).not.toContain("▎") - expect(f.renderer.currentFocusedEditor).toBe(f.input) - expect(f.input.cursorOffset).toBe(offset) - expect(f.captureCharFrame()).toContain("NORMAL") - await f.keys("u") - expect(f.input.plainText).toBe("hello") -}) - -test("s only opens from idle prompt normal mode and respects mappings", async () => { - const f = await mount({ keymaps: { normal: { ss: "x" } } }, { messages: [message(1, "Answer")] }) - await f.keys("ss") - expect(f.hostMode()).toBe("base") - expect(f.input.plainText).toBe("ello") - await f.keys("is") - expect(f.hostMode()).toBe("base") - expect(f.input.plainText).toBe("sello") - f.mockInput.pressEscape() - await f.keys("fs") - expect(f.hostMode()).toBe("base") - f.openDialog("search") - await f.keys("s") - expect(f.hostMode()).toBe("modal") -}) - -test("sessionKey changes the toggle and footer hints in browsing and the message modal", async () => { - const f = await mount({ sessionKey: "q" }, { messages: [message(1, "one q answer")] }) - await f.keys("s") - expect(f.hostMode()).toBe("base") - await f.keys("q") - expect(f.hostMode()).toBe("opencode-vim.session") - expect(f.captureCharFrame()).toContain("SESSION · Enter open · yy copy · q prompt") - await f.keys("s") - expect(f.hostMode()).toBe("opencode-vim.session") - await f.keys("q") - expect(f.hostMode()).toBe("base") - await f.keys("q") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.captureCharFrame()).toContain("v select · q prompt") - await f.keys("sfq") - expect(f.hostMode()).toBe("modal") - expect(f.reader().cursorOffset).toBe(4) - await f.keys("vq") - expect(f.hostMode()).toBe("modal") - expect(f.captureCharFrame()).toContain("VISUAL") - f.mockInput.pressEscape() - await f.keys("q") - expect(f.hostMode()).toBe("base") - expect(f.dialog()).toBeUndefined() - expect(f.input.plainText).toBe("hello") -}) - -test("custom session keys respect prompt mappings, pending motions and insert/dialog scope", async () => { - const f = await mount({ sessionKey: "q", keymaps: { normal: { qq: "x" } } }, { messages: [message(1, "Answer")] }) - await f.keys("qq") - expect(f.hostMode()).toBe("base") - expect(f.input.plainText).toBe("ello") - await f.keys("iq") - expect(f.input.plainText).toBe("qello") - f.mockInput.pressEscape() - await f.keys("fq") - expect(f.hostMode()).toBe("base") - f.openDialog("search") - await f.keys("q") - expect(f.hostMode()).toBe("modal") -}) - -test.each(["s", "c"])("Ctrl+%s can toggle session mode from both browsing and the modal", async (key) => { - const sessionKey = `` - const f = await mount({ sessionKey }, { messages: [message(1, "Answer")] }) - f.setPending([{ key: "ctrl+x" }]) - f.mockInput.pressKey(key, { ctrl: true }) - expect(f.hostMode()).toBe("base") - f.setPending([]) - f.mockInput.pressKey(key, { ctrl: true }) - await f.renderOnce() - expect(f.hostMode()).toBe("opencode-vim.session") - expect(f.captureCharFrame()).toContain(`${sessionKey} prompt`) - f.mockInput.pressKey(key, { ctrl: true }) - expect(f.hostMode()).toBe("base") - f.mockInput.pressKey(key, { ctrl: true }) - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.hostMode()).toBe("modal") - expect(f.captureCharFrame()).toContain(`${sessionKey} prompt`) - f.mockInput.pressKey(key, { ctrl: true }) - await f.renderOnce() - expect(f.hostMode()).toBe("base") - expect(f.dialog()).toBeUndefined() - expect(f.input.plainText).toBe("hello") -}) - -test("a multi-key sessionKey falls back to the default toggle", async () => { - const f = await mount({ sessionKey: "gs" }, { messages: [message(1, "Answer")] }) - await f.keys("s") - expect(f.hostMode()).toBe("opencode-vim.session") - expect(f.captureCharFrame()).toContain("SESSION · Enter open · yy copy · s prompt") - await f.keys("s") - expect(f.hostMode()).toBe("base") -}) - -test("message navigation uses native history loading and marks newly mounted messages", async () => { - const recent = [message(4, "Fourth"), message(5, "Fifth")] - const f = await mount({}, { messages: recent, older: [message(1, "First", "user"), message(2, "Second"), message(3, "Third")] }) - await f.keys("s") - expect(f.captureCharFrame()).toContain("▎Fifth") - f.dispatched.length = 0 - await f.keys("2k") - expect(f.captureCharFrame()).toContain("▎Third") - expect(f.dispatched).toContain("session.line.up") - expect(f.dispatched).not.toContain("session.message.previous") - await f.keys("gg") - expect(f.captureCharFrame()).toContain("▎First") - expect(f.captureCharFrame()).toContain("SESSION · Enter open · yy copy · s prompt") - await f.keys("2j") - expect(f.captureCharFrame()).toContain("▎Third") - f.setMessages([...recent, message(6, "Sixth")]) - await f.renderOnce() - await f.keys("G") - expect(f.captureCharFrame()).toContain("▎Sixth") - expect(f.reader()).toBeUndefined() -}) - -test("whole-message yank preserves Markdown and can be pasted in the prompt", async () => { - const text = "# Heading\n\n```ts\nconst 中 = '👍🏽'\n```" - const f = await mount({}, { messages: [message(1, text)] }) - await f.keys("syy") - expect(f.copied).toEqual([text]) - await f.keys("s$p") - expect(f.input.plainText).toBe("hello" + text) -}) - -test("lines copied from a message fill an empty prompt without a leading blank line", async () => { - const text = "quoted 中 👩‍💻\nsecond line" - const f = await mount({}, { messages: [message(1, text)] }) - f.input.setText("") - await f.keys("s") - f.mockInput.pressEnter() - await f.keys("Vjy") - expect(f.copied).toEqual([text + "\n"]) - await f.keys("sp") - expect(f.input.plainText).toBe(text) - expect(f.input.cursorOffset).toBe(0) - await f.keys("u") - expect(f.input.plainText).toBe("") - f.mockInput.pressKey("r", { ctrl: true }) - expect(f.input.plainText).toBe(text) -}) - -test("session yy briefly highlights only the copied item without changing its text or layout", async () => { - const text = "Yanked 中 👍🏽 é\nSecond line" - const f = await mount({}, { messages: [message(1, "Question", "user"), message(2, text)] }) - await f.keys("s") - const before = f.captureSpans().lines.slice(0, 10) - const characters = f.captureCharFrame().split("\n").slice(0, 10) - const scroll = f.transcript().scrollTop - await f.keys("yy") - const spans = f.captureSpans().lines.flatMap((line) => line.spans) - for (const part of text.split("\n")) { - const span = spans.find((span) => span.text.includes(part)) - expect(span?.bg).toEqual(RGBA.fromHex("#00ffff")) - expect(span?.fg).toEqual(RGBA.fromHex("#000000")) - } - expect(spans.find((span) => span.text.includes("Question"))?.bg).not.toEqual(RGBA.fromHex("#00ffff")) - expect(f.captureCharFrame().split("\n").slice(0, 10)).toEqual(characters) - expect(f.transcript().scrollTop).toBe(scroll) - expect(f.copied).toEqual([text]) - await Bun.sleep(280) - await f.renderOnce() - expect(f.captureSpans().lines.slice(0, 10)).toEqual(before) -}) - -test("session yank flashes stay inside the viewport and clear on navigation or exit", async () => { - const text = Array.from({ length: 24 }, (_, index) => `row ${index} 中`).join("\n") - const f = await mount({}, { messages: [message(1, "Question", "user"), message(2, text)] }) - await f.keys("syy") - const view = f.transcript().viewport - const flashed = f.captureSpans().lines - for (let y = 0; y < flashed.length; y++) { - expect(flashed[y].spans.some((span) => span.bg.equals(RGBA.fromHex("#00ffff")))) - .toBe(y >= view.y && y < view.y + view.height) - } - expect(f.copied).toEqual([text]) - await f.keys("k") - expect(f.captureSpans().lines.flatMap((line) => line.spans).some((span) => span.bg.equals(RGBA.fromHex("#00ffff")))).toBe(false) - await f.keys("yy") - expect(f.captureSpans().lines.flatMap((line) => line.spans).some((span) => span.bg.equals(RGBA.fromHex("#00ffff")))).toBe(true) - await f.keys("ss") - expect(f.captureSpans().lines.flatMap((line) => line.spans).some((span) => span.bg.equals(RGBA.fromHex("#00ffff")))).toBe(false) -}) - -test("entering a message reveals a cursor, supports visual yanks and returns in steps", async () => { - const f = await mount({}, { messages: [message(1, "one two\nsecond line")] }) - await f.keys("s") - f.dispatched.length = 0 - const scroll = f.transcript().scrollTop - const rows = f.transcript().getChildren().map((row) => [row.id, row.y, row.height]) - f.mockInput.pressEnter() - await f.keys("wvll") - expect(f.dialogOptions()).toEqual({ size: "large", centered: true }) - expect(f.hostMode()).toBe("modal") - const modal = f.renderer.root.findDescendantById("host-dialog")! - expect(modal.x).toBeGreaterThan(0) - expect(modal.y).toBeGreaterThan(0) - expect(modal.y + modal.height).toBeLessThan(f.renderer.height) - expect(Math.abs(modal.y - (f.renderer.height - modal.height) / 2)).toBeLessThanOrEqual(1) - expect(f.transcript().getChildren().map((row) => [row.id, row.y, row.height])).toEqual(rows) - expect(f.reader().showCursor).toBe(true) - expect(f.reader().getSelectedText()).toBe("two") - expect(f.captureCharFrame()).toContain("VISUAL") - await f.keys("y") - expect(f.copied).toEqual(["two"]) - f.mockInput.pressEscape() - await f.renderOnce() - expect(f.reader()).toBeUndefined() - expect(f.dialog()).toBeUndefined() - expect(f.hostMode()).toBe("opencode-vim.session") - expect(f.transcript().scrollTop).toBe(scroll) - f.mockInput.pressEnter() - expect(f.reader().cursorOffset).toBe(4) - await f.keys("Vjy") - expect(f.copied.at(-1)).toBe("one two\nsecond line\n") - f.mockInput.pressEscape() - f.mockInput.pressEscape() - expect(f.hostMode()).toBe("base") - expect(f.input.plainText).toBe("hello") - expect(f.dispatched).toEqual([]) -}) - -test("reader blocks edits, custom editing maps and bracketed paste", async () => { - const text = "six words here\nmore words" - const f = await mount({ keymaps: { normal: { j: "dd", Q: "insert" } } }, { messages: [message(1, text)] }) - await f.keys("s") - await f.mockInput.pasteBracketedText("MUTATION") - f.mockInput.pressEnter() - await f.keys("iaAoOdDcxpru.Q") - await f.mockInput.pasteBracketedText("MUTATION") - expect(f.reader().plainText).toBe(text) - expect(f.captureCharFrame()).toContain("v select · s prompt") - await f.keys("0fs") - expect(f.reader()).toBeDefined() - await f.keys("0yiw") - expect(f.copied.at(-1)).toBe("six") - await f.keys("j") - expect(f.reader().cursorOffset).toBeGreaterThan(0) - expect(f.reader().plainText).toBe(text) - await f.keys("s") - expect(f.reader()).toBeUndefined() - expect(f.hostMode()).toBe("base") -}) - -test("shell readers separate command and output and preserve read-only Vim controls", async () => { - const f = await mount({}, { messages: [{ id: "shell-message", type: "assistant", time: { created: 1 }, content: [{ - type: "tool", id: "shell-call", name: "shell", text: "Shell summary", state: { - status: "completed", input: { command: "printf output", workdir: "/workspace" }, - content: [{ type: "text", text: "first output\nsecond output\n" }], metadata: { exit: 0 }, - }, - }] }] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.captureCharFrame()).toContain("Shell output") - expect(f.captureCharFrame()).toContain("Command") - expect(f.captureCharFrame()).toContain("Output") - expect(f.captureCharFrame()).toContain("printf output") - expect(f.captureCharFrame()).toContain("v select · tab switch section · s prompt") - const command = f.renderer.currentFocusedEditor as TextareaRenderable - expect(command.id).toBe("vim-shell-command") - expect(command.cursorOffset).toBe(0) - await f.keys("xviwy") - expect(command.plainText).toBe("printf output") - expect(f.copied.at(-1)).toBe("printf") - f.mockInput.pressKey("TAB") - await f.renderOnce() - expect(f.renderer.currentFocusedEditor).toBe(f.reader()) - expect(f.reader().plainText).toBe("first output\nsecond output") - await f.keys("xVjy") - expect(f.reader().plainText).toBe("first output\nsecond output") - expect(f.copied.at(-1)).toBe("first output\nsecond output\n") - f.mockInput.pressKey("TAB") - await f.renderOnce() - expect(f.renderer.currentFocusedEditor).toBe(command) - await f.keys("v") - expect(command.hasSelection()).toBe(true) - f.reader().focus() - await f.renderOnce() - expect(command.hasSelection()).toBe(false) - await f.keys("s") - expect(f.renderer.currentFocusedEditor).toBe(f.input) - expect(f.input.plainText).toBe("hello") -}) - -test.each(["", "v", "V"])("session keymaps remap panel switching and release Tab in modal mode %j", async (mode) => { - const f = await mount({ keymaps: { session: { "": "passthrough", "w": "switch-panel" } } }, { - messages: [{ id: "shell-message", type: "assistant", time: { created: 1 }, content: [{ - type: "tool", id: "shell-call", name: "shell", text: "Shell summary", state: { - status: "completed", input: { command: "printf output" }, content: [{ type: "text", text: "first output" }], - }, - }] }], - }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - const command = f.renderer.currentFocusedEditor as TextareaRenderable - expect(f.captureCharFrame()).toContain("v select · w switch section · s prompt") - expect(f.captureCharFrame()).not.toContain("tab switch") - await f.keys(mode) - const selection = command.getSelectedText() - let passed = 0 - f.renderer.keyInput.on("keypress", (event) => { if (event.name === "tab" && !event.defaultPrevented) passed++ }) - f.mockInput.pressKey("TAB") - await f.renderOnce() - expect(passed).toBe(1) - expect(f.renderer.currentFocusedEditor).toBe(command) - expect(command.getSelectedText()).toBe(selection) - f.mockInput.pressKey("w", { ctrl: true }) - await f.renderOnce() - expect(f.renderer.currentFocusedEditor).toBe(command) - await f.keys("w") - expect(f.renderer.currentFocusedEditor).toBe(f.reader()) - expect(command.hasSelection()).toBe(false) - expect(command.plainText).toBe("printf output") - expect(f.reader().plainText).toBe("first output") - - f.mockInput.pressKey("w", { ctrl: true }) - command.focus() - await f.keys("w") - expect(f.renderer.currentFocusedEditor).toBe(command) - expect(command.cursorOffset).toBe(7) - f.mockInput.pressKey("w", { ctrl: true }) - f.mockInput.pressEscape() - await f.renderOnce() - expect(f.reader()).toBeUndefined() -}) - -test("session Tab passthrough hides disabled panel hints and leaves generic modals read-only", async () => { - const f = await mount({ keymaps: { session: { "": "passthrough" } } }, { messages: [message(1, "one two")] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.captureCharFrame()).toContain("v select · s prompt") - expect(f.captureCharFrame()).not.toContain("switch") - let passed = 0 - f.renderer.keyInput.on("keypress", (event) => { if (event.name === "tab" && !event.defaultPrevented) passed++ }) - f.mockInput.pressKey("TAB") - await f.keys("xw") - expect(passed).toBe(1) - expect(f.reader().plainText).toBe("one two") - expect(f.reader().cursorOffset).toBe(4) -}) - -test("session passthrough applies while browsing and reading without changing prompt mappings", async () => { - const f = await mount({ keymaps: { - normal: { j: "x" }, - session: { j: "passthrough", "": "passthrough", "w": "switch-panel" }, - } }, { messages: [message(1, "first message"), message(2, "last message")] }) - await f.keys("j") - expect(f.input.plainText).toBe("ello") - await f.keys("s") - const before = f.captureCharFrame() - const passed: string[] = [] - f.renderer.keyInput.on("keypress", (event) => { - if (!event.defaultPrevented) passed.push(event.ctrl ? `ctrl+${event.name}` : event.name) - }) - f.mockInput.pressKey("TAB") - f.mockInput.pressKey("w", { ctrl: true }) - await f.keys("j") - expect(passed).toEqual(["tab", "ctrl+w", "j"]) - expect(f.captureCharFrame()).toBe(before) - expect(f.hostMode()).toBe("opencode-vim.session") - await f.keys("k") - expect(f.captureCharFrame()).toContain("▎first message") - f.mockInput.pressEnter() - await f.renderOnce() - const offset = f.reader().cursorOffset - await f.keys("j") - expect(passed.at(-1)).toBe("j") - expect(f.reader().cursorOffset).toBe(offset) - expect(f.reader().plainText).toBe("first message") - await f.keys("s") - await f.keys("j") - expect(f.input.plainText).toBe("llo") -}) - -test("shell output keeps deliberate blank lines and trailing spaces", async () => { - const f = await mount({}, { messages: [{ id: "shell-message", type: "assistant", time: { created: 1 }, content: [{ - type: "tool", id: "shell-call", name: "shell", text: "Shell summary", state: { - status: "completed", input: { command: "printf output" }, - content: [{ type: "text", text: "output \n\n" }], metadata: { exit: 0 }, - }, - }] }] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.reader().plainText).toBe("output \n") -}) - -test("shell command highlighting preserves Unicode and an active selection", async () => { - let finish!: (value: { highlights: [number, number, string][] }) => void - const highlighting = spyOn(OpenTUI.getTreeSitterClient(), "highlightOnce").mockImplementation(() => new Promise((resolve) => { finish = resolve })) - try { - const command = "printf '你好' | cat" - const f = await mount({}, { messages: [{ id: "shell-message", type: "assistant", time: { created: 1 }, content: [{ - type: "tool", id: "shell-call", name: "shell", text: "Shell summary", state: { - status: "completed", input: { command }, content: [{ type: "text", text: "你好\n" }], metadata: { exit: 0 }, - }, - }] }] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - expect(highlighting).toHaveBeenCalledWith(command, "bash") - const input = f.renderer.currentFocusedEditor as TextareaRenderable - await f.keys("viw") - const offset = input.cursorOffset - finish({ highlights: [[0, 6, "function"], [7, 11, "string"], [14, 17, "function"]] }) - await f.renderOnce() - expect(input.plainText).toBe(command) - expect(input.getSelectedText()).toBe("printf") - expect(input.cursorOffset).toBe(offset) - expect(input.getLineHighlights(0)).toEqual(expect.arrayContaining([ - expect.objectContaining({ start: 7, end: 13 }), expect.objectContaining({ start: 16, end: 19 }), - ])) - } finally { - highlighting.mockRestore() - } -}) - -test("shell diff colors preserve Unicode output, selection and copying", async () => { - const output = "diff --git a/file b/file\n--- a/file\n+++ b/file\n@@ -1 +1 @@\n-hello\n+你好 👩‍💻" - const f = await mount({}, { messages: [{ id: "diff-message", type: "assistant", time: { created: 1 }, content: [{ - type: "tool", id: "diff-call", name: "shell", text: "Diff summary", state: { - status: "completed", input: { command: "git diff" }, content: [{ type: "text", text: output }], metadata: { exit: 0 }, - }, - }] }] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - const input = f.reader() - expect(input.getLineHighlights(4)[0]?.styleId).not.toBe(input.getLineHighlights(5)[0]?.styleId) - expect(input.getLineHighlights(5)[0]?.end).toBe(Bun.stringWidth("+你好 👩‍💻")) - f.mockInput.pressKey("TAB") - await f.keys("GVy") - expect(f.copied.at(-1)).toBe("+你好 👩‍💻\n") - expect(input.plainText).toBe(output) - f.mockInput.pressEscape() - await f.renderOnce() - expect(f.hostMode()).toBe("opencode-vim.session") -}) - -test.each([[80, 16], [120, 38]])("read tools show saved code with scrolling line numbers and copy without the gutter at %ix%i", async (width, height) => { - let finish!: (value: { highlights: [number, number, string][] }) => void - const highlighting = spyOn(OpenTUI.getTreeSitterClient(), "highlightOnce").mockImplementation(() => new Promise((resolve) => { finish = resolve })) - try { - const lines = ['const greeting = "你好 👩‍💻";', `const wrapped = "${"long text ".repeat(15)}";`] - for (let number = 43; number <= 100; number++) lines.push(`const value${number} = ${number};`) - const text = lines.join("\n") - const result = ["Read file src/example.ts, lines 41-100", ...lines.map((line, index) => `${index + 41}: ${line}`), "[Output truncated. Continue reading with offset: 101]"].join("\n") - const f = await mount({}, { messages: [{ id: "read-message", type: "assistant", time: { created: 1 }, content: [{ - type: "tool", id: "read-call", name: "read", text: "Read src/example.ts", state: { - status: "completed", input: { path: "src/example.ts", offset: 41 }, content: [{ type: "text", text: result }], - }, - }] }] }) - f.resize(width, height) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - const input = f.reader() - expect(input.plainText).toBe(text) - expect(f.renderer.currentFocusedEditor).toBe(input) - expect(f.captureCharFrame()).toContain("src/example.ts") - expect(f.captureCharFrame()).toContain("Lines 41–100") - expect(f.captureCharFrame()).toContain("Partial file") - expect(f.captureCharFrame()).toMatch(/41\s+const greeting/) - expect(highlighting).toHaveBeenCalledWith(text, "typescript") - await f.keys("wviw") - const offset = input.cursorOffset - finish({ highlights: [[17, lines[0].length - 1, "string"]] }) - await f.renderOnce() - expect(input.getSelectedText()).toBe("greeting") - expect(input.cursorOffset).toBe(offset) - expect(input.getLineHighlights(0)[0]).toMatchObject({ start: 17, end: Bun.stringWidth(lines[0]) - 1 }) - f.mockInput.pressEscape() - await f.keys("ggVyxG") - expect(f.copied.at(-1)).toBe(lines[0] + "\n") - expect(input.plainText).toBe(text) - expect(input.scrollY).toBeGreaterThan(0) - expect(f.captureCharFrame()).toMatch(/100\s+const value100/) - const cursor = f.renderer.getCursorState() - expect(cursor.visible).toBe(true) - expect(f.captureCharFrame().split("\n")[cursor.y - 1]).toContain("value100") - const remembered = input.cursorOffset - f.mockInput.pressEscape() - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.reader().cursorOffset).toBe(remembered) - f.resize(44, 20) - await f.keys("gg") - expect(f.captureCharFrame()).toMatch(/41\s+const greeting/) - expect(f.reader().plainText).toBe(text) - const modal = f.renderer.root.findDescendantById("host-dialog")! - expect(modal.y).toBeGreaterThanOrEqual(0) - expect(modal.y + modal.height).toBeLessThanOrEqual(20) - await f.keys("s") - expect(f.renderer.currentFocusedEditor).toBe(f.input) - expect(f.input.plainText).toBe("hello") - finish({ highlights: [[0, 5, "function"]] }) - await f.renderOnce() - } finally { - highlighting.mockRestore() - } -}) - -test.each(["Read directory src, 0 entries", "Image read successfully", "Custom read result"])("non-file read results keep the default view: %s", async (result) => { - const f = await mount({}, { messages: [{ id: "read-message", type: "assistant", time: { created: 1 }, content: [{ - type: "tool", id: "read-call", name: "read", text: "Original read summary", state: { - status: "completed", input: { path: "src" }, content: [{ type: "text", text: result }], - }, - }] }] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.reader().plainText).toBe("Original read summary") - expect(f.renderer.root.findDescendantById("vim-file-lines")).toBeUndefined() -}) - -test.each(["Read file empty.unknown, 0 lines", "Read file empty.unknown, lines 1-1\n1: "])("empty and blank read files stay empty when copied: %s", async (result) => { - const f = await mount({}, { messages: [{ id: "read-message", type: "assistant", time: { created: 1 }, content: [{ - type: "tool", id: "read-call", name: "read", text: "Read empty.unknown", state: { - status: "completed", input: { path: "empty.unknown" }, content: [{ type: "text", text: result }], - }, - }] }] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.keys("yy") - expect(f.reader().plainText).toBe("") - expect(f.copied.at(-1)).toBe("\n") - expect(f.captureCharFrame()).toContain(result.endsWith("0 lines") ? "Empty file" : "Lines 1–1") -}) - -function changeMessage(status = "modified", patch = '--- src/change.ts\n+++ src/change.ts\n@@ -10,3 +10,4 @@\n const keep = 1;\n-const text = "old";\n+const text = "你好 👩‍💻";\n+const extra = 2;\n console.log(text);\n@@ -90,1 +91,1 @@\n-const tail = "old";\n+const tail = "new";\n'): TestMessage { - return { id: "change-message", type: "assistant", time: { created: 1 }, content: [{ - type: "tool", id: "edit-change", name: "edit", text: "Edit src/change.ts", state: { - status: "completed", input: { path: "src/change.ts", oldString: "stale input", newString: "unformatted input" }, - content: [{ type: "text", text: "Edited src/change.ts" }], metadata: { files: [ - { file: "src/change.ts", status, additions: 3, deletions: 2, patch }, - ] }, - }, - }] } -} - -test.each(["after", "before", "diff"] as const)("change views honor diffView=%s", async (diffView) => { - const f = await mount({ diffView }, { messages: [changeMessage()] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - const active = f.renderer.root.findDescendantById(`vim-change-view-${diffView}`) as OpenTUI.TextRenderable - expect(active.bg).toEqual(RGBA.fromHex("#006699")) - for (const view of ["After", "Before", "Diff"]) expect(f.captureCharFrame()).toContain(view) - expect(f.captureCharFrame()).toContain("v select · tab switch view · s prompt") - expect(f.reader().plainText.includes('const text = "old";')).toBe(diffView !== "after") - expect(f.reader().plainText.includes('const text = "你好 👩‍💻";')).toBe(diffView !== "before") - expect(f.reader().plainText).not.toContain("@@") - expect(f.renderer.currentFocusedEditor).toBe(f.reader()) - const next = diffView === "after" ? "before" : "after" - const tab = f.renderer.root.findDescendantById(`vim-change-view-${next}`)! - await f.mockMouse.click(tab.x, tab.y) - await f.renderOnce() - expect(f.captureCharFrame()).toContain(next === "after" ? "Code after change" : "Code before change") - expect(f.reader().plainText.includes('const text = "old";')).toBe(next === "before") - expect(f.renderer.currentFocusedEditor).toBe(f.reader()) -}) - -test("change views scroll through all excerpts, copy code without signs, and preserve per-view positions", async () => { - const f = await mount({}, { messages: [changeMessage()] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.keys("xjVy") - expect(f.copied.at(-1)).toBe('const text = "你好 👩‍💻";\n') - const afterOffset = f.reader().cursorOffset - f.mockInput.pressTab() - await f.keys("jVy") - expect(f.copied.at(-1)).toBe('const text = "old";\n') - f.mockInput.pressTab() - await f.keys("jVy") - expect(f.copied.at(-1)).toBe('const text = "old";\n') - const gutter = f.renderer.root.findDescendantById("vim-file-lines") as OpenTUI.LineNumberRenderable - expect(gutter.getLineColors().content.get(1)).toEqual(RGBA.fromHex("#330000")) - expect(gutter.getLineColors().content.get(2)).toEqual(RGBA.fromHex("#003300")) - expect(f.captureCharFrame()).toMatch(/11\s*-\s*const text/) - expect(f.captureCharFrame()).toMatch(/11\s*\+\s*const text/) - await f.keys("GVy") - expect(f.copied.at(-1)).toBe('const tail = "new";\n') - expect(f.reader().scrollY).toBeGreaterThan(0) - expect(f.captureCharFrame()).toMatch(/90\s*-\s*const tail/) - expect(f.captureCharFrame()).toMatch(/91\s*\+\s*const tail/) - const diffOffset = f.reader().cursorOffset - f.mockInput.pressTab() - await f.renderOnce() - expect(f.reader().cursorOffset).toBe(afterOffset) - expect(f.captureCharFrame()).toMatch(/10\s+const keep/) - await f.keys("ggVGy") - expect(f.copied.at(-1)).toBe('const keep = 1;\nconst text = "你好 👩‍💻";\nconst extra = 2;\nconsole.log(text);\n\nconst tail = "new";\n') - await f.keys("v") - f.mockInput.pressTab() - await f.renderOnce() - expect(f.reader().hasSelection()).toBe(false) - expect(f.captureCharFrame()).not.toContain("VISUAL") - f.mockInput.pressTab() - await f.renderOnce() - expect(f.reader().cursorOffset).toBe(diffOffset) - f.resize(44, 20) - await f.renderOnce() - const modal = f.renderer.root.findDescendantById("host-dialog")! - expect(modal.y).toBeGreaterThanOrEqual(0) - expect(modal.y + modal.height).toBeLessThanOrEqual(20) - await f.keys("s") - expect(f.renderer.currentFocusedEditor).toBe(f.input) - expect(f.input.plainText).toBe("hello") -}) - -test("change shortcuts can be remapped while Tab passes through", async () => { - const f = await mount({ keymaps: { session: { "": "passthrough", "w": "switch-panel" } } }, { messages: [changeMessage()] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.captureCharFrame()).toContain("v select · w switch view · s prompt") - expect(f.captureCharFrame()).not.toContain("tab switch view") - const original = f.reader() - let tabs = 0 - f.renderer.keyInput.on("keypress", (event) => { if (event.name === "tab" && !event.defaultPrevented) tabs++ }) - f.mockInput.pressTab() - expect(tabs).toBe(1) - expect(f.reader()).toBe(original) - f.mockInput.pressKey("w", { ctrl: true }) - await f.keys("w") - expect(f.captureCharFrame()).toContain("Code before change") - await f.keys("GVy") - expect(f.copied.at(-1)).toBe('const tail = "old";\n') -}) - -test("change views remain clickable when keyboard cycling is disabled", async () => { - const f = await mount({ keymaps: { session: { "": "passthrough" } } }, { messages: [changeMessage()] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.captureCharFrame()).toContain("v select · s prompt") - expect(f.captureCharFrame()).not.toContain("switch view") - await f.keys("v") - const tab = f.renderer.root.findDescendantById("vim-change-view-diff")! - await f.mockMouse.click(tab.x, tab.y) - await f.renderOnce() - expect(f.captureCharFrame()).toContain("Changes") - expect(f.reader().plainText).toContain('const text = "old";') - expect(f.reader().plainText).toContain('const text = "你好 👩‍💻";') - expect(f.reader().hasSelection()).toBe(false) - expect(f.renderer.currentFocusedEditor).toBe(f.reader()) -}) - -test.each([ - ["added", "before", "--- /dev/null\n+++ src/change.ts\n@@ -0,0 +1,1 @@\n+added line\n", "After", "added line"], - ["deleted", "after", "--- src/change.ts\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-deleted line\n", "Before", "deleted line"], -] as const)("%s files fall back from the unavailable %s view", async (status, diffView, patch, label, text) => { - const f = await mount({ diffView }, { messages: [changeMessage(status, patch)] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.reader().plainText).toBe(text) - expect(f.captureCharFrame()).toContain(`Code ${label.toLowerCase()} change`) - expect(f.renderer.root.findDescendantById(status === "added" ? "vim-change-view-before" : "vim-change-view-after")).toBeUndefined() - f.mockInput.pressTab() - await f.renderOnce() - expect(f.captureCharFrame()).toContain("Changes") - f.mockInput.pressTab() - await f.renderOnce() - expect(f.captureCharFrame()).toContain(`Code ${label.toLowerCase()} change`) -}) - -test("unrecognized saved changes retain the generic view", async () => { - const f = await mount({}, { messages: [changeMessage("modified", "no patch available")] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.reader().plainText).toBe("Edit src/change.ts") - expect(f.renderer.root.findDescendantById("vim-change-view-after")).toBeUndefined() -}) - -test("streaming updates do not move a selection, and browsing refreshes the text", async () => { - const f = await mount({}, { messages: [message(1, "hello")] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.keys("vll") - f.setMessages([message(1, "hello world")]) - await f.renderOnce() - expect(f.reader().plainText).toBe("hello") - expect(f.reader().getSelectedText()).toBe("hel") - f.mockInput.pressEscape() - await f.renderOnce() - expect(f.reader().showCursor).toBe(true) - expect(f.reader().hasSelection()).toBe(false) - f.mockInput.pressEscape() - await f.renderOnce() - expect(f.reader()).toBeUndefined() - expect(f.captureCharFrame()).toContain("▎hello world") -}) - -test("paging a long message scrolls the viewport and retains its position", async () => { - const text = Array.from({ length: 100 }, (_, index) => `line ${index + 1}`).join("\n") - const f = await mount({}, { messages: [message(1, text)] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.renderOnce() - f.mockInput.pressKey("d", { ctrl: true }) - f.mockInput.pressKey("f", { ctrl: true }) - await f.renderOnce() - expect(f.reader().scrollY).toBeGreaterThan(0) - expect(f.reader().height).toBeLessThan(f.renderer.height / 2) - f.resize(44, 20) - await f.renderOnce() - const modal = f.renderer.root.findDescendantById("host-dialog")! - expect(modal.width).toBeLessThan(44) - expect(modal.height).toBeLessThan(20) - expect(f.reader().plainText).toBe(text) - const offset = f.reader().cursorOffset - f.mockInput.pressEscape() - f.mockInput.pressEnter() - expect(f.reader().cursorOffset).toBe(offset) - f.mockInput.pressKey("u", { ctrl: true }) - expect(f.reader().cursorOffset).toBeLessThan(offset) -}) - -test("message modal dismissals restore browsing and save the cursor", async () => { - const f = await mount({}, { messages: [message(1, "one two three")] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.keys("w") - const close = f.renderer.root.findDescendantById("vim-message-close")! - await f.mockMouse.click(close.x, close.y) - await f.renderOnce() - expect(f.reader()).toBeUndefined() - expect(f.hostMode()).toBe("opencode-vim.session") - expect(f.captureCharFrame()).toContain("▎one two three") - f.mockInput.pressEnter() - expect(f.reader().cursorOffset).toBe(4) - await f.renderOnce() - await f.mockMouse.click(0, 0) - expect(f.reader()).toBeUndefined() - f.mockInput.pressEnter() - f.mockInput.pressKey("c", { ctrl: true }) - expect(f.reader()).toBeUndefined() - expect(f.hostMode()).toBe("opencode-vim.session") - await f.keys("yy") - expect(f.copied.at(-1)).toBe("one two three") - expect(f.input.plainText).toBe("hello") -}) - -test("replacing the message modal disposes its keys without closing the replacement", async () => { - const f = await mount({}, { messages: [message(1, "Answer")] }) - await f.keys("s") - const listeners = f.renderer.keyInput.listenerCount("keypress") - f.mockInput.pressEnter() - await f.renderOnce() - f.replaceModal() - await f.renderOnce() - expect(f.reader()).toBeUndefined() - expect(f.renderer.keyInput.listenerCount("keypress")).toBe(listeners) - expect(f.captureCharFrame()).toContain("Another dialog") - f.toggle() - expect(f.dialog()).toBeDefined() - expect(f.captureCharFrame()).toContain("Another dialog") - f.clearModal() -}) - -test.each(["s", "escape"])("leaving the reader with Escape then %s keeps prompt focus after deferred refocus", async (exit) => { - const f = await mount({}, { messages: [message(1, "Answer")] }) - await f.keys("s") - f.mockInput.pressEnter() - f.mockInput.pressEscape() - if (exit === "escape") f.mockInput.pressEscape() - else f.mockInput.pressKey(exit) - expect(f.hostMode()).toBe("base") - expect(f.renderer.currentFocusedEditor?.id).toBe(f.input.id) - await Bun.sleep(10) - expect(f.renderer.currentFocusedEditor?.id).toBe(f.input.id) - await f.keys("iX") - expect(f.input.plainText).toBe("Xhello") - f.mockInput.pressEscape() - await f.keys("s") - expect(f.hostMode()).toBe("opencode-vim.session") -}) - -test("a later host dialog.clear cannot refocus an exited session browser", async () => { - const f = await mount({}, { messages: [message(1, "Answer")] }) - await f.keys("s") - f.mockInput.pressEnter() - await f.keys("s") - await Bun.sleep(10) - expect(f.renderer.currentFocusedEditor?.id).toBe(f.input.id) - // Native session scrolling clears dialogs even when none are open. - f.clearModal() - await Bun.sleep(10) - expect(f.hostMode()).toBe("base") - expect(f.renderer.currentFocusedEditor?.id).toBe(f.input.id) - await f.keys("iX") - expect(f.input.plainText).toBe("Xhello") -}) - -test("empty sessions use the native transcript and can be exited", async () => { - const f = await mount({}, { messages: [] }) - await f.keys("s") - expect(f.captureCharFrame()).toContain("SESSION · Enter open · yy copy · s prompt") - expect(f.reader()).toBeUndefined() - await f.keys("k") - await f.renderOnce() - expect(f.dispatched).toEqual(["session.line.up"]) - await f.keys("s") - expect(f.renderer.currentFocusedEditor).toBe(f.input) -}) - -test("disabling or unloading Vim removes the native marker and reader", async () => { - const f = await mount({}, { messages: [message(1, "Answer")] }) - const listeners = f.renderer.keyInput.listenerCount("keypress") - await f.keys("s") - f.toggle() - await f.renderOnce() - expect(f.hostMode()).toBe("base") - expect(f.captureCharFrame()).not.toContain("▎") - expect(f.renderer.keyInput.listenerCount("keypress")).toBe(listeners) - f.toggle() - await f.keys("s") - f.mockInput.pressEnter() - f.unmount() - await f.renderOnce() - expect(f.reader()).toBeUndefined() - expect(f.renderer.keyInput.listenerCount("keypress")).toBe(listeners - 2) -}) - -test("session browsing and reading yield to dialogs, then restore their focus", async () => { - const f = await mount({}, { messages: [message(1, "Answer")] }) - await f.keys("s") - f.dispatched.length = 0 - const browsingDialog = f.openDialog("search") - await f.keys("j") - expect(f.dispatched).toEqual(["dialog.select.next"]) - expect(f.captureCharFrame()).not.toContain("▎") - expect(f.captureCharFrame()).toContain("NORMAL") - expect(f.captureCharFrame()).not.toContain("SESSION") - await f.keys("i") - expect(f.captureCharFrame()).toContain("INSERT") - f.closeDialog(browsingDialog) - await f.renderOnce() - expect(f.captureCharFrame()).toContain("SESSION") - f.mockInput.pressEnter() - expect(f.renderer.currentFocusedEditor).toBe(f.reader()) - expect(f.reader().showCursor).toBe(true) - const editor = f.openDialog("models") - await f.keys("0is") - expect(editor.value).toBe("smodels") - expect(f.reader().plainText).toBe("Answer") - f.closeDialog(editor) - await f.keys("l") - expect(f.reader().cursorOffset).toBe(1) - await f.keys("s") - expect(f.reader()).toBeUndefined() - expect(f.renderer.currentFocusedEditor).toBe(f.input) -}) - -test("switching sessions closes the reader and removes its selection marker", async () => { - const f = await mount({}, { messages: [message(1, "Answer")] }) - await f.keys("s") - f.mockInput.pressEnter() - f.setRoute({ type: "session", sessionID: "other-session" }) - await f.renderOnce() - expect(f.reader()).toBeUndefined() - expect(f.hostMode()).toBe("base") - expect(f.captureCharFrame()).not.toContain("▎") -}) - -test("native marker selects one text part at a time and clips to the viewport", async () => { - const reply = message(1, "First part") - reply.content!.push({ type: "text", text: "Second part\nThird line" }) - const f = await mount({}, { messages: [reply, message(2, "Next message", "user")] }) - await f.keys("sgg") - const frame = f.captureCharFrame() - expect(frame).toContain("▎First part") - expect(frame).not.toContain("▎Second part") - expect(frame).not.toContain("▎Third line") - expect(frame).not.toContain("▎Next message") - await f.keys("yy") - expect(f.copied.at(-1)).toBe("First part") - await f.keys("jyy") - expect(f.captureCharFrame()).toContain("▎Second part") - expect(f.captureCharFrame()).toContain("▎Third line") - expect(f.copied.at(-1)).toBe("Second part\nThird line") - f.mockInput.pressEnter() - expect(f.reader().plainText).toBe("Second part\nThird line") - f.mockInput.pressEscape() - await f.keys("G") - const lines = f.captureCharFrame().split("\n") - expect(lines.filter((line) => line.includes("▎"))).toHaveLength(1) - - f.setMessages([message(2, Array.from({ length: 30 }, (_, i) => `row ${i}`).join("\n"), "user")]) - await f.renderOnce() - f.transcript().scrollTo(5) - await f.renderOnce() - const scrolled = f.captureCharFrame().split("\n") - expect(scrolled.slice(0, 10).every((line) => line.includes("▎"))).toBe(true) - expect(scrolled.slice(10).some((line) => line.includes("▎"))).toBe(false) - f.mockInput.pressEnter() - expect(f.reader().plainText).toContain("row 0") - expect(f.reader().plainText).toContain("row 29") -}) - -test("collapsed tool groups expand in place and individual calls can be read and copied", async () => { - const first = message(1, "Read first.ts\nFirst output") - first.content![0] = { type: "tool", id: "tool-1", name: "read", text: "Read first.ts\nFirst output", state: { - status: "completed", input: { path: "first.ts" }, content: [{ type: "text", text: "Read file first.ts, lines 1-1\n1: First output" }], - } } - const second = message(2, "Read second.ts\nSecond output") - second.content![0] = { type: "tool", id: "tool-2", name: "read", text: "Read second.ts\nSecond output", state: { - status: "completed", input: { path: "second.ts" }, content: [{ type: "text", text: "Read file second.ts, lines 1-1\n1: Second output" }], - } } - const f = await mount({}, { messages: [first, second, message(3, "Done")], group: ["1", "2"] }) - await f.keys("sk") - expect(f.captureCharFrame()).toContain("▎→Explored: 2 reads") - expect(f.captureCharFrame()).toContain("SESSION · Enter open · yy copy · s prompt") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.reader()).toBeUndefined() - expect(f.captureCharFrame()).toContain("First output") - await f.keys("jyy") - expect(f.copied.at(-1)).toBe("Read first.ts\nFirst output") - expect(f.captureCharFrame()).toContain("▎First output") - expect(f.captureCharFrame()).not.toContain("▎Second output") - f.mockInput.pressEnter() - expect(f.reader().plainText).toBe("First output") - f.mockInput.pressEscape() - await f.keys("jyy") - expect(f.copied.at(-1)).toBe("Read second.ts\nSecond output") - await f.keys("2k") - f.mockInput.pressEnter() - await f.renderOnce() - expect(f.captureCharFrame()).not.toContain("First output") - await f.keys("jyy") - expect(f.copied.at(-1)).toBe("Done") -}) - -test("session entry and navigation work when the first native row has no ID", async () => { - const tool = message(1, "Read file.ts\nFile contents") - tool.content![0] = { type: "tool", id: "read-1", name: "read", text: "Read file.ts\nFile contents", state: { - status: "completed", input: { path: "file.ts" }, content: [{ type: "text", text: "Read file file.ts, lines 1-1\n1: File contents" }], - } } - const f = await mount({}, { messages: [tool, message(2, "Done")], group: ["1"] }) - Reflect.set(f.transcript().getRenderable("1")!, "id", undefined) - await f.keys("s") - expect(f.hostMode()).toBe("opencode-vim.session") - expect(f.captureCharFrame()).toContain("▎Done") - await f.keys("k") - expect(f.captureCharFrame()).toContain("▎→Explored: 2 reads") - f.mockInput.pressEnter() - await f.renderOnce() - await f.keys("jyy") - expect(f.copied.at(-1)).toBe("Read file.ts\nFile contents") - await f.keys("j") - expect(f.captureCharFrame()).toContain("▎Done") - await f.keys("s") - expect(f.renderer.currentFocusedEditor).toBe(f.input) -}) - -test("text, reasoning and standalone tools are separate stops within one message", async () => { - const reply = message(1, "Start") - reply.content!.push({ type: "reasoning", text: "Check something" }, - { type: "tool", id: "shell-1", name: "shell", text: "$ pwd\n/work" }, { type: "text", text: "Finished" }) - const f = await mount({}, { messages: [reply] }) - await f.keys("skyy") - expect(f.copied.at(-1)).toBe("$ pwd\n/work") - await f.keys("kyy") - expect(f.copied.at(-1)).toBe("Check something") - await f.keys("kyy") - expect(f.copied.at(-1)).toBe("Start") - await f.keys("3jyy") - expect(f.copied.at(-1)).toBe("Finished") -}) - -test("session mode starts on the latest message when several messages share the viewport", async () => { - const f = await mount({}, { messages: [ - message(1, "First question", "user"), message(2, "First answer"), - message(3, "Latest question", "user"), message(4, "Latest answer"), - ] }) - await f.scrollTranscript("bottom") - expect(f.captureCharFrame()).toContain("First question") - expect(f.captureCharFrame()).toContain("Latest answer") - const before = f.captureCharFrame().split("\n").slice(0, 10).join("\n") - const scroll = f.transcript().scrollTop - const height = f.transcript().scrollHeight - const positions = f.transcript().getChildren().map((row) => [row.id, row.y, row.height]) - await f.keys("s") - expect(f.captureCharFrame()).toContain("▎Latest answer") - expect(f.captureCharFrame()).not.toContain("▎First question") - expect(f.captureCharFrame().split("\n").slice(0, 10).join("\n").replaceAll("▎", " ")).toBe(before) - expect(f.transcript().scrollTop).toBe(scroll) - expect(f.transcript().scrollHeight).toBe(height) - expect(f.transcript().getChildren().map((row) => [row.id, row.y, row.height])).toEqual(positions) - expect(f.dispatched).toEqual([]) - f.mockInput.pressEnter() - expect(f.reader().plainText).toBe("Latest answer") - f.mockInput.pressEscape() - await f.keys("k") - expect(f.captureCharFrame()).toContain("▎Latest question") - expect(f.transcript().scrollTop).toBe(scroll) - expect(f.dispatched).toEqual([]) - await f.keys("yy") - expect(f.copied.at(-1)).toBe("Latest question") - await f.keys("gg") - expect(f.captureCharFrame()).toContain("▎First question") - await f.keys("ss") - expect(f.captureCharFrame()).toContain("▎Latest answer") - await f.keys("k") - await f.keys("yy") - expect(f.copied.at(-1)).toBe("Latest question") -}) - -test("a partially visible latest reply is highlighted without scrolling or changing sticky mode", async () => { - const text = Array.from({ length: 30 }, (_, index) => `Reply line ${index}`).join("\n") - const f = await mount({}, { messages: [message(1, "Question", "user"), message(2, text)] }) - await f.scrollTranscript("bottom") - f.transcript().stickyScroll = true - await f.renderOnce() - const top = f.transcript().scrollTop - const before = f.captureCharFrame().split("\n").slice(0, 10).join("\n") - expect(before).not.toContain("Reply line 0") - await f.keys("s") - expect(f.transcript().scrollTop).toBe(top) - expect(f.transcript().stickyScroll).toBe(true) - expect(f.captureCharFrame().split("\n").slice(0, 10).join("\n").replaceAll("▎", " ")).toBe(before) - expect(f.dispatched).toEqual([]) - await f.keys("yy") - expect(f.copied.at(-1)).toBe(text) -}) - -test("visible message selection is immediate and off-screen selections still scroll into view", async () => { - const f = await mount({}, { messages: [ - message(1, Array.from({ length: 20 }, (_, index) => `Old line ${index}`).join("\n"), "user"), - message(2, "Visible reply"), message(3, "Latest question", "user"), message(4, "Latest reply"), - ] }) - await f.scrollTranscript("bottom") - const top = f.transcript().scrollTop - await f.keys("skyy") - expect(f.copied.at(-1)).toBe("Latest question") - expect(f.transcript().scrollTop).toBe(top) - expect(f.dispatched).toEqual([]) - await f.keys("jG") - expect(f.captureCharFrame()).toContain("▎Latest reply") - expect(f.transcript().scrollTop).toBe(top) - await f.keys("gg") - expect(f.captureCharFrame()).not.toContain("Latest reply") - await f.keys("ss") - expect(f.captureCharFrame()).toContain("▎Latest reply") - expect(f.transcript().scrollTop).toBeGreaterThan(0) -}) - -test("latest selection includes reasoning-only messages before the next layout", async () => { - const thinking = message(3, "Still thinking") - thinking.content![0].type = "reasoning" - const f = await mount({}, { messages: [message(1, "Old answer"), message(2, "Newest answer"), thinking] }) - await f.scrollTranscript(0) - f.mockInput.pressKey("s") - f.mockInput.pressEnter() - expect(f.reader().plainText).toBe("Still thinking") - f.mockInput.pressEscape() - await f.renderOnce() - expect(f.captureCharFrame()).toContain("▎Still thinking") - await f.keys("k") - expect(f.captureCharFrame()).toContain("▎Newest answer") - await f.keys("yy") - expect(f.copied.at(-1)).toBe("Newest answer") -}) - -test("moving back down through messages scrolls at the bottom edge without navigation padding", async () => { - const f = await mount({}, { messages: [ - message(1, Array.from({ length: 15 }, (_, index) => `Earlier line ${index}`).join("\n"), "user"), - message(2, "Second reply\nSecond middle\nSecond end"), - message(3, "Third question\nThird middle\nThird end", "user"), - message(4, "Latest reply\nLatest middle\nLatest end"), - ] }) - const scroll = f.transcript() - const height = scroll.scrollHeight - const bottom = scroll.viewport.y + scroll.viewport.height - const initial = scroll.scrollTop - await f.keys("s3k") - expect(f.captureCharFrame()).toContain("▎Earlier line 0") - for (const id of ["2", "3", "4"]) { - await f.keys("j") - const row = scroll.getRenderable(id)! - expect(row.y).toBeGreaterThan(scroll.viewport.y) - expect(row.y + row.height).toBe(bottom) - expect(scroll.scrollHeight).toBe(height) - expect(scroll.getRenderable("session-navigation-slack")).toBeUndefined() - } - expect(scroll.scrollTop).toBe(initial) - expect(f.captureCharFrame()).toContain("Third question") - expect(f.captureCharFrame()).toContain("▎Latest end") - expect(f.dispatched).toEqual([]) - await f.keys("3k") - await f.keys("3j") - expect(scroll.scrollTop).toBe(initial) - await f.keys("gg") - await f.keys("G") - expect(scroll.scrollTop).toBe(initial) - expect(scroll.scrollHeight).toBe(height) - await f.keys("3kj") - expect(f.captureCharFrame()).toContain("▎Second reply") -}) - -test("scrolling down reveals individual text parts at the bottom edge", async () => { - const reply = message(2, "First part\nFirst end") - reply.content!.push({ type: "text", text: "Second part\nSecond end" }) - const f = await mount({}, { messages: [ - message(1, Array.from({ length: 20 }, (_, index) => `Old ${index}`).join("\n"), "user"), reply, - ] }) - await f.keys("sgg") - await f.keys("j") - const scroll = f.transcript() - const first = scroll.getRenderable("2")! - expect(first.y + first.height).toBe(scroll.viewport.y + scroll.viewport.height) - expect(f.captureCharFrame()).toContain("▎First part") - expect(f.captureCharFrame()).not.toContain("▎Second end") - await f.keys("j") - const last = scroll.getRenderable("session-part:2:text:1")! - expect(last.y + last.height).toBe(scroll.viewport.y + scroll.viewport.height) - expect(f.captureCharFrame()).toContain("▎Second end") - expect(scroll.getRenderable("session-navigation-slack")).toBeUndefined() -}) - -test("windowed messages mount through ordinary scrolling and return to the natural bottom", async () => { - const f = await mount({}, { messages: [ - message(1, "Oldest question", "user"), - message(2, Array.from({ length: 20 }, (_, index) => `Older reply ${index}`).join("\n")), - message(3, "Visible question", "user"), - message(4, Array.from({ length: 20 }, (_, index) => `Visible reply ${index}`).join("\n")), - message(5, "Latest reply"), - ], window: { start: 2, end: 4 } }) - await f.keys("s") - expect(f.captureCharFrame()).toContain("▎Latest reply") - expect(f.dispatched).toEqual(["session.line.down"]) - await f.keys("3k") - expect(f.dispatched).toContain("session.line.up") - await f.keys("yy") - expect(f.copied.at(-1)).toContain("Older reply 0") - expect(f.captureCharFrame()).toContain("▎Older reply") - await f.keys("3j") - const scroll = f.transcript() - expect(scroll.getRenderable("5")!.y + 1).toBe(scroll.viewport.y + scroll.viewport.height) - expect(scroll.scrollTop).toBe(scroll.scrollHeight - scroll.viewport.height) - expect(scroll.getRenderable("session-navigation-slack")).toBeUndefined() - expect(f.dispatched.every((command) => command.startsWith("session.line."))).toBe(true) -}) - -test("older-history compensation finishes before selecting a newly loaded message", async () => { - const f = await mount({}, { messages: [message(3, "Current question", "user"), message(4, "Current reply")], older: [ - message(1, Array.from({ length: 20 }, (_, index) => `Old question ${index}`).join("\n"), "user"), - message(2, "Previous reply"), - ] }) - await f.keys("s2k") - expect(f.captureCharFrame()).toContain("▎Previous reply") - await f.keys("yy") - expect(f.copied.at(-1)).toBe("Previous reply") - await f.keys("2j") - const scroll = f.transcript() - expect(scroll.scrollTop).toBe(scroll.scrollHeight - scroll.viewport.height) - expect(scroll.getRenderable("session-navigation-slack")).toBeUndefined() - expect(f.dispatched).toEqual(["session.line.up"]) - await f.keys("gg") - await f.keys("k") - await f.keys("jyy") - expect(f.copied.at(-1)).toBe("Previous reply") -}) diff --git a/test/integration/readers/diff.test.ts b/test/integration/readers/diff.test.ts new file mode 100644 index 0000000..24be9b6 --- /dev/null +++ b/test/integration/readers/diff.test.ts @@ -0,0 +1,128 @@ +import { expect, test } from "bun:test" +import { RGBA, type LineNumberRenderable, type TextRenderable } from "@opentui/core" +import { toolItem, useReaderFixture } from "../../helpers/reader" + +const mount = useReaderFixture() +function change(status = "modified", patch = '--- src/change.ts\n+++ src/change.ts\n@@ -10,3 +10,4 @@\n const keep = 1;\n-const text = "old";\n+const text = "你好 👩‍💻";\n+const extra = 2;\n console.log(text);\n@@ -90,1 +91,1 @@\n-const tail = "old";\n+const tail = "new";\n') { + return toolItem("edit", "Edit src/change.ts", { status: "completed", + input: { path: "src/change.ts", oldString: "stale input", newString: "unformatted input" }, + content: [{ type: "text", text: "Edited src/change.ts" }], + metadata: { files: [{ file: "src/change.ts", status, additions: 3, deletions: 2, patch }] }, + }) +} + +test.each(["after", "before", "diff"] as const)("change views honor diffView=%s", async (diffView) => { + const f = await mount(change(), { diffView }) + const active = f.renderer.root.findDescendantById(`vim-change-view-${diffView}`) as TextRenderable + expect(active.bg).toEqual(RGBA.fromHex("#006699")) + for (const view of ["After", "Before", "Diff"]) expect(f.captureCharFrame()).toContain(view) + expect(f.captureCharFrame()).toContain("v select · tab switch view · s prompt") + expect(f.reader().plainText.includes('const text = "old";')).toBe(diffView !== "after") + expect(f.reader().plainText.includes('const text = "你好 👩‍💻";')).toBe(diffView !== "before") + expect(f.reader().plainText).not.toContain("@@") + expect(f.renderer.currentFocusedEditor).toBe(f.reader()) + const next = diffView === "after" ? "before" : "after" + const tab = f.renderer.root.findDescendantById(`vim-change-view-${next}`)! + await f.mockMouse.click(tab.x, tab.y) + await f.renderOnce() + expect(f.captureCharFrame()).toContain(next === "after" ? "Code after change" : "Code before change") + expect(f.reader().plainText.includes('const text = "old";')).toBe(next === "before") + expect(f.renderer.currentFocusedEditor).toBe(f.reader()) +}) + +test("change views scroll through excerpts, copy without signs and preserve per-view positions", async () => { + const f = await mount(change()) + await f.keys("xjVy") + expect(f.copied.at(-1)).toBe('const text = "你好 👩‍💻";\n') + const afterOffset = f.reader().cursorOffset + f.mockInput.pressTab() + await f.keys("jVy") + expect(f.copied.at(-1)).toBe('const text = "old";\n') + f.mockInput.pressTab() + await f.keys("jVy") + expect(f.copied.at(-1)).toBe('const text = "old";\n') + const gutter = f.renderer.root.findDescendantById("vim-file-lines") as LineNumberRenderable + expect(gutter.getLineColors().content.get(1)).toEqual(RGBA.fromHex("#330000")) + expect(gutter.getLineColors().content.get(2)).toEqual(RGBA.fromHex("#003300")) + expect(f.captureCharFrame()).toMatch(/11\s*-\s*const text/) + expect(f.captureCharFrame()).toMatch(/11\s*\+\s*const text/) + await f.keys("GVy") + expect(f.copied.at(-1)).toBe('const tail = "new";\n') + expect(f.reader().scrollY).toBeGreaterThan(0) + expect(f.captureCharFrame()).toMatch(/90\s*-\s*const tail/) + expect(f.captureCharFrame()).toMatch(/91\s*\+\s*const tail/) + const diffOffset = f.reader().cursorOffset + f.mockInput.pressTab() + await f.renderOnce() + expect(f.reader().cursorOffset).toBe(afterOffset) + expect(f.captureCharFrame()).toMatch(/10\s+const keep/) + await f.keys("ggVGy") + expect(f.copied.at(-1)).toBe('const keep = 1;\nconst text = "你好 👩‍💻";\nconst extra = 2;\nconsole.log(text);\n\nconst tail = "new";\n') + await f.keys("v") + f.mockInput.pressTab() + await f.renderOnce() + expect(f.reader().hasSelection()).toBe(false) + expect(f.captureCharFrame()).not.toContain("VISUAL") + f.mockInput.pressTab() + await f.renderOnce() + expect(f.reader().cursorOffset).toBe(diffOffset) + f.resize(44, 20) + await f.renderOnce() + expect(f.reader().plainText).toContain('const tail = "new";') + await f.keys("s") + expect(f.close).toHaveBeenCalledTimes(1) +}) + +test("change shortcuts can be remapped while Tab passes through", async () => { + const f = await mount(change(), { keymaps: { session: { "": "passthrough", "w": "switch-panel" } } }) + expect(f.captureCharFrame()).toContain("v select · w switch view · s prompt") + expect(f.captureCharFrame()).not.toContain("tab switch view") + const original = f.reader() + let tabs = 0 + f.renderer.keyInput.on("keypress", (event) => { if (event.name === "tab" && !event.defaultPrevented) tabs++ }) + f.mockInput.pressTab() + expect(tabs).toBe(1) + expect(f.reader()).toBe(original) + f.mockInput.pressKey("w", { ctrl: true }) + await f.keys("w") + expect(f.captureCharFrame()).toContain("Code before change") + await f.keys("GVy") + expect(f.copied.at(-1)).toBe('const tail = "old";\n') +}) + +test("change views remain clickable when keyboard cycling is disabled", async () => { + const f = await mount(change(), { keymaps: { session: { "": "passthrough" } } }) + expect(f.captureCharFrame()).toContain("v select · s prompt") + expect(f.captureCharFrame()).not.toContain("switch view") + await f.keys("v") + const tab = f.renderer.root.findDescendantById("vim-change-view-diff")! + await f.mockMouse.click(tab.x, tab.y) + await f.renderOnce() + expect(f.captureCharFrame()).toContain("Changes") + expect(f.reader().plainText).toContain('const text = "old";') + expect(f.reader().plainText).toContain('const text = "你好 👩‍💻";') + expect(f.reader().hasSelection()).toBe(false) + expect(f.renderer.currentFocusedEditor).toBe(f.reader()) +}) + +test.each([ + ["added", "before", "--- /dev/null\n+++ src/change.ts\n@@ -0,0 +1,1 @@\n+added line\n", "After", "added line"], + ["deleted", "after", "--- src/change.ts\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-deleted line\n", "Before", "deleted line"], +] as const)("%s files fall back from the unavailable %s view", async (status, diffView, patch, label, text) => { + const f = await mount(change(status, patch), { diffView }) + expect(f.reader().plainText).toBe(text) + expect(f.captureCharFrame()).toContain(`Code ${label.toLowerCase()} change`) + expect(f.renderer.root.findDescendantById(status === "added" ? "vim-change-view-before" : "vim-change-view-after")).toBeUndefined() + f.mockInput.pressTab() + await f.renderOnce() + expect(f.captureCharFrame()).toContain("Changes") + f.mockInput.pressTab() + await f.renderOnce() + expect(f.captureCharFrame()).toContain(`Code ${label.toLowerCase()} change`) +}) + +test("unrecognized saved changes retain the generic view", async () => { + const f = await mount(change("modified", "no patch available")) + expect(f.reader().plainText).toBe("Edit src/change.ts") + expect(f.renderer.root.findDescendantById("vim-change-view-after")).toBeUndefined() +}) diff --git a/test/integration/readers/read.test.ts b/test/integration/readers/read.test.ts new file mode 100644 index 0000000..4e526a0 --- /dev/null +++ b/test/integration/readers/read.test.ts @@ -0,0 +1,69 @@ +import { expect, spyOn, test } from "bun:test" +import * as OpenTUI from "@opentui/core" +import { toolItem, useReaderFixture } from "../../helpers/reader" + +const mount = useReaderFixture() +function read(result: string, path = "src/example.ts") { + return toolItem("read", "Original read summary", { status: "completed", input: { path }, content: [{ type: "text", text: result }] }) +} + +test.each([[80, 16], [120, 38]])("saved code has scrolling line numbers and copies without the gutter at %ix%i", async (width, height) => { + let finish!: (value: { highlights: [number, number, string][] }) => void + const highlighting = spyOn(OpenTUI.getTreeSitterClient(), "highlightOnce").mockImplementation(() => new Promise((resolve) => { finish = resolve })) + try { + const lines = ['const greeting = "你好 👩‍💻";', `const wrapped = "${"long text ".repeat(15)}";`] + for (let number = 43; number <= 100; number++) lines.push(`const value${number} = ${number};`) + const text = lines.join("\n") + const result = ["Read file src/example.ts, lines 41-100", ...lines.map((line, index) => `${index + 41}: ${line}`), "[Output truncated. Continue reading with offset: 101]"].join("\n") + const f = await mount(read(result), {}, { width, height }) + const input = f.reader() + expect(input.plainText).toBe(text) + expect(f.renderer.currentFocusedEditor).toBe(input) + expect(f.captureCharFrame()).toContain("src/example.ts") + expect(f.captureCharFrame()).toContain("Lines 41–100") + expect(f.captureCharFrame()).toContain("Partial file") + expect(f.captureCharFrame()).toMatch(/41\s+const greeting/) + expect(highlighting).toHaveBeenCalledWith(text, "typescript") + await f.keys("wviw") + const offset = input.cursorOffset + finish({ highlights: [[17, lines[0].length - 1, "string"]] }) + await f.renderOnce() + expect(input.getSelectedText()).toBe("greeting") + expect(input.cursorOffset).toBe(offset) + expect(input.getLineHighlights(0)[0]).toMatchObject({ start: 17, end: Bun.stringWidth(lines[0]) - 1 }) + f.mockInput.pressEscape() + await f.keys("ggVyxG") + expect(f.copied.at(-1)).toBe(lines[0] + "\n") + expect(input.plainText).toBe(text) + expect(input.scrollY).toBeGreaterThan(0) + expect(f.captureCharFrame()).toMatch(/100\s+const value100/) + const cursor = f.renderer.getCursorState() + expect(cursor.visible).toBe(true) + expect(f.captureCharFrame().split("\n")[cursor.y - 1]).toContain("value100") + const remembered = input.cursorOffset + await f.reopen() + expect(f.reader().cursorOffset).toBe(remembered) + f.resize(44, 20) + await f.keys("gg") + expect(f.captureCharFrame()).toMatch(/41\s+const greeting/) + expect(f.reader().plainText).toBe(text) + f.unmount() + finish({ highlights: [[0, 5, "function"]] }) + await f.renderOnce() + expect(f.reader()).toBeUndefined() + } finally { highlighting.mockRestore() } +}) + +test.each(["Read directory src, 0 entries", "Image read successfully", "Custom read result"])("non-file results keep the default reader: %s", async (result) => { + const f = await mount(read(result, "src")) + expect(f.reader().plainText).toBe("Original read summary") + expect(f.renderer.root.findDescendantById("vim-file-lines")).toBeUndefined() +}) + +test.each(["Read file empty.unknown, 0 lines", "Read file empty.unknown, lines 1-1\n1: "])("empty and blank files stay empty when copied: %s", async (result) => { + const f = await mount(read(result, "empty.unknown")) + await f.keys("yy") + expect(f.reader().plainText).toBe("") + expect(f.copied.at(-1)).toBe("\n") + expect(f.captureCharFrame()).toContain(result.endsWith("0 lines") ? "Empty file" : "Lines 1–1") +}) diff --git a/test/integration/readers/shell.test.ts b/test/integration/readers/shell.test.ts new file mode 100644 index 0000000..b7ad289 --- /dev/null +++ b/test/integration/readers/shell.test.ts @@ -0,0 +1,111 @@ +import { expect, spyOn, test } from "bun:test" +import * as OpenTUI from "@opentui/core" +import type { TextareaRenderable } from "@opentui/core" +import { toolItem, useReaderFixture } from "../../helpers/reader" + +const mount = useReaderFixture() +function shell(output = "first output\nsecond output\n", command = "printf output") { + return toolItem("shell", "Shell summary", { status: "completed", input: { command, workdir: "/workspace" }, + content: [{ type: "text", text: output }], metadata: { exit: 0 } }) +} + +test("shell readers separate command and output and preserve read-only Vim controls", async () => { + const f = await mount(shell()) + for (const text of ["Shell output", "Command", "Output", "printf output", "v select · tab switch section · s prompt"]) { + expect(f.captureCharFrame()).toContain(text) + } + const command = f.renderer.currentFocusedEditor as TextareaRenderable + expect(command.id).toBe("vim-shell-command") + expect(command.cursorOffset).toBe(0) + await f.keys("xviwy") + expect(command.plainText).toBe("printf output") + expect(f.copied.at(-1)).toBe("printf") + f.mockInput.pressTab() + await f.renderOnce() + expect(f.renderer.currentFocusedEditor).toBe(f.reader()) + expect(f.reader().plainText).toBe("first output\nsecond output") + await f.keys("xVjy") + expect(f.reader().plainText).toBe("first output\nsecond output") + expect(f.copied.at(-1)).toBe("first output\nsecond output\n") + f.mockInput.pressTab() + await f.renderOnce() + expect(f.renderer.currentFocusedEditor).toBe(command) + await f.keys("v") + expect(command.hasSelection()).toBe(true) + f.reader().focus() + await f.renderOnce() + expect(command.hasSelection()).toBe(false) + await f.keys("s") + expect(f.close).toHaveBeenCalledTimes(1) +}) + +test.each(["", "v", "V"])("session keymaps remap panels and release Tab in reader mode %j", async (mode) => { + const f = await mount(shell("first output"), { keymaps: { session: { "": "passthrough", "w": "switch-panel" } } }) + const command = f.renderer.currentFocusedEditor as TextareaRenderable + expect(f.captureCharFrame()).toContain("v select · w switch section · s prompt") + expect(f.captureCharFrame()).not.toContain("tab switch") + await f.keys(mode) + const selection = command.getSelectedText() + let passed = 0 + f.renderer.keyInput.on("keypress", (event) => { if (event.name === "tab" && !event.defaultPrevented) passed++ }) + f.mockInput.pressTab() + await f.renderOnce() + expect(passed).toBe(1) + expect(f.renderer.currentFocusedEditor).toBe(command) + expect(command.getSelectedText()).toBe(selection) + f.mockInput.pressKey("w", { ctrl: true }) + expect(f.renderer.currentFocusedEditor).toBe(command) + await f.keys("w") + expect(f.renderer.currentFocusedEditor).toBe(f.reader()) + expect(command.hasSelection()).toBe(false) + expect(command.plainText).toBe("printf output") + expect(f.reader().plainText).toBe("first output") + f.mockInput.pressKey("w", { ctrl: true }) + command.focus() + await f.keys("w") + expect(f.renderer.currentFocusedEditor).toBe(command) + expect(command.cursorOffset).toBe(7) + f.mockInput.pressKey("w", { ctrl: true }) + f.mockInput.pressEscape() + expect(f.back).toHaveBeenCalledTimes(1) +}) + +test("shell output keeps deliberate blank lines and trailing spaces", async () => { + const f = await mount(shell("output \n\n")) + expect(f.reader().plainText).toBe("output \n") +}) + +test("shell command highlighting preserves Unicode and an active selection", async () => { + let finish!: (value: { highlights: [number, number, string][] }) => void + const highlighting = spyOn(OpenTUI.getTreeSitterClient(), "highlightOnce").mockImplementation(() => new Promise((resolve) => { finish = resolve })) + try { + const command = "printf '你好' | cat" + const f = await mount(shell("你好\n", command)) + expect(highlighting).toHaveBeenCalledWith(command, "bash") + const input = f.renderer.currentFocusedEditor as TextareaRenderable + await f.keys("viw") + const offset = input.cursorOffset + finish({ highlights: [[0, 6, "function"], [7, 11, "string"], [14, 17, "function"]] }) + await f.renderOnce() + expect(input.plainText).toBe(command) + expect(input.getSelectedText()).toBe("printf") + expect(input.cursorOffset).toBe(offset) + expect(input.getLineHighlights(0)).toEqual(expect.arrayContaining([ + expect.objectContaining({ start: 7, end: 13 }), expect.objectContaining({ start: 16, end: 19 }), + ])) + } finally { highlighting.mockRestore() } +}) + +test("shell diff colors preserve Unicode output, selection and copying", async () => { + const output = "diff --git a/file b/file\n--- a/file\n+++ b/file\n@@ -1 +1 @@\n-hello\n+你好 👩‍💻" + const f = await mount(shell(output, "git diff")) + const input = f.reader() + expect(input.getLineHighlights(4)[0]?.styleId).not.toBe(input.getLineHighlights(5)[0]?.styleId) + expect(input.getLineHighlights(5)[0]?.end).toBe(Bun.stringWidth("+你好 👩‍💻")) + f.mockInput.pressTab() + await f.keys("GVy") + expect(f.copied.at(-1)).toBe("+你好 👩‍💻\n") + expect(input.plainText).toBe(output) + f.mockInput.pressEscape() + expect(f.back).toHaveBeenCalledTimes(1) +}) diff --git a/test/integration/readers/text.test.ts b/test/integration/readers/text.test.ts new file mode 100644 index 0000000..fe6db1b --- /dev/null +++ b/test/integration/readers/text.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from "bun:test" +import { textItem, useReaderFixture } from "../../helpers/reader" + +const mount = useReaderFixture() + +test("reader selects and copies characters and lines, and remembers its cursor", async () => { + const f = await mount(textItem("one two\nsecond line")) + expect(f.setDialog).toHaveBeenCalledWith({ size: "large", centered: true }) + expect(f.reader().showCursor).toBe(true) + await f.keys("wvll") + expect(f.reader().getSelectedText()).toBe("two") + expect(f.captureCharFrame()).toContain("VISUAL") + await f.keys("y") + expect(f.copied).toEqual(["two"]) + await f.reopen() + expect(f.reader().cursorOffset).toBe(4) + await f.keys("Vjy") + expect(f.copied.at(-1)).toBe("one two\nsecond line\n") +}) + +test("linewise copies preserve Unicode and append exactly one newline", async () => { + const text = "quoted 中 👩‍💻\nsecond line" + const f = await mount(textItem(text)) + await f.keys("Vjy") + expect(f.copied).toEqual([text + "\n"]) +}) + +test("reader blocks edits, custom editing maps and bracketed paste", async () => { + const text = "six words here\nmore words" + const f = await mount(textItem(text), { keymaps: { normal: { j: "dd", Q: "insert" } } }) + await f.keys("iaAoOdDcxpru.Q") + await f.mockInput.pasteBracketedText("MUTATION") + expect(f.reader().plainText).toBe(text) + expect(f.captureCharFrame()).toContain("v select · s prompt") + await f.keys("0fs") + expect(f.close).not.toHaveBeenCalled() + await f.keys("0yiw") + expect(f.copied.at(-1)).toBe("six") + await f.keys("j") + expect(f.reader().cursorOffset).toBeGreaterThan(0) + expect(f.reader().plainText).toBe(text) + await f.keys("s") + expect(f.close).toHaveBeenCalledTimes(1) +}) + +test("session Tab passthrough hides panel hints and keeps the reader read-only", async () => { + const f = await mount(textItem("one two"), { keymaps: { session: { "": "passthrough" } } }) + expect(f.captureCharFrame()).toContain("v select · s prompt") + expect(f.captureCharFrame()).not.toContain("switch") + let passed = 0 + f.renderer.keyInput.on("keypress", (event) => { if (event.name === "tab" && !event.defaultPrevented) passed++ }) + f.mockInput.pressTab() + await f.keys("xw") + expect(passed).toBe(1) + expect(f.reader().plainText).toBe("one two") + expect(f.reader().cursorOffset).toBe(4) +}) + +test("session passthrough ignores prompt mappings and preserves cursor and text", async () => { + const f = await mount(textItem("first message\nlast message"), { keymaps: { + normal: { j: "x" }, session: { j: "passthrough", "": "passthrough", "w": "switch-panel" }, + } }) + const passed: string[] = [] + f.renderer.keyInput.on("keypress", (event) => { if (!event.defaultPrevented) passed.push(event.ctrl ? `ctrl+${event.name}` : event.name) }) + f.mockInput.pressTab() + f.mockInput.pressKey("w", { ctrl: true }) + await f.keys("j") + expect(passed).toEqual(["tab", "ctrl+w", "j"]) + expect(f.reader().cursorOffset).toBe(0) + expect(f.reader().plainText).toBe("first message\nlast message") +}) + +test("custom session keys respect pending motions and visual selections", async () => { + const f = await mount(textItem("one q answer"), { sessionKey: "q" }) + expect(f.captureCharFrame()).toContain("v select · q prompt") + await f.keys("sfq") + expect(f.reader().cursorOffset).toBe(4) + expect(f.close).not.toHaveBeenCalled() + await f.keys("vq") + expect(f.close).not.toHaveBeenCalled() + expect(f.captureCharFrame()).toContain("VISUAL") + f.mockInput.pressEscape() + await f.keys("q") + expect(f.close).toHaveBeenCalledTimes(1) +}) + +test.each(["s", "c"])("Ctrl+%s closes the reader unless a host leader is pending", async (key) => { + const f = await mount(textItem("Answer"), { sessionKey: `` }) + expect(f.captureCharFrame()).toContain(` prompt`) + f.setPending([{ key: "ctrl+x" }]) + f.mockInput.pressKey(key, { ctrl: true }) + expect(f.close).not.toHaveBeenCalled() + f.setPending([]) + f.mockInput.pressKey(key, { ctrl: true }) + expect(f.close).toHaveBeenCalledTimes(1) +}) + +test("streaming props leave the active snapshot and selection stable until reopened", async () => { + const f = await mount(textItem("hello")) + await f.keys("vll") + f.setItem(textItem("hello world")) + await f.renderOnce() + expect(f.reader().plainText).toBe("hello") + expect(f.reader().getSelectedText()).toBe("hel") + f.mockInput.pressEscape() + await f.renderOnce() + expect(f.reader().showCursor).toBe(true) + expect(f.reader().hasSelection()).toBe(false) + await f.reopen() + expect(f.reader().plainText).toBe("hello world") +}) + +test("paging and resizing retain text and the remembered position", async () => { + const text = Array.from({ length: 100 }, (_, index) => `line ${index + 1}`).join("\n") + const f = await mount(textItem(text)) + f.mockInput.pressKey("d", { ctrl: true }) + f.mockInput.pressKey("f", { ctrl: true }) + await f.renderOnce() + expect(f.reader().scrollY).toBeGreaterThan(0) + expect(f.reader().height).toBeLessThan(f.renderer.height / 2) + f.resize(44, 20) + await f.renderOnce() + expect(f.reader().plainText).toBe(text) + const offset = f.reader().cursorOffset + await f.reopen() + expect(f.reader().cursorOffset).toBe(offset) + f.mockInput.pressKey("u", { ctrl: true }) + expect(f.reader().cursorOffset).toBeLessThan(offset) +}) + +test("Escape cancels selection before returning; close button and Ctrl+C request back", async () => { + const f = await mount(textItem("one two three")) + await f.keys("wv") + f.mockInput.pressEscape() + expect(f.back).not.toHaveBeenCalled() + f.mockInput.pressEscape() + expect(f.back).toHaveBeenCalledTimes(1) + const close = f.renderer.root.findDescendantById("vim-message-close")! + await f.mockMouse.click(close.x, close.y) + expect(f.back).toHaveBeenCalledTimes(2) + f.mockInput.pressKey("c", { ctrl: true }) + expect(f.back).toHaveBeenCalledTimes(3) + const listeners = f.renderer.keyInput.listenerCount("keypress") + f.unmount() + await f.renderOnce() + expect(f.reader()).toBeUndefined() + expect(f.renderer.keyInput.listenerCount("keypress")).toBe(listeners - 1) + f.mockInput.pressKey("c", { ctrl: true }) + expect(f.back).toHaveBeenCalledTimes(3) +}) diff --git a/test/integration/runtime.test.ts b/test/integration/runtime.test.ts deleted file mode 100644 index 690e30e..0000000 --- a/test/integration/runtime.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { expect, test } from "bun:test" -import { cp, mkdir, mkdtemp, rm, symlink } from "node:fs/promises" -import path from "node:path" - -test.each(["source", "npm"])("%s plugin shares the host's runtime", async (kind) => { - const root = path.resolve(import.meta.dir, "../..") - await mkdir("/tmp/opencode", { recursive: true }) - const directory = await mkdtemp("/tmp/opencode/vim-runtime-") - try { - let plugin = directory - let entrypoint = path.join(plugin, "tui.tsx") - if (kind === "npm") { - // Exercise the actual published files, including prepack, from a real - // node_modules path. A source copy outside it uses a different loader. - await run(["npm", "pack", "--pack-destination", directory], root) - const { name, version } = await Bun.file(path.join(root, "package.json")).json() - plugin = path.join(directory, "node_modules", name) - await mkdir(plugin, { recursive: true }) - await run(["tar", "-xzf", path.join(directory, `${name}-${version}.tgz`), "--strip-components=1", "-C", plugin], root) - const manifest = await Bun.file(path.join(plugin, "package.json")).json() - entrypoint = path.resolve(plugin, manifest.exports["./tui"]) - } else { - for (const file of ["package.json", "tui.tsx", "view.tsx", "src"]) { - await cp(path.join(root, file), path.join(plugin, file), { recursive: true }) - } - } - // A plugin can have its own Solid copy. It must still share the host's - // signals, rendering, classes and cleanup through the runtime loader. - const modules = path.join(plugin, "node_modules") - await mkdir(modules) - await cp(path.join(root, "node_modules/solid-js"), path.join(modules, "solid-js"), { recursive: true }) - await symlink(path.join(root, "node_modules/@vimee"), path.join(modules, "@vimee")) - await symlink(path.join(root, "node_modules/diff"), path.join(modules, "diff")) - - // A fresh process installs OpenCode's runtime loader before importing the - // plugin; it must not reuse modules preloaded by the ordinary test suite. - await run([ - process.execPath, "test", "--conditions=browser", "--preload", "@opentui/solid/preload", "test/integration/plugin.test.tsx", - ], root, { OPENCODE_VIM_TEST_ENTRYPOINT: entrypoint }) - } finally { - await rm(directory, { recursive: true, force: true }) - } -}, 30000) - -async function run(command: string[], cwd: string, env: Record = {}) { - const child = Bun.spawn(command, { - cwd, - env: { ...process.env, ...env }, - stdout: "pipe", - stderr: "pipe", - }) - const [code, stdout, stderr] = await Promise.all([ - child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), - ]) - expect(code, stdout + stderr).toBe(0) -} diff --git a/test/integration/transcript.test.ts b/test/integration/transcript.test.ts deleted file mode 100644 index bc30f51..0000000 --- a/test/integration/transcript.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { expect, test } from "bun:test" -import type { Context } from "@opencode/plugin/tui/context" -import { BoxRenderable, ScrollBoxRenderable, TextRenderable, type Renderable } from "@opentui/core" -import { createTestRenderer } from "@opentui/core/testing" -import { transcriptItems, type TranscriptSource } from "../../src/transcript-items" -import { createTranscriptSelection } from "../../src/transcript" - -test("anonymous shell completion rows retain identity across footers, hidden messages and truncated labels", async () => { - const screen = await createTestRenderer({ width: 80, height: 20 }) - const previous: Extract = { - id: "msg_previous", type: "assistant", agent: "build", model: { providerID: "test", id: "test" }, - time: { created: 1, completed: 2 }, finish: "stop", content: [{ type: "text", text: "Previous response" }], - } - const notices: Extract[] = [] - for (const state of ["completed", "error", "cancelled"]) { - notices.push({ type: "synthetic", id: `msg_${state}`, time: { created: 3 }, - description: `printf '${state}'\nprintf 'another line'`, text: `Saved ${state} output`, - metadata: { source: "shell", shellID: `sh_${state}`, state } }) - } - const hidden = { ...notices[0], id: "msg_hidden", description: undefined } - const messages = [previous, hidden, ...notices] - function row(parent: Renderable, content: string, id?: string) { - const node = new BoxRenderable(screen.renderer, { id, flexShrink: 0 }) - const body = new BoxRenderable(screen.renderer, {}) - body.add(new TextRenderable(screen.renderer, { content })) - node.add(body) - parent.add(node) - return node - } - try { - const scroll = new ScrollBoxRenderable(screen.renderer, { width: 80, height: 20 }) - screen.renderer.root.add(scroll) - const answer = row(scroll, "Previous response", previous.id) - const footer = row(scroll, "Build · test · 1s") - const usage = row(scroll, "Tokens: 1 step") - const finished = row(scroll, "↳ Shell finished · printf 'completed' printf 'another line'") - const failed = row(scroll, "! Shell failed · printf 'error' printf 'another line'") - const cancelled = row(scroll, "! Shell cancelled · printf 'cancelled'…") - await screen.renderOnce() - - const rows = scroll.getChildren() - const result = transcriptItems(rows, messages) - expect(result.ranges.map((range) => range.id)).toEqual([`session-part:${previous.id}:text:0`, ...notices.map((notice) => notice.id)]) - expect(result.hasLatest).toBe(true) - for (const [index, node] of [finished, failed, cancelled].entries()) { - const range = result.ranges[index + 1] - expect(range.source).toBe(notices[index]) - expect(range.text).toBe(notices[index].text) - expect(range.node).toBe(node) - expect(range.top).toBe(node.y) - expect(range.bottom).toBe(node.y + node.height) - } - expect(transcriptItems(rows.slice(0, -1), messages).hasLatest).toBe(false) - - // A window containing only completion notices still identifies the transcript scrollbox. - answer.destroyRecursively() - footer.destroyRecursively() - usage.destroyRecursively() - await screen.renderOnce() - let selected: string | undefined - const context = { renderer: screen.renderer, data: { session: { - permission: { list: () => [] }, message: { list: () => messages }, - } }, keymap: { dispatch: () => { throw new Error("All notices are already mounted") } } } as unknown as Context - const selection = createTranscriptSelection(context, "session", (id) => { selected = id }) - selection.latest() - expect(selected).toBe(notices[2].id) - selection.move("previous", 2) - expect(selection.get(selected)?.source).toBe(notices[0]) - selection.move("next", 1) - expect(selection.get(selected)?.source).toBe(notices[1]) - } finally { - screen.renderer.destroy() - } -}) - -for (const expanded of [false, true]) { - test(`permission-blocked tools retain source identity in ${expanded ? "expanded" : "collapsed"} groups`, async () => { - const screen = await createTestRenderer({ width: 80, height: 12 }) - const blocked: Extract = { - type: "tool", id: "blocked", name: "shell", time: { created: 1 }, - state: { status: "running", input: { command: "blocked command" }, metadata: {} }, - } - const ordinary: typeof blocked = { - ...blocked, id: "ordinary", - state: { status: "completed", input: { command: "ordinary command" }, content: [{ type: "text", text: "done" }] }, - } - const message: Extract = { - type: "assistant", id: "msg_tools", agent: "build", model: { providerID: "test", id: "test" }, - time: { created: 1 }, content: [blocked, ordinary], - } - function box(parent: BoxRenderable) { - const node = new BoxRenderable(screen.renderer, {}) - parent.add(node) - return node - } - function text(parent: BoxRenderable, content: string) { - parent.add(new TextRenderable(screen.renderer, { content })) - } - try { - const row = new BoxRenderable(screen.renderer, { id: message.id }) - screen.renderer.root.add(row) - const group = box(row) - text(box(group), "⋯ 2 commands") - if (expanded) text(box(box(group)), "ordinary command") - text(box(group), "blocked command") - await screen.renderOnce() - - const { ranges } = transcriptItems([row], [message], new Set([blocked.id])) - expect(ranges[0].source).toBeUndefined() - expect(ranges.find((range) => range.text === "blocked command")?.source).toBe(blocked) - const shown = ranges.find((range) => range.text === "ordinary command") - if (expanded) expect(shown?.source).toBe(ordinary) - else expect(shown).toBeUndefined() - - const shell: Extract = { - type: "shell", id: "msg_later", shellID: "sh_later", command: "pwd", status: "exited", time: { created: 2 }, - } - expect(transcriptItems([row], [message, shell], new Set([blocked.id])).hasLatest).toBe(false) - } finally { - screen.renderer.destroy() - } - }) -} - -for (const grouped of [false, true]) { - test(`patch files have separate stable ranges ${grouped ? "inside activity groups" : "between other tool calls"}`, async () => { - const screen = await createTestRenderer({ width: 80, height: 40 }) - const patch: Extract = { - type: "tool", id: "patch-files", name: "patch", time: { created: 1 }, - state: { status: "completed", input: { patchText: "" }, content: [{ type: "text", text: "Patched files" }] }, - } - const edit = { ...patch, id: "edit-file", name: "edit" } - const message: Extract = { - type: "assistant", id: "msg_changes", agent: "build", model: { providerID: "test", id: "test" }, - time: { created: 1 }, content: [patch, edit], - } - function box(parent: Renderable, id?: string) { - const node = new BoxRenderable(screen.renderer, { id }) - parent.add(node) - return node - } - function text(parent: BoxRenderable, content: string) { - parent.add(new TextRenderable(screen.renderer, { content })) - } - function file(parent: BoxRenderable, label: string, path: string) { - const node = box(parent) - const header = box(node) - text(header, label) - text(header, path) - text(node, `${path} first hunk`) - text(node, `${path} second hunk`) - return node - } - try { - const row = box(screen.renderer.root, message.id) - let entries = row - let group: BoxRenderable | undefined - if (grouped) { - group = box(row, "changes-group") - text(box(group), "⋯ 2 edits") - entries = box(group) - } - const patchRoot = box(box(entries)) - const first = file(patchRoot, "← Patched", "first.ts") - const added = file(patchRoot, "# Created", "added.ts") - const deleted = file(patchRoot, "# Deleted", "deleted.ts") - const editRow = grouped ? box(entries) : box(screen.renderer.root, `session-part:${message.id}:${edit.id}`) - file(editRow, "← Edit", "edited.ts") - const rows = grouped ? [row] : [row, editRow] - await screen.renderOnce() - - const result = transcriptItems(rows, [message]) - const ranges = result.ranges.filter((range) => !range.toggle) - expect(ranges).toHaveLength(4) - expect(result.hasLatest).toBe(true) - for (const [index, node] of [first, added, deleted].entries()) { - const range = ranges[index] - expect(range.source).toBe(patch) - expect(range.fileIndex).toBe(index) - expect(range.node).toBe(node) - expect(range.top).toBe(node.y) - expect(range.bottom).toBe(node.y + node.height) - expect(range.parentID).toBe(group?.id) - expect(range.text).toContain("second hunk") - const name = ["first.ts", "added.ts", "deleted.ts"][index] - expect(range.text).toContain(name) - for (const other of ["first.ts", "added.ts", "deleted.ts", "edited.ts"]) { - if (other !== name) expect(range.text).not.toContain(other) - } - } - expect(ranges[0].bottom).toBeLessThanOrEqual(ranges[1].top) - expect(ranges[1].bottom).toBeLessThanOrEqual(ranges[2].top) - expect(ranges[3].source).toBe(edit) - expect(ranges[3].text).toContain("edited.ts") - expect(new Set(ranges.map((range) => range.id)).size).toBe(4) - await screen.renderOnce() - expect(transcriptItems(rows, [message]).ranges.map((range) => range.id)).toEqual(result.ranges.map((range) => range.id)) - - first.visible = false - added.visible = false - deleted.visible = false - text(patchRoot, "Patching") - await screen.renderOnce() - const fallback = transcriptItems(rows, [message]).ranges.find((range) => range.source === patch) - expect(fallback?.id).toBe(`session-part:${message.id}:${patch.id}`) - expect(fallback?.text).toBe("Patching") - } finally { - screen.renderer.destroy() - } - }) -} diff --git a/test/unit/edit.test.ts b/test/unit/edit.test.ts index 0a983b8..2453ed6 100644 --- a/test/unit/edit.test.ts +++ b/test/unit/edit.test.ts @@ -5,7 +5,7 @@ describe("editInput", () => { test("inserts without replacing unchanged placeholders", () => { const fixture = createFixture("[Image 1] after") - editInput(fixture.input, "[Image 1] kafter") + editInput(fixture.input, "[Image 1] kafter", "unicode") expect(fixture.calls).toEqual([["cursor", 10], ["insert", "k"]]) }) @@ -13,7 +13,7 @@ describe("editInput", () => { test("uses display offsets for a minimal deletion", () => { const fixture = createFixture("中 [Image 1] abc") - editInput(fixture.input, "中 [Image 1] ac") + editInput(fixture.input, "中 [Image 1] ac", "unicode") expect(fixture.calls).toEqual([["selection", 14, 15], ["insert", ""]]) }) @@ -21,11 +21,21 @@ describe("editInput", () => { test("does not split graphemes", () => { const fixture = createFixture("a👩‍💻b") - editInput(fixture.input, "axb") + editInput(fixture.input, "axb", "unicode") expect(fixture.calls).toEqual([["selection", 1, 3], ["insert", "x"]]) }) + test("uses the terminal width method when deleting or inserting after a joined emoji", () => { + const deletion = createFixture("a👩‍💻b") + editInput(deletion.input, "ab", "wcwidth") + expect(deletion.calls).toEqual([["selection", 1, 5], ["insert", ""]]) + + const insertion = createFixture("a👩‍💻b") + editInput(insertion.input, "a👩‍💻xb", "wcwidth") + expect(insertion.calls).toEqual([["cursor", 5], ["insert", "x"]]) + }) + for (const [before, after, start, end, inserted] of [ ["a👩‍💻b", "a👩‍🔬b", 1, 3, "👩‍🔬"], ["a\u0301x", "a\u0300x", 0, 1, "a\u0300"], @@ -35,7 +45,7 @@ describe("editInput", () => { ] as const) { test(`expands shared text to whole graphemes: ${before} → ${after}`, () => { const fixture = createFixture(before) - editInput(fixture.input, after) + editInput(fixture.input, after, "unicode") expect(fixture.calls).toEqual([["selection", start, end], ["insert", inserted]]) }) } diff --git a/test/unit/session-keymaps.test.ts b/test/unit/session-keymaps.test.ts index 0747a2f..1c5b046 100644 --- a/test/unit/session-keymaps.test.ts +++ b/test/unit/session-keymaps.test.ts @@ -2,6 +2,11 @@ import { expect, spyOn, test } from "bun:test" import { createVimConfig } from "../../src/modules/vim/config" import { createSessionKeymaps } from "../../src/session-keys" +test("session toggle accepts a single normal key or control chord, not a sequence", () => { + for (const key of ["q", "", ""]) expect(createVimConfig({ sessionKey: key }).sessionKey).toBe(key) + expect(createVimConfig({ sessionKey: "gs" }).sessionKey).toBe("s") +}) + test("session bindings replace defaults without changing prompt mappings", () => { const config = createVimConfig({ keymaps: { normal: { "": "x" }, diff --git a/test/unit/vimee.test.ts b/test/unit/vimee.test.ts index 3a6bcda..523c43b 100644 --- a/test/unit/vimee.test.ts +++ b/test/unit/vimee.test.ts @@ -7,32 +7,6 @@ import { createVimState, type VimMode } from "../../src/modules/vim/state" import { createVimeeAdapter } from "../../src/modules/vim/vimee" describe("vim command keymaps", () => { - test("does not restore stale cursor state after a normal command", () => { - const fixture = createFixture("normal", "command:test.run") - fixture.dispatch = () => { - fixture.input.plainText = "updated" - fixture.input.cursorOffset = 7 - } - - expect(fixture.handle()).toBe(true) - expect(fixture.commands).toEqual(["test.run"]) - expect(fixture.input.cursorOffset).toBe(7) - }) - - test("uses the host history boundary and restores display width", () => { - const fixture = createFixture("normal", "command:prompt.history.next", "中") - let boundary = -1 - fixture.dispatch = () => { - boundary = fixture.input.cursorOffset - fixture.input.plainText = "中中" - fixture.input.cursorOffset = 2 - } - - expect(fixture.handle()).toBe(true) - expect(boundary).toBe(1) - expect(fixture.input.cursorOffset).toBe(4) - }) - test("dispatches commands in insert mode", () => { const fixture = createFixture("insert", "command:test.run") @@ -110,24 +84,6 @@ describe("vim enter keymaps", () => { }) describe("vim prompt history", () => { - test("starts from an empty prompt and continues through history", () => { - const fixture = createFixture("normal", undefined, "") - const entries = ["newest", "older", ""] - fixture.dispatch = (command) => { - fixture.input.plainText = entries.shift() ?? "" - fixture.input.cursorOffset = command === "prompt.history.previous" ? 0 : fixture.input.plainText.length - } - - expect(fixture.handle("k")).toBe(true) - expect(fixture.handle("k")).toBe(true) - expect(fixture.handle("j")).toBe(true) - expect(fixture.commands).toEqual([ - "prompt.history.previous", - "prompt.history.previous", - "prompt.history.next", - ]) - }) - test("keeps normal movement for a nonempty prompt", () => { const fixture = createFixture("normal", undefined, "text") @@ -136,18 +92,6 @@ describe("vim prompt history", () => { expect(fixture.commands).toEqual([]) }) - test("stops history navigation after another normal key", () => { - const fixture = createFixture("normal", undefined, "") - fixture.dispatch = () => { - fixture.input.plainText = "recalled" - } - - expect(fixture.handle("k")).toBe(true) - expect(fixture.handle("h")).toBe(true) - expect(fixture.handle("k")).toBe(true) - expect(fixture.commands).toEqual(["prompt.history.previous"]) - }) - test("prefers configured keymaps", () => { const fixture = createFixture("normal", "command:test.run", "", () => {}, "k") @@ -185,7 +129,6 @@ function createFixture(mode: VimMode, action: string | undefined, text = "text", input, commands, submissions, - dispatch: (_command: string) => {}, handle: (key = mappedKey) => adapter.handle({ name: key } as KeyEvent, key, ctx), } const ctx = { @@ -194,7 +137,6 @@ function createFixture(mode: VimMode, action: string | undefined, text = "text", keymap: { dispatchCommand(command: string) { commands.push(command) - fixture.dispatch(command) return { ok: true as const } }, }, diff --git a/tui.tsx b/tui.tsx index 93f68fd..fa00653 100644 --- a/tui.tsx +++ b/tui.tsx @@ -1,343 +1 @@ -/** @jsxImportSource @opentui/solid */ -import { Plugin } from "@opencode/plugin/tui" -import { InputRenderable, KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core" -import { createEffect, createSignal, onCleanup, untrack } from "solid-js" -import { applyVimCursorStyle, focusedInput } from "./src/modules/vim/actions" -import { createVimConfig } from "./src/modules/vim/config" -import { editInput } from "./src/modules/vim/edit" -import { keyNotation } from "./src/modules/vim/keys" -import { createVimLog } from "./src/modules/vim/log" -import { displayToChar } from "./src/modules/vim/map" -import { createVimState } from "./src/modules/vim/state" -import { createVimeeAdapter } from "./src/modules/vim/vimee" -import { VimStatus } from "./view" -import { SESSION_MODE, createSessionMode } from "./src/session" -import { createVimClipboard } from "./src/clipboard" - -type Context = Parameters[0]["setup"]>[0] - -export default Plugin.define({ - id: "opencode-vim", - setup(context) { - context.ui.slot({ append: "app", render: () => }) - }, -}) - -function VimHost(props: { context: Context }) { - const config = createVimConfig(props.context.options) - const normalMappings = Object.keys(config.keymaps.normal ?? {}) - const log = createVimLog(config) - const clipboard = createVimClipboard(props.context.renderer) - const clipboardOptions = { onYank: (text: string) => { void clipboard.write(text) }, readClipboard: clipboard.read } - const state = createVimState(config.defaultMode, log) - const vimee = createVimeeAdapter(state, config, log, clipboardOptions) - const dialogState = createVimState(config.defaultMode, log) - const dialogVimee = createVimeeAdapter(dialogState, config, log, clipboardOptions) - const [dialogFocused, setDialogFocused] = createSignal(false) - const promptVim = { state, vimee } - const dialogVim = { state: dialogState, vimee: dialogVimee } - let dialogInput: typeof props.context.renderer.currentFocusedEditor = null - const [saved, setSaved] = props.context.storage.store("state", { initial: { enabled: true } }) - const enabled = () => saved.enabled - const ctx = createCompatContext(props.context) - let cursorMode = "" - let cursorInput: typeof props.context.renderer.currentFocusedEditor = null - let originalCursorStyle: CursorStyleOptions | undefined - const session = createSessionMode(props.context, config, clipboard) - let pendingKeys: Array | undefined - - const removeStatus = props.context.ui.slot({ - prepend: "prompt.footer", - render: (footer) => - footer.mode === "normal" ? session.active() && !dialogFocused() ? : ( - dialogFocused() ? dialogState.mode() : state.mode()} - enabled={enabled} - theme={compatTheme(props.context)} - /> - ) : null, - }) - - props.context.keymap.layer(() => ({ - mode: "global", - commands: [ - { - id: "opencode-vim.toggle", - title: "Toggle Vim Mode", - description: "Enable or disable Vim key handling", - group: "Vim", - palette: true, - slash: { name: "vim" }, - run() { - const next = !enabled() - void setSaved((draft) => { - draft.enabled = next - }) - if (!next) { - pendingKeys = undefined - session.close() - vimee.suspend() - dialogVimee.suspend() - } - props.context.ui.toast.show({ message: `Vim mode ${next ? "enabled" : "disabled"}`, variant: "info" }) - props.context.renderer.requestRender() - }, - }, - ], - })) - - const onKey = (event: KeyEvent) => { - if (!enabled() || event.defaultPrevented) return - if (pendingKeys) { - pendingKeys.push(new KeyEvent(event)) - event.preventDefault() - event.stopPropagation() - return - } - if (props.context.keymap.mode.current() === SESSION_MODE) return - const kind = inputKind(props.context) - if (!kind) return - if (props.context.keymap.pending().length) return - const { state, vimee } = kind === "dialog" ? dialogVim : promptVim - const key = keyNotation(event as never) - if (!key) return - const mapped = normalMappings.some((sequence) => sequence.startsWith(key)) - if (kind === "prompt" && key === config.sessionKey && !mapped && state.mode() === "normal" && !vimee.isPending()) { - if (session.enter()) { - event.preventDefault() - event.stopPropagation() - return - } - } - if (passThroughKey(event, key, state.mode(), vimee.isPending(), mapped)) return - if (key === "" && state.mode() === "normal" && !vimee.isPending()) return - - if (kind === "dialog" && !vimee.isPending() && !mapped && state.mode() === "normal") { - if (key === "" || key === "" || key === "" || key === "" || key === "") return - const command = key === "j" ? "dialog.select.next" : key === "k" ? "dialog.select.prev" : undefined - if (command && props.context.keymap.commands().some((item) => item.id === command)) { - event.preventDefault() - event.stopPropagation() - props.context.keymap.dispatch(command) - return - } - } - - if (kind === "prompt" && !vimee.isPending() && sendCompletionKey(event, props.context, key, state.mode())) { - syncCursor(true) - return - } - - const before = state.mode() - let consumed = false - try { - const result = vimee.handle(event as never, key, ctx as never) - if (typeof result === "boolean") consumed = result - else { - consumed = true - const queued: Array = [] - pendingKeys = queued - const input = props.context.renderer.currentFocusedEditor - void result.then((handled) => { - if (pendingKeys !== queued) return - pendingKeys = undefined - if (!handled) return - syncCursor(true) - // Replay through the host too: a queued i may be followed by native typing. - for (const event of queued) { - if (!enabled() || props.context.renderer.currentFocusedEditor !== input) break - if (event instanceof PasteEvent) props.context.renderer.keyInput.emit("paste", event) - else props.context.renderer.keyInput.emit("keypress", event) - } - }).catch((error) => { - if (pendingKeys === queued) pendingKeys = undefined - log("vim.clipboard.paste.error", { error: String(error) }) - }) - } - } finally { - if (consumed || state.mode() !== before) { - event.preventDefault() - event.stopPropagation() - } - } - if (consumed) syncCursor(true) - } - - // Focus events can fire inside the host's prompt effects. Do not subscribe - // those effects to Vim state or the keymap registry while syncing the editor. - const onFocus = () => untrack(() => { - pendingKeys = undefined - vimee.suspend() - dialogVimee.suspend() - syncCursor() - }) - const onPaste = (event: PasteEvent) => { - if (!pendingKeys || event.defaultPrevented) return - pendingKeys.push(new PasteEvent(event.bytes, event.metadata)) - event.preventDefault() - event.stopPropagation() - } - props.context.renderer.keyInput.prependListener("keypress", onKey) - props.context.renderer.keyInput.prependListener("paste", onPaste) - props.context.renderer.on("focused_editor", onFocus) - createEffect(() => syncCursor()) - let route = props.context.ui.router.current() - createEffect(() => { - const next = props.context.ui.router.current() - if (next !== route && pendingKeys) onFocus() - route = next - }) - onCleanup(() => { - pendingKeys = undefined - removeStatus() - session.close() - props.context.renderer.keyInput.off("keypress", onKey) - props.context.renderer.keyInput.off("paste", onPaste) - props.context.renderer.off("focused_editor", onFocus) - vimee.cleanup() - dialogVimee.cleanup() - void clipboard.dispose() - restoreCursor() - }) - - return null - - function syncCursor(force = false) { - const input = props.context.renderer.currentFocusedEditor - const kind = inputKind(props.context) - setDialogFocused(kind === "dialog") - if (!enabled() || !kind || !input) { - restoreCursor() - return - } - const { state, vimee } = kind === "dialog" ? dialogVim : promptVim - if (kind === "dialog" && dialogInput !== input) { - dialogInput = input - dialogVimee.suspend() - dialogState.setMode(promptVim.state.mode()) - } - const mode = state.mode() - vimee.attach(ctx) - const inputChanged = cursorInput !== input - if (inputChanged) { - restoreCursor() - cursorInput = input - originalCursorStyle = input.cursorStyle - } - if (!force && !inputChanged && cursorMode === mode) return - if (applyVimCursorStyle(ctx as never, config.cursorStyles[mode])) { - cursorMode = mode - props.context.renderer.requestRender() - } - } - - function restoreCursor() { - if (cursorInput && !cursorInput.isDestroyed && originalCursorStyle) cursorInput.cursorStyle = originalCursorStyle - cursorInput = null - cursorMode = "" - } -} - -function createCompatContext(context: Context) { - const prompt = { - get current() { - return { input: focusedInputValue(context), mode: "normal", parts: [] } - }, - get focused() { - return inputKind(context) !== undefined - }, - set(value: { input: string }) { - const input = context.renderer.currentFocusedEditor - if (input instanceof InputRenderable) input.value = value.input - else if (input) editInput(input, value.input) - }, - submit() { - if (inputKind(context) === "dialog") { - const command = context.keymap.commands().some((item) => item.id === "dialog.select.submit") - ? "dialog.select.submit" - : "dialog.prompt.submit" - context.keymap.dispatch(command) - } else { - context.keymap.dispatch("prompt.submit") - } - }, - blur() { - context.renderer.currentFocusedEditor?.blur() - }, - } - - return { - api: { - renderer: context.renderer, - keymap: { - dispatchCommand(command: string) { - const ok = context.keymap.commands().some((item) => item.id === command) - if (ok) context.keymap.dispatch(command) - return { ok } - }, - }, - theme: { current: compatTheme(context) }, - }, - kind: context.ui.router.current().type === "session" ? "session" : "home", - prompt: () => (inputKind(context) ? prompt : undefined), - requestRender: () => context.renderer.requestRender(), - } -} - -function inputKind(context: Context): "prompt" | "dialog" | undefined { - if (context.keymap.mode.current() === SESSION_MODE) return - if (!context.renderer.currentFocusedEditor) return - const commands = context.keymap.commands() - if (context.keymap.mode.current() === "modal") { - if (commands.some((item) => item.id === "dialog.select.submit" || item.id === "dialog.prompt.submit")) return "dialog" - return - } - if (commands.some( - (item) => - item.id === "prompt.submit" || item.id === "prompt.autocomplete.next" || item.id === "prompt.history.previous", - )) return "prompt" -} - -function focusedInputValue(context: Context) { - return context.renderer.currentFocusedEditor?.plainText ?? "" -} - -function compatTheme(context: Context) { - return { - get background() { return context.theme.background.base }, - get info() { return context.theme.text.feedback.info.base }, - get success() { return context.theme.text.feedback.success.base }, - get warning() { return context.theme.text.feedback.warning.base }, - get textMuted() { return context.theme.text.muted }, - } -} - -function passThroughKey(event: KeyEvent, key: string, mode: string, pending: boolean, mapped: boolean) { - if (key === "" && event.shift) return true - if (mode !== "normal") return false - if (mapped) return false - if (event.ctrl && key !== "" && key !== "") return true - return event.super === true || event.meta === true || (isArrowKey(key) && !pending) -} - -function sendCompletionKey(event: KeyEvent, context: Context, key: string, mode: string) { - if (mode !== "normal") return false - const command = key === "j" ? "prompt.autocomplete.next" : key === "k" ? "prompt.autocomplete.prev" : undefined - if (!command || !isCompletionToken(context) || !context.keymap.commands().some((item) => item.id === command)) return false - event.preventDefault() - event.stopPropagation() - context.keymap.dispatch(command) - return true -} - -function isCompletionToken(context: Context) { - const input = context.renderer.currentFocusedEditor - const text = input?.plainText ?? "" - const index = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0)) - const before = text.slice(0, Math.min(index + 1, text.length)) - return /^\/\S*$/.test(before) || /(?:^|\s)@\S*$/.test(before) -} - -function isArrowKey(key: string) { - return key === "" || key === "" || key === "" || key === "" -} +export { default } from "./src/plugin"