diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 446d947..67f819d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -174,5 +174,9 @@ jobs: ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/multiline-undo/*.txt ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/multiline-undo/failure.json ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/multiline-undo/failure.termctrl + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/answer-paste/*.txt + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/answer-paste/failure.json + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/answer-paste/failure.termctrl + ${{ runner.temp }}/vimcode-e2e-${{ matrix.host }}/*/failure-status.json if-no-files-found: warn retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 455bc5e..05679bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,9 +58,10 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (477 lines) Dual v1 tui/v2 setup entry: intercept registration, action application + index.ts (707 lines) Dual v1 tui/v2 setup entry: intercepts, answer ownership, action application editor.ts (28 lines) Host-coordinate horizontal selection bounds, preserving native anchor - v2.ts (246 lines) Experimental v2 TUI facade (host input, commands, state, events) + editing.ts (28 lines) Editor-local Vim contexts and shared ANSWER focus trait check + v2.ts (250 lines) Experimental v2 TUI facade (host input, commands, state, events) vim/ Pure vim engine (thin barrel re-exports the public surface): index.ts (7 lines) Barrel — public surface only. No export *, no internals. types.ts (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess @@ -90,9 +91,11 @@ test/ editor.test.ts (118) Host-coordinate boundaries and opaque selection-color forwarding v2.test.ts (428) Experimental v2 facade contract and lifecycle tests child-session-navigation.test.ts (267) #79: isolated intercept and real OpenTUI keymap navigation regressions + question-answer.test.ts (621) #78: common v1/v2 answer editing and host-key ownership regressions + question-answer-support.ts (464) Typed focused editor fixture with real OpenTUI keymap dispatch leader.test.ts (125 lines) Unit tests for leader key matching functions compat/ Optional real-host Python driver and test-only TUI fixture (not packaged) - e2e/ Terminal Control guards: shared harness, *.integration.ts and *.e2e.ts explicit entries (not unit-discovered or packaged) + e2e/ Terminal Control guards: shared harness, v1/v2 question setup fixtures, *.integration.ts and *.e2e.ts explicit entries (not unit-discovered or packaged) ``` **Data flow:** @@ -172,7 +175,9 @@ just compat-unit # Pure checks for the optional host harness just compat v1 /absolute/opencode 1.18.33 /canonical/empty/output # Isolated installed-artifact check ``` -The `dev-tui.json` config is picked up only by `just dev`. Running `opencode` normally in this directory does not load the plugin. `just dev2` runs `scripts/dev2.ts` with the tested v2 package (or an explicit binary), loads local source through the root `tui.ts` shim, and isolates on-disk settings/history under `.dev2/`. The shim/launcher are not distributed; package installs still resolve `exports["./tui"]`. Both dev commands retain arrow navigation and add `ctrl+x j` to open children/picker; v1 uses `h`/`l` to cycle and `k` to return, while v2 uses `h`/`k` and `j`/`l` in the Composer. Visual mode captures the focused editor on entry and re-anchors at the new editor's cursor on the next eligible key after a prompt switch; overlay keys do not change ownership. Horizontal normalization probes `editBuffer.getTextRange()` in host display-cell coordinates and uses lower `editorView` selection calls to retain the renderer's native anchor. The v2 disabled setting is per activation: external storage reconciliation applies on reload, while local `/vim` updates both controller and form guard immediately. `just compat-unit` runs in CI; the Python/tmux live-host checks remain manual. Separate PR jobs run `just test-int` and `just test-e2e` on pinned v1/v2 hosts; harness parser checks are units, driver lifecycle checks are SDK/process integration, and real-host checks are product E2E. These entries use non-discovered `.unit.ts`/`.integration.ts`/`.e2e.ts` suffixes, so ordinary `bun test` and `just check` remain fast and do not launch the guards. `scripts/test-e2e.ts` infers canonical fresh OS-temp outputs, prints receipts, and privately bootstraps exact pins only when no matching PATH/override binary exists. Optional ignored `.env` recognizes only `VIMCODE_E2E_V1_BIN` / `VIMCODE_E2E_V2_BIN` paths (relative to repo root); process values win, CI ignores the file, and unknown/provider keys are never forwarded. Use `bun --env-file=/dev/null run test:e2e`, not bare `bun run`, on Bun 1.3.2 to prevent outer dotenv autoload; synthetic launcher integration tests verify suppression. The advanced `just test-e2e HOST BINARY VERSION OUTPUT` form and CI artifact paths remain supported. See `docs/test-e2e.md` for network/bootstrap limits (local v2 registry access blocked), Linux pending status, and the unchanged private SDK 1.2.1 cleanup boundary. +The `dev-tui.json` config is picked up only by `just dev`. Running `opencode` normally in this directory does not load the plugin. `just dev2` runs `scripts/dev2.ts` with the tested v2 package (or an explicit binary), loads local source through the root `tui.ts` shim, and isolates on-disk settings/history under `.dev2/`. The shim/launcher are not distributed; package installs still resolve `exports["./tui"]`. Both dev commands retain arrow navigation and add `ctrl+x j` to open children/picker; v1 uses `h`/`l` to cycle and `k` to return, while v2 uses `h`/`k` and `j`/`l` in the Composer. Visual mode captures the focused editor on entry and re-anchors at the new editor's cursor on the next eligible key after a prompt switch; overlay keys do not change ownership. Horizontal normalization probes `editBuffer.getTextRange()` in host display-cell coordinates and uses lower `editorView` selection calls to retain the renderer's native anchor. The v2 disabled setting is per activation: external storage reconciliation applies on reload, while local `/vim` updates both controller and form guard immediately. `just compat-unit` runs in CI; the Python/tmux live-host checks remain manual. Separate PR jobs run `just test-int` and `just test-e2e` on pinned v1/v2 hosts; harness parser checks are units, driver lifecycle checks are SDK/process integration, and real-host checks are product E2E. These entries use non-discovered `.unit.ts`/`.integration.ts`/`.e2e.ts` suffixes, so ordinary `bun test` and `just check` remain fast and do not launch the guards. `scripts/test-e2e.ts` infers canonical fresh OS-temp outputs, prints receipts, and privately bootstraps exact pins only when no matching PATH/override binary exists. Optional ignored `.env` recognizes only `VIMCODE_E2E_V1_BIN` / `VIMCODE_E2E_V2_BIN` paths (relative to repo root); process values win, CI ignores the file, and unknown/provider keys are never forwarded. Use `bun --env-file=/dev/null run test:e2e`, not bare `bun run`, on Bun 1.3.2 to prevent outer dotenv autoload; synthetic launcher integration tests verify suppression. The advanced `just test-e2e HOST BINARY VERSION OUTPUT` form and CI artifact paths remain supported. See `docs/test-e2e.md` for network/bootstrap limits (local v2 registry access blocked), Linux CI/startup diagnostics and the unchanged private SDK 1.2.1 cleanup boundary. + +Question answer editing uses the shared controller on both hosts: require the focused editor's `traits.status === "ANSWER"` plus active question/form ownership, never a renderable id or an arbitrary textarea. Each answer editor has its own Vim state, snapshots, visual owner and leader sequence; the main prompt's context is suspended intact. Enter/Ctrl+Enter/Tab remain host-owned. Deferred editor actions validate focus, route, question ownership and disposal. Pending answer actions, including `:` palette transitions, drain in input order before the next key or native paste is handed to the host. Global palette actions still survive editor focus changes. During asynchronous palette mounting, keys and paste wait for the dialog's focused editor and are reconstructed through `keyInput.processParsedKey()`/`processPaste()`, one per turn; focus loss, closure or disposal drops remaining buffered input. Disabling Vim restores the owned editor's line cursor without styling unrelated overlays. v2 still protects printable leaders in non-answer textual forms without interpreting their other keys as Vim commands. The experimental v2 adapter uses a raw renderer key listener because v2's public keymap does not expose intercepts; see `docs/opencode-v2-poc.md` for configuration and `docs/opencode-v2-strategy.md` for tested alternatives and remaining compatibility gaps. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c686bd..61de506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version - Added separate PR CI guards via `just test-int` for harness integration and `just test-e2e` for cache-installed OpenCode v1/v2 E2E editing, with exact prompt checks and no-plugin controls; local runs remain on demand and outside the unit suite. - Added no-argument `just test-e2e` defaults for both pinned hosts, optional single-host selection, fresh temp receipts, private pinned bootstrap, and ignored path-only `.env` binary overrides; existing explicit CI paths remain supported. +- Added v1/v2 Terminal Control regressions for answer append followed by native paste in one PTY write, with synthetic question setup fixtures and exact visible-answer checks. + +### Fixed + +- Custom question answers now support Vim editing on both OpenCode v1 and v2, starting in insert mode with isolated mode/undo state and preserving native answer submission, choice navigation and permissions ([#78](https://github.com/oribarilan/vimcode/issues/78)). +- Native paste in question answers now waits for queued Vim motions, preventing insertion at the previous cursor position. +- E2E startup failures retain the native exit status and current-scenario host error log instead of only reporting a closed readiness stream. ## [0.19.1] — 2026-10-04 diff --git a/README.md b/README.md index 61b7869..5215a35 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,15 @@ In normal mode, keys are vim commands. Unrecognized keys get swallowed so you do ### Overlay passthrough -When OpenCode shows its own UI (command palette, `/sessions`, the `@` file picker, question prompts, permission prompts) vimcode steps aside. All keys pass through to the overlay until it closes. +When OpenCode shows its own UI (command palette, `/sessions`, the `@` file picker, question choices, permission prompts) vimcode steps aside. Those controls keep their native keybindings. + +### Question answers + +On both OpenCode v1 and v2, a question's **Type your own answer** editor starts in insert mode with a line cursor. Escape switches to normal without closing the editor; motions, operators, text objects, visual mode and undo work on the answer. `i` returns to insert. A further Escape in normal mode reaches OpenCode's cancel handler, which can discard an unsubmitted draft on v1. + +Enter, Ctrl+Enter and Tab keep OpenCode's native question behavior, rather than vimcode's main-prompt submit/newline bindings. Each answer editor has its own Vim mode, pending commands, yank register and undo snapshots; returning to the main prompt restores its previous state. This also applies to child-session questions. `p` pastes the answer's own yank register, and empty-answer `j`/`k` never loads prompt history. + +`:` still opens the command palette, which owns its keys until it closes. Timeline and conversation-navigation Vim shortcuts are inactive while editing an answer. Question choices and confirmation screens remain host-controlled. ### Subagent navigation diff --git a/docs/test-e2e.md b/docs/test-e2e.md index 1c0f617..f87a6da 100644 --- a/docs/test-e2e.md +++ b/docs/test-e2e.md @@ -45,20 +45,25 @@ The suites test different boundaries: | Checks | Classification | Entry file | | --- | --- | --- | -| Prompt parsing, borders, provider metadata, and cursor diagnostics | Unit tests of the harness: pure functions and fixed snapshots | `test/e2e/prompt.unit.ts` | +| Prompt/answer parsing, borders, provider metadata, and cursor diagnostics | Unit tests of the harness: pure functions and fixed snapshots | `test/e2e/prompt.unit.ts` | +| Pinned v1 native question setup exports | Unit tests of the harness: synthetic embedded module layouts | `test/e2e/v1-question.unit.ts` | | SDK requests, timeouts, and owned-process cleanup | Harness integration tests: real SDK and OS processes with a synthetic driver | `test/e2e/driver.integration.ts` | | Launcher selection, pins, paths, dotenv isolation, bounded probes, and aggregate/negative exits | Harness integration tests: synthetic settings and OS child processes, no host downloads | `test/e2e/launcher.integration.ts` | | Installed plugin behavior in real OpenCode | Product E2E tests: real host, PTY, packaged vimcode, and keyboard input | `test/e2e/opencode.e2e.ts` | -`just test-int` groups the SDK/process integration checks and supporting parser units under the **Harness integration tests** CI job. The parser units stay outside ordinary product-test discovery as part of the on-demand harness suite. Lifecycle checks cover stalled readiness, launch/shutdown responses, SDK abort, keyboard type/press requests through the real failure/finally path, unresolved cleanup retries, and driver-override rejection. They do not exercise vimcode or OpenCode. +`just test-int` groups the SDK/process integration checks and supporting parser units under the **Harness integration tests** CI job. The parser units stay outside ordinary product-test discovery as part of the on-demand harness suite. Lifecycle checks cover stalled readiness, launch/shutdown responses, SDK abort, keyboard type/press requests through the real failure/finally path, unresolved cleanup retries, and driver-override rejection. They do not exercise vimcode or OpenCode. Keyboard raw-write timeouts use the same failure/finally cleanup checks as type/press. A synthetic native-startup-exit case verifies that the body is never entered, the scenario stays failed, no retry occurs, and native exit status plus only the current scenario's error log are retained. -The four product E2E scenarios run against both host generations: +Five product E2E scenarios run against both host generations: - Cold package-cache installation and exact insertion of `cold package loaded`. - `Escape`, `0`, `w`, `i`, `X` produces exactly `one Xtwo three`. - `Escape`, `0`, `w`, `ci"`, `new` produces exactly `say "new" now`, including an intermediate empty-quote check. - Multiline `dG` leaves exactly `alpha` plus an empty line; one `u` restores exactly `alpha`, `bravo`, `charlie`. +- A question answer containing `hello world`, followed by Escape and a single raw PTY write of `a` plus bracketed paste `X`, must display exactly `hello worldX`. This regression was demonstrated red (`hello worlXd`) with the paste-ordering fix removed on both hosts. + +Ctrl+G opens a synthetic question through test-only setup plugins. v2 uses the public session/form API. v1 has no question-create endpoint, so its server fixture listens for the synthetic session and calls the pinned native `Question` service. `v1-question.ts` resolves only the embedded runtime/store/question exports, validates their module layout and service shape, and records their paths/hashes in the receipt; it does not copy or patch host code. Importing the runtime is deferred until the session event to avoid bootstrap re-entry. A private `.npmrc` bounds v1's optional config dependency install when the registry is unavailable; the plain fixtures have no npm imports. A strict visible choice checkpoint guards Enter before opening the v1 custom-answer editor. Neither fixture reads or edits widget state. Assertions require the complete bordered one-line editor layout, and fixtures stay outside the vimcode tarball. + ## PR checks and host pins `.github/workflows/test.yml` keeps the existing `check` job and adds: @@ -81,7 +86,7 @@ Each E2E invocation packs the current source with `npm pack --ignore-scripts`, i Driver and host receive isolated HOME, all XDG config/data/cache/state directories, a sandbox cwd, and no inherited provider credentials or config overrides. PATH contains only selected Bun/npm/Node tool directories, system directories, and first-priority clipboard stubs. Update checks are disabled. The isolated `opencode.json` sets `enabled_providers: []`, explicitly excluding even free OpenCode Zen models. Before any scenario inputs, the actual TUI must show the strict bordered home prompt with `Build · No provider selected Connect a provider`; only then does the receipt claim `providersDisabled: true` and record that scenario's observation. Provider-free empty prompts support `Ask` or the host's `Ask anything` placeholder. If the observed v1 connection dialog overlays startup, a bounded Escape dismisses it; no Enter or approval is sent to that dialog. No `models` CLI probe is run: v2 can start an unowned service daemon through that route. Clipboard writes are discarded and reads return nothing. Inputs are synthetic; no model submission, permission approval, or real clipboard access is tested. This is environment/config isolation, not an OS/network sandbox: package/cache startup may access the network. -On failure, Actions uploads only the guard logs, synthetic receipts, scenario checkpoint `.txt` files, and available `failure.txt`, `failure.json`, and `failure.termctrl` evidence, named per host and retained for seven days. Cache, config, home, history, and package trees are not uploaded. Local artifacts stay in the requested output directory. Recordings may contain typed data: do not adapt these guards to real credentials or private prompts and upload the result. +On failure, Actions uploads only the guard logs, synthetic receipts, scenario checkpoint `.txt` files, and available `failure.txt`, `failure.json`, `failure-status.json`, and `failure.termctrl` evidence, named per host and retained for seven days. v2 runs at error log level; local `host-error.log` retains only current-scenario error lines from the isolated host log. It is not uploaded automatically. Cache, config, home, history, and package trees are not uploaded. Local artifacts stay in the requested output directory. Recordings may contain typed data: do not adapt these guards to real credentials or private prompts and upload the result. A nonempty inherited `TERMCTRL_BINARY` is rejected and recorded during setup. The SDK resolves that override in the Bun parent before applying the isolated driver environment; the guard supports only the pinned packaged driver. @@ -95,8 +100,8 @@ For forced cleanup on macOS/Linux, the adapter freezes the exact owned driver PI ## Oracle and validation limits -Both pinned hosts expose the same bordered home prompt. Assertions compare every rendered prompt line exactly, including empty lines and internal whitespace, not a substring anywhere on screen. Cases use short ASCII, unwrapped lines without trailing spaces. Terminal padding cannot distinguish trailing buffer spaces; these tests do not establish hidden-buffer state, Unicode/display-cell behavior, selection, other routes, full v2 parity, or all-version compatibility. +Both pinned hosts expose the same bordered home prompt. Assertions compare every rendered prompt line exactly, including empty lines and internal whitespace, not a substring anywhere on screen. Cases use short ASCII, unwrapped lines without trailing spaces. Terminal padding cannot distinguish trailing buffer spaces; these tests do not establish hidden-buffer state, Unicode/display-cell behavior, selection, routes beyond home and the single-line v1/v2 question cases, full v2 parity, or all-version compatibility. Cursor-only SDK captures may be stale. Word/quote placement is proven by exact editing effects; cursor metadata is diagnostic there. The multiline Enter safety guard requires observed cursor transitions. Captures must be settled and remain unchanged for a 250 ms stability window after each checkpoint starts, with bounded 5-second keyboard sends/editing captures, a 30-second startup observation deadline, and a 10 ms polling cadence. The 240-second outer scenario timeout leaves room for the longest sequence's bounded operations, failure evidence, and finally cleanup; Bun's timeout alone cannot reject a stalled SDK request. This avoids Escape/printable-key coalescing without unconditional per-key sleeps. -Both pinned hosts (including the provider-free four-scenario run and no-plugin negative controls) and the harness checks (including stalled-send failure/finally/reap probes) were validated on macOS arm64 with Bun 1.3.2. The default no-argument run passed v1 via PATH but v2 automatic npm provisioning was blocked by registry `ENOTCONN` in this environment, so bare/no-override eight-case success is not claimed. A no-argument run with the optional v2 binary path override passed all eight cases; selectors, advanced paths, failure propagation, and the official v1 macOS bootstrap were also checked. Ubuntu workflow syntax and the official Linux package/archive identities were checked, but Linux execution remains pending: the available Docker daemon was not running. The first Actions run must establish that environment. Windows is not supported by this PTY guard; the existing fast suite remains unchanged. +Both pinned hosts (including the provider-free four-scenario run and no-plugin negative controls) and the harness checks (including stalled-send failure/finally/reap probes) were validated on macOS arm64 with Bun 1.3.2. The default no-argument run passed v1 via PATH but v2 automatic npm provisioning was blocked by registry `ENOTCONN` in this environment, so bare/no-override eight-case success is not claimed. A no-argument run with the optional v2 binary path override passed all eight cases; selectors, advanced paths, failure propagation, and the official v1 macOS bootstrap were also checked. The first Ubuntu CI run passed the full v1 suite and harness checks. v2 passed four scenarios, including question-answer paste, but one private server exited before readiness in the quote scenario. A full Ubuntu 24.04 x64 Docker reproduction and 30 additional startup/shutdown cycles passed; the original native exit cause remains unconfirmed. Failure diagnostics now retain native exit status and the isolated current-scenario host error log. No automatic startup retries or weakened editing assertions were added. Windows is not supported by this PTY guard; the existing fast suite remains unchanged. diff --git a/package.json b/package.json index bfb72b3..157313d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ], "scripts": { "test": "bun test", - "test:int": "bun --env-file=/dev/null test ./test/e2e/prompt.unit.ts ./test/e2e/driver.integration.ts ./test/e2e/launcher.integration.ts", + "test:int": "bun --env-file=/dev/null test ./test/e2e/prompt.unit.ts ./test/e2e/v1-question.unit.ts ./test/e2e/driver.integration.ts ./test/e2e/launcher.integration.ts", "test:e2e": "bun --env-file=/dev/null scripts/test-e2e.ts", "typecheck": "tsc --noEmit", "check": "biome check .", diff --git a/src/editing.ts b/src/editing.ts new file mode 100644 index 0000000..3bb0a61 --- /dev/null +++ b/src/editing.ts @@ -0,0 +1,28 @@ +import { createVimState, type VimState } from "./vim"; + +export type EditingContext = { + state: VimState; + undoSnapshots: Array<{ text: string; cursor: number }>; + deferredActions: Array<() => void>; + visualEditorOwner?: unknown; + leaderPending: boolean; + leaderTimer?: ReturnType; +}; + +export function createEditingContext(): EditingContext { + return { state: createVimState(), undoSnapshots: [], deferredActions: [], leaderPending: false }; +} + +// The host assigns this trait to question textareas on both v1 and v2. +// It is not sufficient alone: the controller also checks question ownership. +export function hasAnswerFocus(renderer: { + currentFocusedEditor?: unknown; + currentFocusedRenderable?: unknown; +}): boolean { + const editor = renderer.currentFocusedEditor; + return ( + !!editor && + editor === renderer.currentFocusedRenderable && + (editor as { traits?: { status?: unknown } }).traits?.status === "ANSWER" + ); +} diff --git a/src/index.ts b/src/index.ts index 9439cb7..d2f6d86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import type { TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"; import { writeClipboard } from "./clipboard"; +import { createEditingContext, type EditingContext, hasAnswerFocus } from "./editing"; import { selectVisualCharacterRange } from "./editor"; import { findMatchingLeader, type KeyLike, leaderChar } from "./leader"; import { createV2Facade, type V2Context } from "./v2"; import { checkForUpdate } from "./version"; import { type Action, - createVimState, finishOneShotIfComplete, handleInsertKey, handleNormalKey, @@ -18,7 +18,10 @@ import { const plugin = { id: "vimcode", tui: async (api: TuiPluginApi, options?: Record) => { - const state = createVimState(); + const mainContext = createEditingContext(); + const state = mainContext.state; + let answerContexts = new WeakMap(); + let displayedContext = mainContext; const startMode = options?.startMode === "normal" ? "normal" : "insert"; state.mode = startMode; const leaderKeys = resolveLeaderKeys(); @@ -40,18 +43,17 @@ const plugin = { api.ui?.toast?.({ message: "Vim mode disabled (use /vim to re-enable)", variant: "info", duration: 3000 }); } - // Track whether the previous key was the leader, so the follow-up - // key also passes through to OpenCode's leader system. - let leaderPending = false; - let leaderTimer: ReturnType | null = null; + // Keep handles for disposal even after an answer editor loses focus. + const leaderTimers = new Set>(); // Track pending permissions/questions from child sessions via events. // permission()/question() only covers one session ID, but subagent // prompts live on child IDs. Events fire globally; we aggregate by root. const pendingChildPrompts = new Map(); + const pendingChildQuestions = new Map(); // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - function trackPromptEvent(event: any, delta: number) { + function trackPromptEvent(event: any, delta: number, question = false) { const sessionID = event?.properties?.sessionID ?? event?.sessionID; if (!sessionID) return; const session = api.state?.session?.get?.(sessionID); @@ -59,6 +61,11 @@ const plugin = { const count = (pendingChildPrompts.get(rootId) ?? 0) + delta; if (count <= 0) pendingChildPrompts.delete(rootId); else pendingChildPrompts.set(rootId, count); + if (question) { + const questions = (pendingChildQuestions.get(rootId) ?? 0) + delta; + if (questions <= 0) pendingChildQuestions.delete(rootId); + else pendingChildQuestions.set(rootId, questions); + } } // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API @@ -66,14 +73,14 @@ const plugin = { // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API const unsubPermsReplied = api.event?.on?.("permission.replied", (e: any) => trackPromptEvent(e, -1)); // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubQuestAsked = api.event?.on?.("question.asked", (e: any) => trackPromptEvent(e, 1)); + const unsubQuestAsked = api.event?.on?.("question.asked", (e: any) => trackPromptEvent(e, 1, true)); // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubQuestReplied = api.event?.on?.("question.replied", (e: any) => trackPromptEvent(e, -1)); + const unsubQuestReplied = api.event?.on?.("question.replied", (e: any) => trackPromptEvent(e, -1, true)); // Dismissing a question emits question.rejected, not question.replied. // Without this the +1 from question.asked never balances and the plugin // stays stuck passing every key through to the host. // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubQuestRejected = api.event?.on?.("question.rejected", (e: any) => trackPromptEvent(e, -1)); + const unsubQuestRejected = api.event?.on?.("question.rejected", (e: any) => trackPromptEvent(e, -1, true)); api.lifecycle?.onDispose?.(() => { unsubPermsAsked?.(); unsubPermsReplied?.(); @@ -90,17 +97,122 @@ const plugin = { return (pendingChildPrompts.get(sid) ?? 0) > 0; } - // Snapshots for single-step undo of vim changes. - // The host editor's undo system splits repeated commands into multiple - // entries, so we save/restore the buffer ourselves. - let undoSnapshots: Array<{ text: string; cursor: number }> = []; - let visualEditorOwner: unknown; let disposed = false; api.lifecycle?.onDispose?.(() => { disposed = true; - visualEditorOwner = undefined; }); + type HostKey = Parameters[0]; + type HostPaste = Parameters; + type ConsumeEvent = { preventDefault(): void; stopPropagation(): void }; + type HostKeyEvent = HostKey & ConsumeEvent; + type HostPasteEvent = ConsumeEvent & { bytes: HostPaste[0]; metadata?: HostPaste[1] }; + type HostInput = { type: "key"; value: HostKey } | { type: "paste"; value: HostPaste }; + // Local host declarations omit the inherited EventEmitter methods. + const keyInput = api.renderer?.keyInput as + | (TuiPluginApi["renderer"]["keyInput"] & { + prependListener(event: "keypress", listener: (key: HostKeyEvent) => void): void; + prependListener(event: "paste", listener: (paste: HostPasteEvent) => void): void; + off(event: "keypress", listener: (key: HostKeyEvent) => void): void; + off(event: "paste", listener: (paste: HostPasteEvent) => void): void; + }) + | undefined; + const canReplayInput = + !!keyInput?.processParsedKey && !!keyInput?.processPaste && !!keyInput?.prependListener && !!keyInput?.off; + let paletteHandoff: { source: unknown; target?: unknown; inputs: HostInput[]; deadline: number } | undefined; + let paletteTimer: ReturnType | undefined; + let replayingInput = false; + + function replayPaletteInput() { + paletteTimer = undefined; + const handoff = paletteHandoff; + if (!handoff || disposed) return; + const renderer = api.renderer; + const target = renderer?.currentFocusedEditor; + if (!handoff.target) { + if ( + api.ui?.dialog?.open && + target && + target !== handoff.source && + target === renderer.currentFocusedRenderable + ) { + handoff.target = target; + } else if (Date.now() < handoff.deadline) { + paletteTimer = setTimeout(replayPaletteInput, 0); + return; + } else { + paletteHandoff = undefined; + api.ui?.toast?.({ message: "Command palette did not take focus", variant: "warning", duration: 2000 }); + return; + } + } + if (!api.ui?.dialog?.open || target !== handoff.target || target !== renderer.currentFocusedRenderable) { + paletteHandoff = undefined; + return; + } + const input = handoff.inputs.shift(); + if (!input) { + paletteHandoff = undefined; + return; + } + // Reconstruct native events one per turn so dialog mounting and focus + // changes settle, while preserving paste metadata and fresh flags. + replayingInput = true; + try { + if (input.type === "key") keyInput?.processParsedKey(input.value); + else keyInput?.processPaste(...input.value); + } finally { + replayingInput = false; + } + if (disposed || !handoff.inputs.length || !api.ui?.dialog?.open) paletteHandoff = undefined; + else paletteTimer = setTimeout(replayPaletteInput, 0); + } + + function hostMode(): string | undefined { + return ( + (api.keymap as typeof api.keymap & { mode?: { current(): string } }).mode?.current() ?? api.mode?.current?.() + ); + } + + function answerOwnsFocus(): boolean { + const renderer = api.renderer; + const route = api.route.current; + if (!renderer || !hasAnswerFocus(renderer) || api.ui?.dialog?.open || route.name !== "session") return false; + const mode = hostMode(); + if (mode) return mode === "question" || mode === "form"; + const sid = route.params?.sessionID; + if (typeof sid !== "string") return false; + const rootID = api.state.session.get?.(sid)?.parentID ?? sid; + return !!api.state.session.question(sid)?.length || !!pendingChildQuestions.get(rootID); + } + + function answerContextForFocus(): EditingContext | undefined { + if (state.disabled || !answerOwnsFocus()) return; + const editor = api.renderer.currentFocusedEditor; + if (!editor) return; + let context = answerContexts.get(editor); + if (!context) { + context = createEditingContext(); + answerContexts.set(editor, context); + } + return context; + } + + function mainPromptBlocked(): boolean { + const route = api.route.current; + const mode = hostMode(); + if (api.ui?.dialog?.open || (mode && mode !== "base" && mode !== "autocomplete")) return true; + if (route.name !== "session") return false; + const sid = route.params?.sessionID; + return typeof sid === "string" && (!!api.state.session.get?.(sid)?.parentID || hasActivePrompts(sid)); + } + + function showContext(context: EditingContext) { + if (displayedContext === context) return; + displayedContext = context; + applyActions([{ type: "mode", mode: context.state.oneShotNormal ? "(insert)" : context.state.mode }], context); + } + const prompt = { getLine: (n: number) => getInputText().split("\n")[n] ?? "", getLineCount: () => getInputText().split("\n").length, @@ -130,14 +242,49 @@ const plugin = { ); } - function applyActions(actions: Action[]) { + function applyActions(actions: Action[], context = mainContext) { + const state = context.state; + const editor = api.renderer?.currentFocusedEditor; + const route = api.route.current; + const routeName = route.name; + const sessionID = "params" in route ? route.params?.sessionID : undefined; + const isAnswer = context !== mainContext; + const stillOwnsEditor = () => { + const renderer = api.renderer; + const currentRoute = api.route.current; + return ( + !disposed && + !mainContext.state.disabled && + !api.ui?.dialog?.open && + renderer?.currentFocusedEditor === editor && + (renderer?.currentFocusedRenderable === undefined || renderer.currentFocusedRenderable === editor) && + currentRoute.name === routeName && + ("params" in currentRoute ? currentRoute.params?.sessionID : undefined) === sessionID && + (isAnswer + ? answerContextForFocus() === context + : !mainPromptBlocked() && !answerContextForFocus() && !hasAnswerFocus(renderer ?? {})) + ); + }; + function deferAction(run: () => void) { + if (!isAnswer) { + setTimeout(run, 0); + return; + } + context.deferredActions.push(run); + setTimeout(() => { + const index = context.deferredActions.indexOf(run); + if (index < 0) return; + context.deferredActions.splice(index, 1); + run(); + }, 0); + } let keepUndoSnapshotForBatch = false; for (const action of actions) { // Buffer-modifying actions (cmd, insertText) clear the undo stack, // unless this batch includes a saveUndoSnapshot (which sets // keepUndoSnapshotForBatch to preserve the stack). if ((action.type === "cmd" || action.type === "insertText") && !keepUndoSnapshotForBatch) { - undoSnapshots = []; + context.undoSnapshots = []; } switch (action.type) { case "cmd": { @@ -145,35 +292,58 @@ const plugin = { state.mode === "visual" && (action.cmd === "input.select.left" || action.cmd === "input.select.right") ? state.visualAnchor : undefined; - const visualEditor = visualAnchor === undefined ? undefined : api.renderer?.currentFocusedEditor; - setTimeout(() => { + const visualEditor = visualAnchor === undefined ? undefined : editor; + const command = + isAnswer && action.cmd === "prompt.history.previous" + ? "input.move.up" + : isAnswer && action.cmd === "prompt.history.next" + ? "input.move.down" + : action.cmd; + if (isAnswer && action.cmd === "prompt.paste") { + if (state.yankRegister) editor?.insertText?.(state.yankRegister); + break; + } + const globalCommand = command === "command.palette.show"; + if (isAnswer && globalCommand && canReplayInput) { + paletteHandoff = { source: editor, inputs: [], deadline: Date.now() + 1000 }; + } + if ( + isAnswer && + (command === "input.submit" || command.startsWith("prompt.") || command.startsWith("session.")) + ) + break; + deferAction(() => { if ( - disposed || + (globalCommand ? disposed : !stillOwnsEditor()) || (visualEditor && (api.ui?.dialog?.open || api.renderer?.currentFocusedEditor !== visualEditor || api.renderer?.currentFocusedRenderable !== visualEditor)) ) return; - const dispatched = api.keymap.dispatchCommand(action.cmd); + const dispatched = api.keymap.dispatchCommand(command); + if (isAnswer && globalCommand && paletteHandoff) { + if (dispatched?.ok === false) paletteHandoff = undefined; + else paletteTimer = setTimeout(replayPaletteInput, 0); + } if ( dispatched?.ok && visualEditor && visualAnchor !== undefined && state.mode === "visual" && state.visualAnchor === visualAnchor && - visualEditorOwner === visualEditor && + context.visualEditorOwner === visualEditor && !api.ui?.dialog?.open && api.renderer?.currentFocusedEditor === visualEditor && api.renderer?.currentFocusedRenderable === visualEditor ) { selectVisualCharacterRange(visualEditor, visualAnchor); } - }, 0); + }); break; } case "mode": - visualEditorOwner = action.mode === "visual" ? api.renderer?.currentFocusedEditor : undefined; + context.visualEditorOwner = action.mode === "visual" ? (context.visualEditorOwner ?? editor) : undefined; if (modeIndicator === "toast") { const label = action.mode === "(insert)" ? action.mode : action.mode.toUpperCase(); api.ui?.toast?.({ @@ -198,8 +368,8 @@ const plugin = { break; case "yankSelection": { // Deferred so it runs after any preceding select commands - setTimeout(() => { - const editor = api.renderer?.currentFocusedEditor; + deferAction(() => { + if (!stillOwnsEditor()) return; const text = editor?.editorView?.getSelectedText?.() ?? ""; if (text) { state.yankRegister = text; @@ -211,7 +381,7 @@ const plugin = { }); } editor?.editorView?.resetSelection?.(); - }, 0); + }); break; } case "clearSelection": @@ -220,7 +390,7 @@ const plugin = { case "deleteRange": { const editor = api.renderer?.currentFocusedEditor; const eb = editor?.editBuffer; - if (eb?.deleteRange) { + if (eb?.deleteRange && editor) { const text = editor.plainText ?? ""; const [sl, sc] = offsetToLineCol(text, action.start); const [el, ec] = offsetToLineCol(text, action.end + 1); @@ -231,7 +401,7 @@ const plugin = { case "saveUndoSnapshot": { const editor = api.renderer?.currentFocusedEditor; if (editor) { - undoSnapshots.push({ + context.undoSnapshots.push({ text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0, }); @@ -240,7 +410,7 @@ const plugin = { break; } case "undo": { - const undoSnapshot = undoSnapshots.pop(); + const undoSnapshot = context.undoSnapshots.pop(); if (undoSnapshot) { const editor = api.renderer?.currentFocusedEditor; const eb = editor?.editBuffer; @@ -249,7 +419,9 @@ const plugin = { editor.cursorOffset = undoSnapshot.cursor; } } else { - setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0); + deferAction(() => { + if (stillOwnsEditor()) api.keymap.dispatchCommand("input.undo"); + }); } break; } @@ -271,9 +443,24 @@ const plugin = { function syncCursorStyle() { const editor = api.renderer?.currentFocusedEditor; - if (!editor) return; + if ( + !editor || + api.ui?.dialog?.open || + (api.renderer.currentFocusedRenderable !== undefined && api.renderer.currentFocusedRenderable !== editor) + ) + return; + if (state.disabled) { + if (answerOwnsFocus() || (!hasAnswerFocus(api.renderer) && !mainPromptBlocked())) { + editor.cursorStyle = { style: "line", blinking: true }; + } + return; + } + const answer = answerContextForFocus(); + if (!answer && (hasAnswerFocus(api.renderer) || mainPromptBlocked())) return; + const context = answer ?? mainContext; + showContext(context); editor.cursorStyle = { - style: state.mode === "insert" ? "line" : "block", + style: context.state.mode === "insert" ? "line" : "block", blinking: true, }; } @@ -285,7 +472,7 @@ const plugin = { const cursorInterval = setInterval(syncCursorStyle, 100); api.lifecycle?.onDispose?.(() => clearInterval(cursorInterval)); api.lifecycle?.onDispose?.(() => { - if (leaderTimer) clearTimeout(leaderTimer); + for (const timer of leaderTimers) clearTimeout(timer); }); if (options?.updateCheck !== false) { @@ -332,6 +519,7 @@ const plugin = { slashName: "vim", run: async () => { const result = toggleVimMode(state); + answerContexts = new WeakMap(); await api.kv?.set?.("vimcode.disabled", state.disabled); applyActions(result.actions); }, @@ -345,18 +533,25 @@ const plugin = { if (ctx.event.eventType === "release") return; // If vim mode is disabled, pass all keys through unmodified. - if (state.disabled) return; + if (mainContext.state.disabled || disposed) return; + // Terminal input can contain several keys before timers run. A queued + // palette must transfer ownership before the following key is routed. + const previousAnswer = answerContextForFocus(); + if (previousAnswer) for (const action of previousAnswer.deferredActions.splice(0)) action(); + const answer = answerContextForFocus(); + const context = answer ?? mainContext; + const state = context.state; + if (hasAnswerFocus(api.renderer ?? {}) && !answer) return; - // Pass through when any overlay owns the keyboard: dialogs (command - // palette, session list, etc.), question prompts, or permission prompts. + // Question choices, permissions and unrelated dialogs remain host-owned. if (api.ui?.dialog?.open) return; const route = api.route.current; if (route.name === "session") { const sid = route.params?.sessionID; // Child sessions have no editable prompt. Let host navigation own // the keys without changing the mode restored in the parent (#79). - if (typeof sid === "string" && api.state?.session?.get?.(sid)?.parentID) return; - if (sid && hasActivePrompts(sid)) { + if (!answer && typeof sid === "string" && api.state?.session?.get?.(sid)?.parentID) return; + if (!answer && typeof sid === "string" && hasActivePrompts(sid)) { // Consume the leader key so dispatchLayers() doesn't // match it as a leader token, which would enter pending- // sequence state instead of typing a space. @@ -372,13 +567,16 @@ const plugin = { // The v2 facade exposes the host mode here. Autocomplete can also // appear while vim is in normal mode; its own layer owns those keys. - const hostMode = (api.keymap as typeof api.keymap & { mode?: { current(): string } }).mode?.current(); - if (hostMode === "autocomplete" && state.mode !== "insert") return; + const mode = hostMode(); + if (mode === "autocomplete" && state.mode !== "insert") return; + showContext(context); + // Answer commit and field navigation belong to the host question layer. + if (answer && (ctx.event.name === "return" || ctx.event.name === "tab")) return; // Let autocomplete handle Enter/Escape before vim consumes them. // dispatchCommand returns { ok } — true when the autocomplete layer // is active and handled the command, false when it's hidden/disabled. - if (state.mode === "insert") { + if (!answer && state.mode === "insert") { if (ctx.event.name === "escape") { const r = api.keymap.dispatchCommand("prompt.autocomplete.hide"); if (r.ok) { @@ -400,16 +598,21 @@ const plugin = { // In normal/visual mode, let the leader key and its follow-up // pass through so OpenCode's leader bindings work. if (leaderKeys.length > 0 && state.mode !== "insert") { - if (leaderPending) { - leaderPending = false; - if (leaderTimer) clearTimeout(leaderTimer); + if (context.leaderPending) { + context.leaderPending = false; + if (context.leaderTimer) { + clearTimeout(context.leaderTimer); + leaderTimers.delete(context.leaderTimer); + } return; } if (findMatchingLeader(ctx.event, leaderKeys)) { - leaderPending = true; - leaderTimer = setTimeout(() => { - leaderPending = false; + context.leaderPending = true; + context.leaderTimer = setTimeout(() => { + context.leaderPending = false; + if (context.leaderTimer) leaderTimers.delete(context.leaderTimer); }, 2000); + leaderTimers.add(context.leaderTimer); return; } } @@ -417,8 +620,8 @@ const plugin = { // Visual mode follows the active prompt, never an old editor's offset. // Capture ownership on entry, then re-anchor only after overlay guards. const editor = api.renderer?.currentFocusedEditor; - if (state.mode === "visual" && editor && editor !== visualEditorOwner) { - visualEditorOwner = editor; + if (state.mode === "visual" && editor && editor !== context.visualEditorOwner) { + context.visualEditorOwner = editor; state.visualAnchor = editor.cursorOffset; } const handlerMode = state.mode; @@ -451,10 +654,37 @@ const plugin = { } if (consume) ctx.consume(); - applyActions(actions); + applyActions(actions, context); }, { priority: 10_000 }, ); + if (canReplayInput && keyInput) { + const capturePaletteKey = (key: HostKeyEvent) => { + if (!paletteHandoff || replayingInput || key.eventType === "release") return; + key.preventDefault(); + key.stopPropagation(); + paletteHandoff.inputs.push({ type: "key", value: key }); + }; + const capturePalettePaste = (paste: HostPasteEvent) => { + // Native paste bypasses the key intercept, so settle answer motions first. + if (!replayingInput && !disposed) { + const answer = answerContextForFocus(); + if (answer) for (const action of answer.deferredActions.splice(0)) action(); + } + if (!paletteHandoff || replayingInput) return; + paste.preventDefault(); + paste.stopPropagation(); + paletteHandoff.inputs.push({ type: "paste", value: [paste.bytes, paste.metadata] }); + }; + keyInput.prependListener("keypress", capturePaletteKey); + keyInput.prependListener("paste", capturePalettePaste); + api.lifecycle?.onDispose?.(() => { + keyInput.off("keypress", capturePaletteKey); + keyInput.off("paste", capturePalettePaste); + if (paletteTimer) clearTimeout(paletteTimer); + paletteHandoff = undefined; + }); + } }, async setup(context: V2Context) { const { api, dispose } = createV2Facade(context); diff --git a/src/v2.ts b/src/v2.ts index 56252df..b690ac4 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -1,4 +1,5 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import { hasAnswerFocus } from "./editing"; import { findMatchingLeader, leaderChar } from "./leader"; import type { KeyEvent } from "./vim"; @@ -35,6 +36,8 @@ export type V2Context = { keyInput: { prependListener(event: "keypress", handler: (key: RawKeyEvent) => void): void; off(event: "keypress", handler: (key: RawKeyEvent) => void): void; + processParsedKey?(key: RawKeyEvent): boolean; + processPaste?(...paste: Parameters): void; }; }; keymap: { @@ -157,7 +160,8 @@ export function createV2Facade(context: V2Context): { api: TuiPluginApi; dispose if (!editor || editor !== context.renderer.currentFocusedRenderable) return; // v2's leader token also matches inside textual forms. Insert its // character without running Vim commands against the form's input. - if (mode === "form" && !effectiveDisabled) { + const answer = mode === "form" && hasAnswerFocus(context.renderer); + if (mode === "form" && !answer && !effectiveDisabled) { const leader = findMatchingLeader(event, leaders); const character = leader && leaderChar(leader); if (character && editor.insertText) { @@ -167,7 +171,7 @@ export function createV2Facade(context: V2Context): { api: TuiPluginApi; dispose } return; } - if (mode !== "base" && mode !== "autocomplete") return; + if (mode !== "base" && mode !== "autocomplete" && !answer) return; handler({ event, consume: () => { @@ -187,7 +191,7 @@ export function createV2Facade(context: V2Context): { api: TuiPluginApi; dispose dialog: { get open() { const mode = context.keymap.mode.current(); - return mode !== "base" && mode !== "autocomplete"; + return mode !== "base" && mode !== "autocomplete" && !(mode === "form" && hasAnswerFocus(context.renderer)); }, }, }, diff --git a/test/compat/cases.py b/test/compat/cases.py index 11e4797..2196247 100644 --- a/test/compat/cases.py +++ b/test/compat/cases.py @@ -1,5 +1,5 @@ """Exact-buffer scenarios for the installed OpenCode TUI package.""" -from driver import atomic_json, capture, command, keys, normal, read_json, record, save, seed, send, snapshot, tmux, wait_for +from driver import atomic_json, capture, command, compare_state, keys, normal, read_json, record, save, seed, send, snapshot, tmux, wait_for def run_core(ctx): @@ -149,20 +149,81 @@ def run_v2_prompts(ctx): wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "root form") for ch in "hello world": send(ctx, ch) - record(ctx, "root-form-space", {"text": "hello world", "mode": "form"}) + record(ctx, "root-form-space", {"text": "hello world", "mode": "form", "editorStatus": "ANSWER", + "cursorStyle": {"style": "line", "blinking": True}}) + send(ctx, "Escape", False) + record(ctx, "root-form-escape-normal", {"text": "hello world", "offset": 10, "mode": "form", + "cursorStyle": {"style": "block", "blinking": True}}) + keys(ctx, "hx") + record(ctx, "root-form-normal-delete", {"text": "hello word", "offset": 9, "mode": "form"}) + keys(ctx, "il") + record(ctx, "root-form-insert-correction", {"text": "hello world", "offset": 10, + "cursorStyle": {"style": "line", "blinking": True}}) send(ctx, "Enter", False) send(ctx, "Enter", False) completed_command(ctx, "formState", lambda result: result.get("state") == {"status": "answered", "answer": {"answer": "hello world"}}) + for kind, burst in (("key", ":x"), ("paste", ":\x1b[200~x\x1b[201~")): + command(ctx, "form") + wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "palette-order form") + send(ctx, "hello world") + send(ctx, "Escape", False) + record(ctx, f"root-form-{kind}-before-palette", {"text": "hello world", "offset": 10, "mode": "form"}) + tmux(ctx, "send-keys", "-t", ctx["pane"], "-l", burst) + wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "modal", "palette owns burst input") + record(ctx, f"root-form-{kind}-palette-query", {"text": "x", "mode": "modal", "editorStatus": "FILTER"}) + send(ctx, "Escape", False) + record(ctx, f"root-form-{kind}-after-palette", {"text": "hello world", "offset": 10, "mode": "form", + "editorStatus": "ANSWER", "cursorStyle": {"style": "block", "blinking": True}}) + send(ctx, "Enter", False) + send(ctx, "Enter", False) + completed_command(ctx, "formState", lambda result: + result.get("state") == {"status": "answered", "answer": {"answer": "hello world"}}) + command(ctx, "form") + wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "burst-edit form") + send(ctx, "burst draft") + send(ctx, "Escape", False) + record(ctx, "root-form-before-burst", {"text": "burst draft", "offset": 10, "mode": "form"}) + # One terminal write: no timer turn may separate the edit from Enter. + tmux(ctx, "send-keys", "-t", ctx["pane"], "-l", "x\r") + wait_for(lambda: snapshot(ctx), lambda value: value.get("editorStatus") != "ANSWER", "burst answer committed") + if command(ctx, "formState").get("state", {}).get("status") == "pending": + send(ctx, "Enter", False) + expected = {"state": {"status": "answered", "answer": {"answer": "burst draf"}}} + actual = completed_command(ctx, "formState", lambda result: result.get("state") == expected["state"]) + ctx["report"]["checks"].append(compare_state("root-form-burst-submit", actual, expected)) + save(ctx) + command(ctx, "form") + wait_for(lambda: snapshot(ctx), lambda value: value.get("editorStatus") == "ANSWER", "paste-order answer") + send(ctx, "hello world") + send(ctx, "Escape", False) + record(ctx, "root-form-before-append-paste", {"text": "hello world", "offset": 10, "mode": "form"}) + # Native paste bypasses key dispatch; it must still follow the append motion. + tmux(ctx, "send-keys", "-t", ctx["pane"], "-l", "a\x1b[200~X\x1b[201~") + wait_for(lambda: snapshot(ctx), lambda value: "X" in (value.get("text") or ""), "answer paste delivered") + record(ctx, "root-form-append-paste-burst", {"text": "hello worldX", "offset": 12, "mode": "form"}) + send(ctx, "Enter", False) + send(ctx, "Enter", False) + completed_command(ctx, "formState", lambda result: + result.get("state") == {"status": "answered", "answer": {"answer": "hello worldX"}}) command(ctx, "childForm") wait_for(lambda: snapshot(ctx), lambda value: value["mode"] == "form", "child form") for ch in "child words": send(ctx, ch) - record(ctx, "child-form-space", {"text": "child words", "mode": "form"}) + record(ctx, "child-form-space", {"text": "child words", "mode": "form", "editorStatus": "ANSWER", + "cursorStyle": {"style": "line", "blinking": True}}) + send(ctx, "Escape", False) + record(ctx, "child-form-escape-normal", {"text": "child words", "offset": 10, "mode": "form", + "cursorStyle": {"style": "block", "blinking": True}}) + send(ctx, "x") + record(ctx, "child-form-normal-delete", {"text": "child word", "offset": 10}) + keys(ctx, "i!") + record(ctx, "child-form-insert-correction", {"text": "child word!", "offset": 11, + "cursorStyle": {"style": "line", "blinking": True}}) send(ctx, "Enter", False) send(ctx, "Enter", False) completed_command(ctx, "formState", lambda result: - result.get("state") == {"status": "answered", "answer": {"answer": "child words"}}) + result.get("state") == {"status": "answered", "answer": {"answer": "child word!"}}) command(ctx, "permission") wait_for(lambda: capture(ctx), lambda pane: "Permission required" in pane, "pending permission") send(ctx, "Escape", False) @@ -185,7 +246,8 @@ def run_v2_reload(ctx): wait_for(lambda: capture(ctx), lambda value: ":vim" in value, "Vim toggle command", 5) send(ctx, "Enter", False) send(ctx, "z") - record(ctx, "disabled-typing-passes-through", {"text": "zalpha beta gamma"}) + record(ctx, "disabled-typing-passes-through", {"text": "zalpha beta gamma", + "cursorStyle": {"style": "line", "blinking": True}}) saved = list((ctx["output"] / "state" / "opencode").rglob("plugin.vimcode.settings.json")) if len(saved) != 1 or read_json(saved[0]).get("disabled") is not True: raise AssertionError(f"Disabled state did not persist: {saved}") diff --git a/test/compat/fixture/tui.ts b/test/compat/fixture/tui.ts index 0904873..a6ee904 100644 --- a/test/compat/fixture/tui.ts +++ b/test/compat/fixture/tui.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; type Editor = { id?: string; + traits?: { status?: string }; plainText: string; cursorOffset: number; visualCursor?: { logicalRow: number; logicalCol: number; offset: number }; @@ -163,6 +164,7 @@ function observe( cursorStyle: editor?.cursorStyle ?? null, keypressListeners: renderer.keyInput?.listeners("keypress").length ?? null, editorId: editor?.id ?? null, + editorStatus: editor?.traits?.status ?? null, focusedId: renderer.currentFocusedRenderable?.id ?? null, mode: mode(), route: route(), diff --git a/test/e2e/driver.integration.ts b/test/e2e/driver.integration.ts index eb5ee1a..53d7e08 100644 --- a/test/e2e/driver.integration.ts +++ b/test/e2e/driver.integration.ts @@ -1,31 +1,57 @@ import { expect, it } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { bounded, closeDriver, ownDriver } from "./driver"; -import { type HostRun, prepareRun, pressKey, scenario, startScenario, stopScenario, typeText } from "./host"; +import { + type HostRun, + prepareRun, + pressKey, + scenario, + startScenario, + stopScenario, + typeText, + writeInput, +} from "./host"; if (!["darwin", "linux"].includes(process.platform)) throw new Error("Harness integration process checks require macOS or Linux"); -function fixture(mode: "ready-stall" | "shutdown-stall" | "launch-stall" | "abort" | "send-stall") { +function fixture(mode: "ready-stall" | "shutdown-stall" | "launch-stall" | "abort" | "send-stall" | "startup-exit") { const directory = mkdtempSync(join(realpathSync(tmpdir()), "vimcode-driver-probe-")); const binaryPath = join(directory, "driver"); writeFileSync( binaryPath, `#!${process.execPath} import { spawn } from "node:child_process"; -import { appendFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; import { createInterface } from "node:readline"; process.on("SIGTERM", () => {}); setInterval(() => {}, 1000); +let launchedHost; if (${JSON.stringify(mode)} !== "ready-stall") { console.log(JSON.stringify({ type: "hello", protocolVersion: 2 })); createInterface({ input: process.stdin }).on("line", line => { const request = JSON.parse(line); appendFileSync(${JSON.stringify(join(directory, "requests.txt"))}, request.method + "\\n"); const result = value => console.log(JSON.stringify({ type: "result", id: request.id, result: value })); + if (${JSON.stringify(mode)} === "startup-exit" && request.method !== "launch") { + if (request.method === "capture") result({ reason: "exited", shot: { + text: "Standalone server exited before reporting readiness", frame: { cursor: null } + }}); + else if (request.method === "status") result({ state: "exited", exit: { code: 1, signal: null, success: false } }); + else if (request.method === "recording") result({ bytes: [] }); + else if (request.method === "stop") { + if (launchedHost?.exitCode === null) { + launchedHost.once("exit", () => result(null)); + launchedHost.kill("SIGKILL"); + } else result(null); + } else if (request.method === "shutdown") { + result(null); + setImmediate(() => process.exit(0)); + } + } if (${JSON.stringify(mode)} === "send-stall" && request.method !== "launch") { if (request.method === "capture") result({ reason: "idle", shot: { text: " ┃\\n ┃ Ask\\n ┃\\n ┃ Build · No provider selected Connect a provider\\n ╹▀▀▀▀▀", @@ -37,7 +63,12 @@ if (${JSON.stringify(mode)} !== "ready-stall") { } if (request.method === "launch") { const host = spawn(${JSON.stringify(process.execPath)}, ["-e", "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"], { detached: true, stdio: "ignore" }); + launchedHost = host; writeFileSync(${JSON.stringify(join(directory, "host.pid"))}, String(host.pid)); + if (${JSON.stringify(mode)} === "startup-exit") { + mkdirSync(${JSON.stringify(join(directory, "opencode/log"))}, { recursive: true }); + appendFileSync(${JSON.stringify(join(directory, "opencode/log/opencode.log"))}, "level=ERROR message=synthetic-startup-cause\\n"); + } if (${JSON.stringify(mode)} === "abort") console.log("invalid-json"); else if (${JSON.stringify(mode)} !== "launch-stall") console.log(JSON.stringify({ type: "result", id: request.id, result: null })); } @@ -90,7 +121,7 @@ for (const mode of ["ready-stall", "shutdown-stall", "launch-stall", "abort"] as }); } -for (const send of ["type", "press"] as const) { +for (const send of ["type", "press", "write"] as const) { it(`scenario captures stalled keyboard ${send} failure and finally reaps its driver/host`, async () => { const probe = fixture("send-stall"); const run: HostRun = { @@ -114,7 +145,8 @@ for (const send of ["type", "press"] as const) { scenario(run, `send-${send}`, async () => { owner = run.driver; if (send === "type") await typeText(run, "synthetic", 100); - else await pressKey(run, "Escape", 100); + else if (send === "press") await pressKey(run, "Escape", 100); + else await writeInput(run, "synthetic", 100); throw new Error("stalled send must not complete"); }), ).rejects.toThrow("driver shutdown exceeded"); @@ -125,7 +157,11 @@ for (const send of ["type", "press"] as const) { cleanupErrors: string[]; }; expect(receipt.checks).toEqual([ - { name: `send-${send}`, status: "fail", error: `keyboard ${send} exceeded 100ms` }, + { + name: `send-${send}`, + status: "fail", + error: `keyboard ${send === "write" ? "raw write" : send} exceeded 100ms`, + }, ]); expect(receipt.cleanupErrors).toEqual(["Error: driver shutdown exceeded 5000ms"]); expect(readFileSync(join(probe.directory, `send-${send}/failure.txt`), "utf8")).toContain("No provider selected"); @@ -146,6 +182,60 @@ for (const send of ["type", "press"] as const) { }, 20_000); } +it("retains native startup exit status and current-run error logs without retrying or entering the test", async () => { + const probe = fixture("startup-exit"); + mkdirSync(join(probe.directory, "opencode/log"), { recursive: true }); + writeFileSync(join(probe.directory, "opencode/log/opencode.log"), "level=ERROR message=previous-scenario\n"); + const run: HostRun = { + output: probe.directory, + env: { HOME: probe.directory, XDG_DATA_HOME: probe.directory, PATH: "/usr/bin:/bin" }, + receipt: { + host: "v2", + binary: "unused", + version: "2.0.15", + platform: process.platform, + terminalControl: "1.2.1", + driverBinary: probe.binaryPath, + negativeControl: false, + checks: [], + cleanupErrors: [], + }, + }; + let entered = false; + try { + await expect( + scenario(run, "startup-exit", async () => { + entered = true; + }), + ).rejects.toThrow("OpenCode capture ended with exited"); + expect(entered).toBe(false); + expect(run.receipt.cleanupErrors).toEqual([]); + expect(run.receipt.checks[0]).toEqual({ + name: "startup-exit", + status: "fail", + error: "OpenCode capture ended with exited", + }); + expect(run.receipt.providersDisabled).toBeUndefined(); + expect(JSON.parse(readFileSync(join(probe.directory, "startup-exit/failure-status.json"), "utf8"))).toEqual({ + state: "exited", + exit: { code: 1, signal: null, success: false }, + }); + expect(readFileSync(join(probe.directory, "startup-exit/host-error.log"), "utf8")).toBe( + "level=ERROR message=synthetic-startup-cause\n", + ); + expect( + readFileSync(join(probe.directory, "requests.txt"), "utf8") + .split("\n") + .filter((method) => method === "launch"), + ).toHaveLength(1); + expect(present(Number(readFileSync(join(probe.directory, "host.pid"), "utf8")))).toBe(false); + expect(run.driver).toBeUndefined(); + } finally { + if (run.driver && !run.driver.reaped) await closeDriver(run.driver, 1_000).catch(() => {}); + rmSync(probe.directory, { recursive: true, force: true }); + } +}, 20_000); + it("retains unresolved ownership across cleanup retries and blocks later launches", async () => { const probe = fixture("shutdown-stall"); const env = Object.fromEntries(Object.keys(process.env).map((key) => [key, undefined])); diff --git a/test/e2e/fixture/package.json b/test/e2e/fixture/package.json new file mode 100644 index 0000000..ac64947 --- /dev/null +++ b/test/e2e/fixture/package.json @@ -0,0 +1,8 @@ +{ + "name": "vimcode-e2e-question-fixture", + "private": true, + "type": "module", + "exports": { + "./tui": "./tui.ts" + } +} diff --git a/test/e2e/fixture/tui.ts b/test/e2e/fixture/tui.ts new file mode 100644 index 0000000..d90a79d --- /dev/null +++ b/test/e2e/fixture/tui.ts @@ -0,0 +1,107 @@ +type Context = { + options: { directory: string }; + keymap: { + layer( + input: () => { + mode: "global"; + commands: Array<{ + id: string; + title: string; + group: string; + palette: true; + bind: string; + run(): Promise; + }>; + }, + ): void; + }; + ui: { + slot(input: { append: "app"; render(): null }): () => void; + router: { navigate(route: { type: "session"; sessionID: string }): void }; + }; + client: { + session: { + create(input: { title: string; location: { directory: string } }): Promise<{ id: string }>; + form: { + create(input: { + sessionID: string; + title: string; + fields: Array<{ key: string; type: "string"; title: string; required: true }>; + }): Promise; + }; + }; + }; +}; + +type V1Api = { + keymap: { + registerLayer(input: { + commands: Array<{ name: string; title: string; run(): Promise }>; + bindings: Array<{ key: string; cmd: string }>; + }): void; + }; + route: { navigate(name: "session", params: { sessionID: string }): void }; + client: { + session: { + create( + input: { directory: string; title: string }, + options: { throwOnError: true }, + ): Promise<{ data: { id: string } }>; + }; + }; +}; + +// Sets up a real question without a model call or reading/mutating editor state. +export default { + id: "vimcode-e2e-question-fixture", + async tui(api: V1Api, options: { directory: string }) { + api.keymap.registerLayer({ + commands: [ + { + name: "vimcode.e2e.question", + title: "Open synthetic question", + async run() { + const session = await api.client.session.create( + { directory: options.directory, title: "Vimcode E2E session" }, + { throwOnError: true }, + ); + api.route.navigate("session", { sessionID: session.data.id }); + }, + }, + ], + bindings: [{ key: "ctrl+g", cmd: "vimcode.e2e.question" }], + }); + }, + setup(context: Context) { + return context.ui.slot({ + append: "app", + render() { + context.keymap.layer(() => ({ + mode: "global", + commands: [ + { + id: "vimcode.e2e.question", + title: "Open synthetic question", + group: "E2E", + palette: true, + bind: "ctrl+g", + async run() { + const session = await context.client.session.create({ + title: "Vimcode E2E session", + location: { directory: context.options.directory }, + }); + context.ui.router.navigate({ type: "session", sessionID: session.id }); + await context.client.session.form.create({ + sessionID: session.id, + title: "Vimcode E2E question", + fields: [{ key: "answer", type: "string", title: "Answer", required: true }], + }); + }, + }, + ], + })); + return null; + }, + }); + }, +}; diff --git a/test/e2e/fixture/v1-server.ts b/test/e2e/fixture/v1-server.ts new file mode 100644 index 0000000..d25aab7 --- /dev/null +++ b/test/e2e/fixture/v1-server.ts @@ -0,0 +1,26 @@ +// Runs inside v1's server worker so the native question uses its real event bus. +export default { + id: "vimcode-e2e-v1-question-server", + async server(input: { directory: string }, options: { v1Bridge: string }) { + return { + async event({ event }: { event: { type: string; properties: { info?: { id: string; title: string } } } }) { + const session = event.properties.info; + if (event.type !== "session.created" || session?.title !== "Vimcode E2E session") return; + const { AppRuntime, InstanceStore, Question } = await import(options.v1Bridge); + await AppRuntime.runPromise( + InstanceStore.Service.use((store: { provide(input: object, effect: unknown): unknown }) => + store.provide( + { directory: input.directory }, + Question.Service.use((question: { ask(input: object): unknown }) => + question.ask({ + sessionID: session.id, + questions: [{ header: "Answer", question: "Vimcode E2E question", options: [] }], + }), + ), + ), + ), + ).catch(() => {}); + }, + }; + }, +}; diff --git a/test/e2e/host.ts b/test/e2e/host.ts index 7ca4972..24db92e 100644 --- a/test/e2e/host.ts +++ b/test/e2e/host.ts @@ -11,7 +11,15 @@ import { type TerminalControl, } from "@kitlangton/terminal-control"; import { bounded, closeDriver, type OwnedDriver, ownDriver } from "./driver"; -import { promptMatches, providerDisabledPrompt, type VisiblePrompt, visiblePrompt } from "./prompt"; +import { + promptMatches, + providerDisabledPrompt, + type VisiblePrompt, + visibleAnswer, + visiblePrompt, + visibleV1AnswerChoice, +} from "./prompt"; +import { v1QuestionBridge } from "./v1-question"; const root = realpathSync(fileURLToPath(new URL("../../", import.meta.url))); const configNames = [ @@ -33,6 +41,7 @@ type Receipt = { platform: string; terminalControl: string; driverBinary?: string; + questionSetupModules?: Array<{ path: string; sha256: string }>; negativeControl: boolean; providersDisabled?: boolean; providerObservations?: Record; @@ -49,6 +58,7 @@ export type HostRun = { driver?: OwnedDriver; session?: Session; scenario?: string; + hostLogOffset?: number; }; export function saveReceipt(run: HostRun) { @@ -171,10 +181,42 @@ export function prepareRun(): HostRun { run.receipt.artifact = { tarball, sha256: hash(readFileSync(tarball)) }; const spec = `vimcode@file:${tarball}`; const options = { updateCheck: false, modeIndicator: "none", experimentalV2Leader: "ctrl+x" }; + let v1Bridge: string | undefined; + if (host === "v1" && !run.receipt.negativeControl) { + const bridge = v1QuestionBridge(readFileSync(actualBinary)); + v1Bridge = join(run.output, "v1-question-bridge.ts"); + writeFileSync(v1Bridge, bridge.source); + run.receipt.questionSetupModules = bridge.modules; + // The plain setup fixture has no npm imports; bound v1's optional config + // dependency install so an unavailable registry cannot stall startup. + writeFileSync(join(run.output, "config/opencode/.npmrc"), "fetch-retries=0\nfetch-timeout=1000\n"); + writeFileSync( + join(run.output, "config/opencode/opencode.json"), + JSON.stringify({ + autoupdate: false, + enabled_providers: [], + plugin: [[join(root, "test/e2e/fixture/v1-server.ts"), { v1Bridge }]], + }), + ); + } const config = host === "v1" - ? { plugin: run.receipt.negativeControl ? [] : [[spec, options]], keybinds: { leader: "ctrl+x" } } - : { plugins: run.receipt.negativeControl ? [] : [{ package: spec, options }], keybinds: { leader: "ctrl+x" } }; + ? { + plugin: run.receipt.negativeControl + ? [] + : [ + [spec, options], + [join(root, "test/e2e/fixture/tui.ts"), { directory: join(run.output, "sandbox"), v1Bridge }], + ], + keybinds: { leader: "ctrl+x" }, + } + : { + plugins: [ + ...(run.receipt.negativeControl ? [] : [{ package: spec, options }]), + { package: join(root, "test/e2e/fixture"), options: { directory: join(run.output, "sandbox") } }, + ], + keybinds: { leader: "ctrl+x" }, + }; writeFileSync(join(run.output, `config/opencode/${host === "v1" ? "tui" : "cli"}.json`), JSON.stringify(config)); saveReceipt(run); return run; @@ -220,6 +262,8 @@ export async function startScenario(run: HostRun, name: string) { if (run.driver) throw new Error("Previous owned driver has not been reaped; refusing another launch"); run.scenario = name; mkdirSync(join(run.output, name)); + const hostLog = join(run.env.XDG_DATA_HOME ?? run.output, "opencode/log/opencode.log"); + run.hostLogOffset = existsSync(hostLog) ? statSync(hostLog).size : 0; // The driver also gets no provider credentials; launch independently rejects inheritance. const driverEnv = Object.fromEntries(Object.keys(process.env).map((key) => [key, undefined])); Object.assign(driverEnv, run.env); @@ -237,7 +281,10 @@ export async function startScenario(run: HostRun, name: string) { host: "opentui", viewport: { cols: 110, rows: 34 }, inheritEnv: false, - env: run.env, + env: { + ...run.env, + ...(run.receipt.host === "v2" ? { OPENCODE_LOG_LEVEL: "error" } : {}), + }, record: "on-failure", }), "host launch", @@ -318,13 +365,35 @@ export async function pressKey(run: HostRun, key: Key, timeoutMs = 5_000) { await bounded(sessionFor(run).keyboard.press(key), "keyboard press", timeoutMs); } -export async function checkpoint(run: HostRun, name: string, lines: string[], cursor?: { x: number; y: number }) { +export async function writeInput(run: HostRun, text: string, timeoutMs = 5_000) { + await bounded(sessionFor(run).keyboard.write(new TextEncoder().encode(text)), "keyboard raw write", timeoutMs); +} + +export async function openQuestion(run: HostRun) { + await pressKey(run, "Control+G"); + if (run.receipt.host === "v1") { + await checkpoint(run, "answer-choice", ["Type your own answer"], undefined, visibleV1AnswerChoice); + await pressKey(run, "Enter"); + } +} + +export async function answerCheckpoint(run: HostRun, name: string, text: string) { + await checkpoint(run, name, [text], undefined, visibleAnswer); +} + +export async function checkpoint( + run: HostRun, + name: string, + lines: string[], + cursor?: { x: number; y: number }, + readPrompt = visiblePrompt, +) { let actual: VisiblePrompt | null = null; try { const capture = await waitVisible( run, (snapshot) => { - actual = visiblePrompt(snapshot); + actual = readPrompt(snapshot); return promptMatches(actual, lines, cursor); }, 5_000, @@ -356,10 +425,30 @@ export async function saveFailure(run: HostRun, error: unknown) { writeFileSync(join(directory, "failure.txt"), capture.text); writeFileSync(join(directory, "failure.json"), JSON.stringify(capture, null, 2)); await bounded(run.session.saveRecording(join(directory, "failure.termctrl")), "failure recording"); + if (capture.reason === "exited" || capture.reason === "outputclosed") { + const { state, exit } = await bounded(run.session.status(), "failure exit status"); + writeFileSync(join(directory, "failure-status.json"), JSON.stringify({ state, exit }, null, 2)); + } } catch (captureError) { run.receipt.checks.push({ name: "failure-evidence", status: "fail", error: String(captureError) }); } } + try { + const hostLog = join(run.env.XDG_DATA_HOME ?? run.output, "opencode/log/opencode.log"); + if (existsSync(hostLog)) { + const log = readFileSync(hostLog); + const offset = (run.hostLogOffset ?? 0) <= log.length ? (run.hostLogOffset ?? 0) : 0; + const errors = log + .subarray(offset) + .toString() + .split("\n") + .filter((line) => /level=(?:ERROR|FATAL)|"level":"(?:error|fatal)"/.test(line)); + if (errors.length) + writeFileSync(join(run.output, run.scenario ?? "", "host-error.log"), `${errors.join("\n")}\n`); + } + } catch (logError) { + run.receipt.checks.push({ name: "failure-evidence", status: "fail", error: String(logError) }); + } saveReceipt(run); } diff --git a/test/e2e/opencode.e2e.ts b/test/e2e/opencode.e2e.ts index b5616c9..e151dcb 100644 --- a/test/e2e/opencode.e2e.ts +++ b/test/e2e/opencode.e2e.ts @@ -1,5 +1,16 @@ import { beforeAll, describe, it } from "bun:test"; -import { checkpoint, type HostRun, prepareRun, pressKey, scenario, typeText, verifyInstalledArtifact } from "./host"; +import { + answerCheckpoint, + checkpoint, + type HostRun, + openQuestion, + prepareRun, + pressKey, + scenario, + typeText, + verifyInstalledArtifact, + writeInput, +} from "./host"; const negativeControl = process.env.VIMCODE_E2E_NO_PLUGIN === "1"; // Leave time for bounded sends/captures and finally cleanup before Bun's outer timeout. @@ -66,6 +77,29 @@ describe("Terminal Control / cache-installed OpenCode", () => { scenarioTimeoutMs, ); + it.skipIf(negativeControl)( + "native paste waits for queued append in a question answer", + async () => { + await scenario(run, "answer-paste", async () => { + verifyInstalledArtifact(run); + await openQuestion(run); + await answerCheckpoint( + run, + "answer-ready", + run.receipt.host === "v1" ? "Type your own answer" : "Type your answer", + ); + await typeText(run, "hello world"); + await answerCheckpoint(run, "answer-insert", "hello world"); + await pressKey(run, "Escape"); + await answerCheckpoint(run, "answer-normal", "hello world"); + // One PTY write: no timer turn separates append from native bracketed paste. + await writeInput(run, "a\x1b[200~X\x1b[201~"); + await answerCheckpoint(run, "answer-paste-result", "hello worldX"); + }); + }, + scenarioTimeoutMs, + ); + it.skipIf(negativeControl)( "multiline dG and one u restore the entire prompt", async () => { diff --git a/test/e2e/prompt.ts b/test/e2e/prompt.ts index da3b2f5..05f7593 100644 --- a/test/e2e/prompt.ts +++ b/test/e2e/prompt.ts @@ -34,6 +34,75 @@ export function visiblePrompt(snapshot: ScreenSnapshot): VisiblePrompt | null { }; } +export function visibleV1AnswerChoice(snapshot: ScreenSnapshot): VisiblePrompt | null { + const rows = snapshot.text.split("\n"); + const titles = rows.flatMap((row, y) => { + const match = /^( *)┃ {2}Vimcode E2E question *$/.exec(row); + return match ? [{ y, x: match[1].length }] : []; + }); + if (titles.length !== 1) return null; + const { x, y } = titles[0]; + const border = `${" ".repeat(x)}┃`; + if ( + [-1, 1, 3, 5].some((offset) => rows[y + offset]?.trimEnd() !== border) || + rows[y + 2]?.trimEnd() !== `${border} 1. Type your own answer` || + rows[y + 4]?.trimEnd() !== `${border} ↑↓ select enter submit esc dismiss` + ) + return null; + return { lines: ["Type your own answer"], origin: { x: x + 6, y: y + 2 }, cursor: null }; +} + +// Exact one-line answer layouts on the pinned hosts, not timeline text. +export function visibleAnswer(snapshot: ScreenSnapshot): VisiblePrompt | null { + const rows = snapshot.text.split("\n"); + const titles = rows.flatMap((row, y) => { + const match = /^( *)┃ {2}Vimcode E2E question *$/.exec(row); + return match ? [{ y, x: match[1].length }] : []; + }); + if (titles.length !== 1) return null; + const { x, y } = titles[0]; + const border = `${" ".repeat(x)}┃`; + const content = (offset: number) => + rows[y + offset]?.startsWith(border) ? rows[y + offset].slice(x + 1).trimEnd() : undefined; + if ( + rows[y - 1]?.trimEnd() === border && + [1, 4, 6].every((offset) => rows[y + offset]?.trimEnd() === border) && + content(2) === " 1. Type your own answer" && + content(5) === " ↑↓ select enter submit esc dismiss" && + rows[y + 3]?.startsWith(`${border} `) + ) { + const origin = { x: x + 6, y: y + 3 }; + const cursor = snapshot.frame.cursor; + return { + lines: [rows[origin.y].slice(origin.x).trimEnd()], + origin, + cursor: + cursor && cursor.y === origin.y && cursor.x >= origin.x + ? { x: cursor.x - origin.x, y: 0, style: cursor.style } + : null, + }; + } + if ( + rows[y - 1]?.trimEnd() !== border || + [1, 3, 5, 7, 9].some((offset) => rows[y + offset]?.trimEnd() !== border) || + content(2) !== " Answer Submit" || + content(4) !== " Answer" || + content(8) !== " ⇆ tab enter confirm esc dismiss" || + !rows[y + 6]?.startsWith(`${border} `) + ) + return null; + const origin = { x: x + 4, y: y + 6 }; + const cursor = snapshot.frame.cursor; + return { + lines: [rows[origin.y].slice(origin.x).trimEnd()], + origin, + cursor: + cursor && cursor.y === origin.y && cursor.x >= origin.x + ? { x: cursor.x - origin.x, y: 0, style: cursor.style } + : null, + }; +} + export function providerDisabledPrompt(snapshot: ScreenSnapshot): boolean { const prompt = visiblePrompt(snapshot); if (!prompt) return false; diff --git a/test/e2e/prompt.unit.ts b/test/e2e/prompt.unit.ts index 478f76b..1271224 100644 --- a/test/e2e/prompt.unit.ts +++ b/test/e2e/prompt.unit.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { ScreenSnapshot } from "@kitlangton/terminal-control"; -import { promptMatches, providerDisabledPrompt, visiblePrompt } from "./prompt"; +import { promptMatches, providerDisabledPrompt, visibleAnswer, visiblePrompt, visibleV1AnswerChoice } from "./prompt"; function screen(lines: string[], outside = "one Xtwo three"): ScreenSnapshot { return { @@ -20,6 +20,99 @@ function screen(lines: string[], outside = "one Xtwo three"): ScreenSnapshot { }; } +describe("pinned-v1 custom answer screen oracle", () => { + function answerScreen(text?: string): ScreenSnapshot { + const snapshot = screen([]); + return { + ...snapshot, + text: [ + "hello worldX outside the question", + " ┃", + " ┃ Vimcode E2E question", + " ┃", + " ┃ 1. Type your own answer", + ...(text === undefined ? [] : [` ┃ ${text}`]), + " ┃", + " ┃ ↑↓ select enter submit esc dismiss", + " ┃", + ].join("\n"), + frame: { ...snapshot.frame, cursor: null }, + }; + } + + it("requires the exact custom-choice screen before Enter opens the editor", () => { + const choice = answerScreen(); + expect(promptMatches(visibleV1AnswerChoice(choice), ["Type your own answer"])).toBe(true); + expect(visibleAnswer(choice)).toBeNull(); + expect(visibleV1AnswerChoice(answerScreen("hello worldX"))).toBeNull(); + expect(visibleV1AnswerChoice({ ...choice, text: choice.text.replace("enter submit", "enter toggle") })).toBeNull(); + }); + + it("compares the entire editor line and rejects malformed borders or confirmation screens", () => { + expect(promptMatches(visibleAnswer(answerScreen("hello worldX")), ["hello worldX"])).toBe(true); + expect(promptMatches(visibleAnswer(answerScreen("hello worlXd")), ["hello worldX"])).toBe(false); + expect(promptMatches(visibleAnswer(answerScreen("extra hello worldX")), ["hello worldX"])).toBe(false); + expect(promptMatches(visibleAnswer(answerScreen("hello worldX")), ["hello worldX"])).toBe(false); + const snapshot = answerScreen("hello worldX"); + for (const text of [ + `${snapshot.text}\n ┃ Vimcode E2E question`, + snapshot.text.replace(" ┃ hello", " x hello"), + snapshot.text.replace("1. Type your own answer", "Review"), + snapshot.text.replace("enter submit", "enter confirm"), + ]) + expect(visibleAnswer({ ...snapshot, text })).toBeNull(); + }); +}); + +describe("pinned-v2 scalar answer screen oracle", () => { + function answerScreen(text: string): ScreenSnapshot { + const snapshot = screen([]); + return { + ...snapshot, + text: [ + "hello worldX outside the form", + " ┃", + " ┃ Vimcode E2E question", + " ┃", + " ┃ Answer Submit", + " ┃", + " ┃ Answer", + " ┃", + ` ┃ ${text}`, + " ┃", + " ┃ ⇆ tab enter confirm esc dismiss", + " ┃", + ].join("\n"), + frame: { + ...snapshot.frame, + cursor: { x: 18, y: 8, style: "bar", blinking: false, color: { r: 255, g: 255, b: 255 } }, + }, + }; + } + + it("compares the entire answer, not matching timeline text or a suffix", () => { + expect(promptMatches(visibleAnswer(answerScreen("hello worldX")), ["hello worldX"])).toBe(true); + expect(promptMatches(visibleAnswer(answerScreen("hello worlXd")), ["hello worldX"])).toBe(false); + expect(promptMatches(visibleAnswer(answerScreen("extra hello worldX")), ["hello worldX"])).toBe(false); + expect(promptMatches(visibleAnswer(answerScreen("hello worldX")), ["hello worldX"])).toBe(false); + expect(visibleAnswer(answerScreen("hello worldX"))?.cursor).toEqual({ x: 12, y: 0, style: "bar" }); + }); + + it("rejects ambiguous titles, missing borders and choice/confirmation screens", () => { + const snapshot = answerScreen("hello worldX"); + for (const text of [ + `${snapshot.text}\n ┃ Vimcode E2E question`, + snapshot.text.replace(" ┃ Answer Submit", " x Answer Submit"), + snapshot.text.replace(" ┃ Answer\n", " ┃ Review\n"), + snapshot.text.replace("enter confirm", "enter toggle"), + snapshot.text.replace(" ┃ hello worldX", " ┃ hello worldX"), + snapshot.text.replace(" ┃\n ┃ hello", "missing border\n ┃ hello"), + ]) { + expect(visibleAnswer({ ...snapshot, text })).toBeNull(); + } + }); +}); + describe("pinned-host home prompt screen oracle", () => { it("ignores matching text outside the prompt and rejects a matching suffix", () => { expect(promptMatches(visiblePrompt(screen(["wrong"])), ["one Xtwo three"])).toBe(false); diff --git a/test/e2e/v1-question.ts b/test/e2e/v1-question.ts new file mode 100644 index 0000000..b9346cb --- /dev/null +++ b/test/e2e/v1-question.ts @@ -0,0 +1,36 @@ +import { createHash } from "node:crypto"; + +// v1 has no question-create endpoint. Resolve only these pinned, embedded exports +// so the setup fixture can call the native service without copying or patching it. +export function v1QuestionBridge(binary: Buffer) { + function embedded(marker: string) { + const offset = binary.indexOf(marker); + if (offset < 0) throw new Error(`Pinned v1 question setup missing ${marker}`); + const start = binary.lastIndexOf(0, offset); + const end = binary.indexOf(0, offset); + const pathStart = binary.lastIndexOf(0, start - 1); + const path = binary.subarray(pathStart + 1, start).toString(); + const source = binary.subarray(start + 1, end).toString(); + if (!/^\/\$bunfs\/root\/chunk-[\w]+\.js$/.test(path) || !source.startsWith("// @bun")) { + throw new Error("Pinned v1 embedded module layout changed"); + } + return { path, source, sha256: createHash("sha256").update(source).digest("hex") }; + } + const runtime = embedded(" as AppRuntime,"); + const store = embedded(" as InstanceStore}"); + const question = embedded("Question.ask"); + const namespace = /Question:\(\)=>([\w$]+)/.exec(question.source)?.[1]; + const exports = question.source.slice(question.source.lastIndexOf("export{")); + const questionExport = namespace && new RegExp(`(?:\\{|,)${namespace} as ([\\w$]+)[,}]`).exec(exports)?.[1]; + if (!questionExport || !question.source.includes('Service()("@opencode/Question")')) { + throw new Error("Pinned v1 Question service export changed"); + } + return { + source: [ + `export { AppRuntime } from ${JSON.stringify(runtime.path)};`, + `export { InstanceStore } from ${JSON.stringify(store.path)};`, + `export { ${questionExport} as Question } from ${JSON.stringify(question.path)};`, + ].join("\n"), + modules: [runtime, store, question].map(({ path, sha256 }) => ({ path, sha256 })), + }; +} diff --git a/test/e2e/v1-question.unit.ts b/test/e2e/v1-question.unit.ts new file mode 100644 index 0000000..a8cb8f4 --- /dev/null +++ b/test/e2e/v1-question.unit.ts @@ -0,0 +1,48 @@ +import { expect, it } from "bun:test"; +import { v1QuestionBridge } from "./v1-question"; + +function binary( + question = 'var q={};map(q,{Question:()=>q});class Service extends Tag.Service()("@opencode/Question"){};var ask="Question.ask";export{q as Q};', +) { + return Buffer.from( + [ + "native prefix", + "/$bunfs/root/chunk-runtime.js", + "// @bun\nexport{r as AppRuntime,l as AppLayer};", + "/$bunfs/root/chunk-store.js", + "// @bun\nexport{s as InstanceStore};", + "/$bunfs/root/chunk-question.js", + `// @bun\n${question}`, + "native suffix", + ].join("\0"), + ); +} + +it("resolves native v1 question setup exports and records their identities without copying code", () => { + const bridge = v1QuestionBridge(binary()); + expect(bridge.source).toBe( + [ + 'export { AppRuntime } from "/$bunfs/root/chunk-runtime.js";', + 'export { InstanceStore } from "/$bunfs/root/chunk-store.js";', + 'export { Q as Question } from "/$bunfs/root/chunk-question.js";', + ].join("\n"), + ); + expect(bridge.modules).toHaveLength(3); + expect(bridge.modules.every(({ sha256 }) => /^[a-f0-9]{64}$/.test(sha256))).toBe(true); + expect(bridge.source).not.toContain("class Service"); +}); + +it("fails closed when pinned embedded layout or service exports change", () => { + expect(() => v1QuestionBridge(Buffer.from("not a native host"))).toThrow("missing"); + expect(() => + v1QuestionBridge(Buffer.from(binary().toString().replace("/$bunfs/root/chunk-question.js", "/untrusted.js"))), + ).toThrow("layout changed"); + expect(() => + v1QuestionBridge(binary('var q={};map(q,{Question:()=>q});var ask="Question.ask";export{q as Q};')), + ).toThrow("service export changed"); + expect(() => + v1QuestionBridge( + binary('class Service extends Tag.Service()("@opencode/Question"){};var ask="Question.ask";export{q as Q};'), + ), + ).toThrow("service export changed"); +}); diff --git a/test/question-answer-support.ts b/test/question-answer-support.ts new file mode 100644 index 0000000..eef3bcf --- /dev/null +++ b/test/question-answer-support.ts @@ -0,0 +1,464 @@ +import { mock } from "bun:test"; +import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import { registerEnabledFields, registerLeader } from "@opentui/keymap/addons"; +import { createTestKeymap, type TestKeyModifierOptions, TestKeymapEvent } from "@opentui/keymap/testing"; +import plugin from "../src/index"; +import type { V2Context } from "../src/v2"; + +// Exercise register actions without reading or writing the desktop clipboard. +export const clipboardWrites: string[] = []; +mock.module("../src/clipboard", () => ({ + writeClipboard(text: string) { + clipboardWrites.push(text); + }, +})); + +type PasteMetadata = Parameters[1]; +type PasteInputEvent = { + bytes: Uint8Array; + metadata?: PasteMetadata; + defaultPrevented: boolean; + propagationStopped: boolean; + preventDefault(): void; + stopPropagation(): void; +}; + +export function answerEditor(text = "hello world", status = "ANSWER") { + let selection: { start: number; end: number } | undefined; + const editor = { + traits: { status }, + plainText: text, + cursorOffset: text.length, + cursorStyle: { style: "block", blinking: true }, + get visualCursor() { + return { logicalRow: editor.plainText.slice(0, editor.cursorOffset).split("\n").length - 1 }; + }, + insertText(value: string) { + editor.plainText = + editor.plainText.slice(0, editor.cursorOffset) + value + editor.plainText.slice(editor.cursorOffset); + editor.cursorOffset += value.length; + }, + setSelectionInclusive(start: number, end: number) { + selection = { start, end: end + 1 }; + }, + editorView: { + resetSelection() { + selection = undefined; + }, + setSelection(start: number, end: number) { + selection = { start, end }; + }, + getSelectedText() { + return selection ? editor.plainText.slice(selection.start, selection.end) : ""; + }, + }, + editBuffer: { + getTextRange(start: number, end: number) { + return editor.plainText.slice(start, end); + }, + setText(value: string) { + editor.plainText = value; + }, + deleteRange(sl: number, sc: number, el: number, ec: number) { + const lines = editor.plainText.split("\n"); + const start = lines.slice(0, sl).reduce((n, line) => n + line.length + 1, 0) + sc; + const end = lines.slice(0, el).reduce((n, line) => n + line.length + 1, 0) + ec; + editor.plainText = editor.plainText.slice(0, start) + editor.plainText.slice(end); + editor.cursorOffset = start; + }, + }, + command(command: string) { + const offset = editor.cursorOffset; + if (command === "input.move.left") editor.cursorOffset = Math.max(0, offset - 1); + if (command === "input.move.right") editor.cursorOffset = Math.min(editor.plainText.length, offset + 1); + if (command === "input.select.right" || command === "input.select.left") { + editor.cursorOffset = Math.max( + 0, + Math.min(editor.plainText.length, offset + (command.endsWith("right") ? 1 : -1)), + ); + selection = { start: Math.min(offset, editor.cursorOffset), end: Math.max(offset, editor.cursorOffset) }; + } + if (command === "input.delete") + editor.plainText = editor.plainText.slice(0, offset) + editor.plainText.slice(offset + 1); + if (command === "input.backspace" && selection) { + editor.plainText = editor.plainText.slice(0, selection.start) + editor.plainText.slice(selection.end); + editor.cursorOffset = selection.start; + selection = undefined; + } + }, + get selection() { + return selection; + }, + }; + return editor; +} + +export async function questionHost(version: "v1" | "v2", options: Record = {}) { + const { keymap, host, diagnostics, cleanup } = createTestKeymap({ defaultKeys: true }); + registerEnabledFields(keymap); + registerLeader(keymap, { trigger: "space" }); + const disposals: Array<() => void> = [cleanup]; + const rawListeners: Array<(event: TestKeymapEvent) => void> = []; + const pasteListeners: Array<(event: PasteInputEvent) => void> = []; + function prependInput(event: "keypress", listener: (event: TestKeymapEvent) => void): void; + function prependInput(event: "paste", listener: (event: PasteInputEvent) => void): void; + function prependInput( + event: "keypress" | "paste", + listener: ((event: TestKeymapEvent) => void) | ((event: PasteInputEvent) => void), + ) { + if (event === "keypress") rawListeners.unshift(listener as (event: TestKeymapEvent) => void); + else pasteListeners.unshift(listener as (event: PasteInputEvent) => void); + } + function removeInput(event: "keypress", listener: (event: TestKeymapEvent) => void): void; + function removeInput(event: "paste", listener: (event: PasteInputEvent) => void): void; + function removeInput( + event: "keypress" | "paste", + listener: ((event: TestKeymapEvent) => void) | ((event: PasteInputEvent) => void), + ) { + if (event === "keypress") { + const index = rawListeners.indexOf(listener as (event: TestKeymapEvent) => void); + if (index >= 0) rawListeners.splice(index, 1); + } else { + const index = pasteListeners.indexOf(listener as (event: PasteInputEvent) => void); + if (index >= 0) pasteListeners.splice(index, 1); + } + } + const commands: Array<{ id: string; run(): void | Promise }> = []; + const dispatched: string[] = []; + const hostActions: string[] = []; + const committedAnswers: string[] = []; + const navigatedAnswers: string[] = []; + const paletteSubmissions: string[] = []; + const pasteReplays: Array<{ text: string; metadata?: PasteMetadata }> = []; + const toasts: string[] = []; + const events = new Map void>(); + const main = answerEditor("main prompt", "PROMPT"); + const renderer = { + currentFocusedEditor: main as ReturnType | undefined, + currentFocusedRenderable: main as object | undefined, + keyInput: { + prependListener: prependInput, + off: removeInput, + processParsedKey(event: TestKeyModifierOptions & { name: string }) { + press(event.name, event); + return true; + }, + processPaste(bytes: Uint8Array, metadata?: PasteMetadata) { + const text = new TextDecoder().decode(bytes); + pasteReplays.push({ text, metadata }); + paste(text, metadata); + }, + }, + }; + let mode = "base"; + let sessionID = "root"; + let question = false; + let permission = false; + let dialog = false; + let paletteReturn: { editor: ReturnType | undefined; mode: string } | undefined; + keymap.registerLayer({ + commands: [ + { + name: "command.palette.show", + run() { + paletteReturn = { editor: renderer.currentFocusedEditor, mode }; + function openPalette() { + dialog = true; + mode = "modal"; + const input = answerEditor("", "PALETTE"); + if (options.delayedPalette) queueMicrotask(() => focus(input)); + else focus(input); + hostActions.push("palette"); + } + if (options.delayedPalette) queueMicrotask(openPalette); + else openPalette(); + }, + }, + ], + }); + keymap.registerLayer({ + enabled: () => mode === "modal" && paletteReturn !== undefined, + commands: [ + { + name: "palette.input", + run() { + hostActions.push("palette:h"); + }, + }, + { + name: "palette.submit", + run() { + hostActions.push("palette:submit"); + paletteSubmissions.push(renderer.currentFocusedEditor?.plainText ?? ""); + }, + }, + { + name: "palette.close", + run() { + if (!paletteReturn) throw new Error("Palette not open"); + dialog = false; + mode = paletteReturn.mode; + focus(paletteReturn.editor); + paletteReturn = undefined; + }, + }, + ], + bindings: [ + { key: "h", cmd: "palette.input" }, + { key: "return", cmd: "palette.submit" }, + { key: "escape", cmd: "palette.close" }, + ], + }); + const settings = { disabled: options.disabled === true, lastUpdateCheck: "" }; + const inputTarget = host.rootTarget.append(host.createTarget("editor")); + host.focus(inputTarget); + const isEditing = () => mode === (version === "v1" ? "question" : "form") && !!renderer.currentFocusedEditor; + keymap.registerLayer({ + enabled: isEditing, + commands: [ + { + name: "answer.commit", + run() { + hostActions.push("commit"); + committedAnswers.push(renderer.currentFocusedEditor?.plainText ?? ""); + focus(undefined); + }, + }, + { + name: "answer.cancel", + run() { + hostActions.push("cancel"); + focus(undefined); + }, + }, + { + name: "answer.tab", + run() { + hostActions.push("tab"); + navigatedAnswers.push(renderer.currentFocusedEditor?.plainText ?? ""); + focus(undefined); + }, + }, + ], + bindings: [ + { key: "return", cmd: "answer.commit" }, + { key: "ctrl+return", cmd: "answer.commit" }, + { key: "escape", cmd: "answer.cancel" }, + { key: "tab", cmd: "answer.tab" }, + ], + }); + keymap.registerLayer({ + commands: [ + "input.move.left", + "input.move.right", + "input.move.down", + "input.move.up", + "input.select.left", + "input.select.right", + "input.delete", + "input.backspace", + "input.undo", + ].map((name) => ({ + name, + run() { + renderer.currentFocusedEditor?.command(name); + }, + })), + }); + const dispatch = (command: string) => { + dispatched.push(command); + return keymap.dispatchCommand(command); + }; + const api = { + renderer, + keymap: { + intercept: keymap.intercept.bind(keymap), + dispatchCommand: dispatch, + mode: options.noHostMode ? undefined : { current: () => mode }, + registerLayer(input: { commands: Array<{ name: string; run(): void | Promise }> }) { + commands.push(...input.commands.map((command) => ({ id: command.name, run: command.run }))); + }, + }, + ui: { + toast(input: { message: string }) { + toasts.push(input.message); + }, + dialog: { + get open() { + return dialog; + }, + }, + }, + route: { + get current() { + return { name: "session", params: { sessionID } }; + }, + }, + state: { + session: { + get(id: string) { + return id === "child" ? { parentID: "root" } : {}; + }, + question: () => (question ? [{}] : []), + permission: () => (permission ? [{}] : []), + }, + }, + kv: { + get: async () => settings.disabled, + set: async (_key: string, value: unknown) => { + settings.disabled = value === true; + }, + }, + event: { + on(name: string, handler: (event: { properties: { sessionID: string } }) => void) { + events.set(name, handler); + return () => { + events.delete(name); + }; + }, + }, + lifecycle: { + onDispose(dispose: () => void) { + disposals.push(dispose); + }, + }, + tuiConfig: { keybinds: { get: () => [{ key: "space" }] } }, + }; + if (version === "v1") await plugin.tui(api as unknown as TuiPluginApi, { updateCheck: false, ...options }); + else { + const context: V2Context = { + options: { updateCheck: false, experimentalV2Leader: "space", ...options }, + renderer, + keymap: { + dispatch, + mode: { current: () => mode }, + layer(input) { + commands.push(...input().commands); + }, + }, + storage: { + store() { + return [ + settings as unknown as T, + async (update: (value: T) => void) => update(settings as unknown as T), + ] as const; + }, + }, + data: { + on: () => () => {}, + session: { + root: (id) => (id === "child" ? "root" : id), + family: () => ["root", "child"], + form: { list: () => (question ? [{}] : []) }, + permission: { list: () => (permission ? [{}] : []) }, + }, + }, + ui: { + toast: { show: api.ui.toast }, + router: { current: () => ({ type: "session", sessionID }) }, + slot({ render }) { + render(); + return () => {}; + }, + }, + }; + disposals.push(await plugin.setup(context)); + } + function focus(editor: ReturnType | undefined) { + renderer.currentFocusedEditor = editor; + renderer.currentFocusedRenderable = editor; + } + function open(editor = answerEditor(), child = false) { + question = true; + mode = version === "v1" ? "question" : "form"; + sessionID = child ? "child" : "root"; + focus(editor); + return editor; + } + function close() { + question = false; + mode = "base"; + sessionID = "root"; + focus(main); + } + function press(name: string, flags: TestKeyModifierOptions = {}) { + let event = new TestKeymapEvent(name, flags); + for (const listener of [...rawListeners]) { + listener(event); + if (event.propagationStopped) break; + } + if (!event.propagationStopped) event = host.press(name, flags); + if ( + !event.propagationStopped && + !event.defaultPrevented && + name.length === 1 && + !flags.ctrl && + !flags.meta && + !flags.super + ) { + renderer.currentFocusedEditor?.insertText(name); + } + return event; + } + function paste(text: string, metadata?: PasteMetadata) { + const event: PasteInputEvent = { + bytes: new TextEncoder().encode(text), + metadata, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() { + this.propagationStopped = true; + }, + }; + for (const listener of [...pasteListeners]) { + listener(event); + if (event.propagationStopped) break; + } + if (!event.propagationStopped && !event.defaultPrevented) renderer.currentFocusedEditor?.insertText(text); + return event; + } + function dispose() { + for (const fn of disposals.splice(0).reverse()) fn(); + } + return { + main, + renderer, + dispatched, + hostActions, + committedAnswers, + navigatedAnswers, + paletteSubmissions, + pasteReplays, + toasts, + diagnostics, + open, + close, + press, + paste, + focus, + dispose, + setMode(value: string) { + mode = value; + }, + setQuestion(value: boolean) { + question = value; + }, + setDialog(value: boolean) { + dialog = value; + mode = value ? "modal" : "base"; + }, + setPermission(value: boolean) { + permission = value; + mode = value ? "permission" : "base"; + }, + emit(name: string, sessionID = "root") { + events.get(name)?.({ properties: { sessionID } }); + }, + async toggle() { + await commands.find((command) => command.id === "vimcode.vim")?.run(); + }, + }; +} diff --git a/test/question-answer.test.ts b/test/question-answer.test.ts new file mode 100644 index 0000000..ae8f04c --- /dev/null +++ b/test/question-answer.test.ts @@ -0,0 +1,621 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { answerEditor, clipboardWrites, questionHost } from "./question-answer-support"; + +const disposals: Array<() => void> = []; +const flush = () => Bun.sleep(5); +async function waitFor(check: () => boolean) { + for (let attempt = 0; attempt < 100; attempt++) { + if (check()) return; + await Bun.sleep(5); + } + throw new Error("Timed out waiting for palette key delivery"); +} +afterEach(async () => { + await flush(); + for (const dispose of disposals.splice(0)) dispose(); + clipboardWrites.length = 0; +}); + +for (const version of ["v1", "v2"] as const) { + async function setup(options: Record = {}) { + const mock = await questionHost(version, options); + disposals.push(mock.dispose); + return mock; + } + describe(`#78 — ${version} question answer editing`, () => { + for (const mainMode of ["insert", "normal", "visual"] as const) { + it(`starts the answer in insert, restores main ${mainMode} on return`, async () => { + const mock = await setup({ startMode: mainMode === "insert" ? "insert" : "normal" }); + if (mainMode === "visual") mock.press("v"); + const answer = mock.open(); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("line"); + mock.press("z"); + expect(answer.plainText).toBe("hello worldz"); + expect(mock.press("escape").propagationStopped).toBe(true); + expect(mock.hostActions).toEqual([]); + mock.press("h"); + await flush(); + expect(answer.cursorOffset).toBe(10); + mock.close(); + mock.dispatched.length = 0; + mock.press(mainMode === "visual" ? "l" : "h"); + await flush(); + if (mainMode === "insert") expect(mock.main.plainText).toBe("main prompth"); + else expect(mock.dispatched).toEqual([mainMode === "visual" ? "input.select.right" : "input.move.left"]); + }); + } + it("consumes first Escape, uses focused edit commands, and lets normal Escape cancel", async () => { + const mock = await setup({ startMode: "normal" }); + const answer = mock.open(); + expect(mock.press("escape").propagationStopped).toBe(true); + await flush(); + expect(mock.hostActions).toEqual([]); + expect(answer.cursorOffset).toBe(10); + mock.press("h"); + mock.press("x"); + await flush(); + expect(answer.plainText).toBe("hello word"); + expect(mock.main.plainText).toBe("main prompt"); + mock.press("escape"); + expect(mock.hostActions).toEqual(["cancel"]); + expect(mock.diagnostics.errors).toEqual([]); + }); + for (const mode of ["insert", "normal", "visual"] as const) { + it(`preserves Enter, Ctrl+Enter and Tab host behavior in answer ${mode}`, async () => { + const mock = await setup(); + for (const [key, flags] of [ + ["return", {}], + ["return", { ctrl: true }], + ["tab", {}], + ] as const) { + mock.open(); + if (mode !== "insert") mock.press("escape"); + if (mode === "visual") mock.press("v"); + mock.press(key, flags); + } + await flush(); + expect(mock.hostActions).toEqual(["commit", "commit", "tab"]); + expect(mock.dispatched).not.toContain("input.submit"); + expect(mock.dispatched).not.toContain("input.newline"); + expect(mock.dispatched.filter((command) => command.startsWith("prompt.autocomplete"))).toEqual([]); + }); + } + for (const [label, key, flags] of [ + ["Enter", "return", {}], + ["Ctrl+Enter", "return", { ctrl: true }], + ["Tab", "tab", {}], + ] as const) { + it(`applies a rapid delete before native ${label} changes focus`, async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("x"); + mock.press(key, flags); + await flush(); + expect(key === "tab" ? mock.navigatedAnswers : mock.committedAnswers).toEqual(["hello worl"]); + expect(answer.plainText).toBe("hello worl"); + }); + } + it("applies queued motions before the next insert key", async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("h"); + mock.press("i"); + mock.press("!"); + await flush(); + expect(answer.plainText).toBe("hello wor!ld"); + expect(answer.cursorOffset).toBe(10); + }); + it("applies queued append movement before native paste", async () => { + const mock = await setup(); + const answer = mock.open(); + const metadata = { kind: "text" as const, mimeType: "text/plain" }; + mock.press("escape"); + mock.press("a"); + const paste = mock.paste("X", metadata); + expect(paste.defaultPrevented).toBe(false); + expect(paste.propagationStopped).toBe(false); + expect(answer.plainText).toBe("hello worldX"); + expect(answer.cursorOffset).toBe(12); + await flush(); + expect(answer.plainText).toBe("hello worldX"); + expect(answer.cursorOffset).toBe(12); + expect(mock.dispatched).toEqual(["input.move.right"]); + expect(mock.pasteReplays).toEqual([]); + }); + it("does not drain a previous answer's actions when paste goes to the main prompt", async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("a"); + mock.close(); + mock.paste("X"); + await flush(); + expect(mock.dispatched).not.toContain("input.move.right"); + expect(answer.plainText).toBe("hello world"); + expect(answer.cursorOffset).toBe(10); + expect(mock.main.plainText).toBe("main promptX"); + }); + it("restores a line cursor when Vim is disabled from normal mode", async () => { + const mock = await setup({ startMode: "normal" }); + await Bun.sleep(120); + expect(mock.main.cursorStyle.style).toBe("block"); + await mock.toggle(); + await Bun.sleep(120); + expect(mock.main.cursorStyle.style).toBe("line"); + await mock.toggle(); + const answer = mock.open(); + mock.press("escape"); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("block"); + await mock.toggle(); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("line"); + }); + it("restores the answer's line cursor after disabling through the palette", async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("block"); + mock.press(":"); + await flush(); + await mock.toggle(); + mock.press("escape"); + await Bun.sleep(120); + expect(mock.renderer.currentFocusedEditor).toBe(answer); + expect(answer.cursorStyle.style).toBe("line"); + }); + it("deletes a text object and undoes without borrowing main snapshots", async () => { + const mock = await setup({ startMode: "normal" }); + mock.main.cursorOffset = 0; + mock.press("d"); + mock.press("i"); + mock.press("w"); + expect(mock.main.plainText).toBe(" prompt"); + const answer = mock.open(); + mock.press("escape"); + mock.press("g"); + mock.press("g"); + mock.press("d"); + mock.press("i"); + mock.press("w"); + expect(answer.plainText).toBe(" world"); + mock.close(); + mock.press("u"); + expect(mock.main.plainText).toBe("main prompt"); + expect(answer.plainText).toBe(" world"); + mock.open(answer); + mock.press("u"); + expect(answer.plainText).toBe("hello world"); + expect(mock.main.plainText).toBe("main prompt"); + }); + it("cannot borrow a main snapshot when the answer has no undo snapshots", async () => { + const mock = await setup({ startMode: "normal" }); + mock.main.cursorOffset = 0; + mock.press("d"); + mock.press("i"); + mock.press("w"); + const answer = mock.open(); + mock.press("escape"); + mock.press("u"); + await flush(); + expect(mock.dispatched).toContain("input.undo"); + expect(answer.plainText).toBe("hello world"); + expect(mock.main.plainText).toBe(" prompt"); + mock.close(); + mock.press("u"); + expect(mock.main.plainText).toBe("main prompt"); + }); + it("keeps answer and main yank registers separate and pastes locally", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("y"); + mock.press("y"); + const answer = mock.open(); + mock.press("escape"); + mock.press("g"); + mock.press("g"); + mock.press("y"); + mock.press("i"); + mock.press("w"); + mock.press("p"); + expect(answer.plainText).toBe("hellohello world"); + expect(mock.dispatched).not.toContain("prompt.paste"); + mock.close(); + mock.press("p"); + await flush(); + expect(clipboardWrites).toEqual(["main prompt\n", "hello", "hello", "main prompt\n"]); + expect(mock.dispatched).toContain("prompt.paste"); + }); + it("shows block in answer normal and line after returning to insert", async () => { + const mock = await setup({ startMode: "normal" }); + const answer = mock.open(); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("line"); + mock.press("escape"); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("block"); + mock.press("i"); + await Bun.sleep(120); + expect(answer.cursorStyle.style).toBe("line"); + }); + it("keeps main count and pending operator while answering", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("2"); + mock.press("d"); + const answer = mock.open(); + mock.press("escape"); + mock.press("h"); + await flush(); + expect(answer.plainText).toBe("hello world"); + mock.close(); + mock.press("w"); + await flush(); + expect(mock.dispatched.filter((command) => command === "input.delete.word.forward")).toHaveLength(2); + }); + it("keeps one-shot normal isolated", async () => { + const mock = await setup(); + mock.press("o", { ctrl: true }); + mock.open(); + mock.press("escape"); + mock.press("h"); + await flush(); + mock.close(); + mock.press("h"); + await flush(); + mock.press("z"); + expect(mock.main.plainText).toContain("z"); + }); + it("re-anchors main visual mode if its prompt editor changed while answering", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("v"); + mock.open(); + mock.press("escape"); + mock.close(); + const nextPrompt = answerEditor("new prompt", "PROMPT"); + nextPrompt.cursorOffset = 0; + mock.focus(nextPrompt); + mock.press("l"); + await flush(); + expect(nextPrompt.selection).toEqual({ start: 0, end: 2 }); + }); + it("supports visual selection, visual Escape and deleting the selection", async () => { + const mock = await setup(); + const answer = mock.open(answerEditor("hello world")); + mock.press("escape"); + mock.press("g"); + mock.press("g"); + mock.press("v"); + mock.press("l"); + await flush(); + expect(answer.selection).toEqual({ start: 0, end: 2 }); + mock.press("escape"); + expect(mock.hostActions).toEqual([]); + expect(answer.selection).toBeUndefined(); + mock.press("g"); + mock.press("g"); + mock.press("v"); + mock.press("l"); + await flush(); + mock.press("d"); + await flush(); + expect(answer.plainText).toBe("llo world"); + }); + it("starts replacement and reopened answer editors fresh and isolates root/child", async () => { + const mock = await setup({ startMode: "normal" }); + const first = mock.open(); + mock.press("escape"); + mock.press("2"); + mock.press("d"); + const second = mock.open(answerEditor("child answer"), true); + mock.press("z"); + expect(second.plainText).toBe("child answerz"); + mock.press("escape"); + mock.press("h"); + await flush(); + expect(first.plainText).toBe("hello world"); + mock.close(); + const reopened = mock.open(answerEditor("again")); + mock.press("z"); + expect(reopened.plainText).toBe("againz"); + expect(mock.press("escape").propagationStopped).toBe(true); + mock.press("u"); + await flush(); + expect(mock.dispatched).toContain("input.undo"); + expect(reopened.plainText).toBe("againz"); + }); + it("inserts a printable leader in insert and keeps answer leader sequencing separate", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("space"); + const answer = mock.open(); + mock.press("space"); + expect(answer.plainText).toBe("hello world "); + mock.press("escape"); + expect(mock.press("h").propagationStopped).toBe(true); + await flush(); + mock.close(); + mock.dispatched.length = 0; + mock.press("h"); + await flush(); + expect(mock.dispatched).toEqual([]); + }); + it("empty answer j/k are editor motions, never prompt history", async () => { + const mock = await setup(); + mock.open(answerEditor("")); + mock.press("escape"); + mock.press("j"); + mock.press("k"); + await flush(); + expect(mock.dispatched).toEqual(["input.move.down", "input.move.up"]); + }); + it("preserves the global palette shortcut, yields modal keys and restores the same answer mode", async () => { + const mock = await setup({ startMode: "normal" }); + const answer = mock.open(); + mock.press("escape"); + mock.press(":"); + await flush(); + expect(mock.dispatched).toContain("command.palette.show"); + mock.press("h"); + mock.press("escape"); + expect(mock.hostActions).toEqual(["palette", "palette:h"]); + expect(mock.renderer.currentFocusedEditor).toBe(answer); + expect(mock.press("h").propagationStopped).toBe(true); + await flush(); + expect(answer.plainText).toBe("hello world"); + expect(answer.cursorOffset).toBe(9); + expect(mock.main.plainText).toBe("main prompt"); + }); + for (const delayedPalette of [false, true]) { + for (const priorEdit of [false, true]) { + it(`orders a ${delayedPalette ? "delayed" : "queued"} palette before following keys${priorEdit ? " after a prior edit" : ""}`, async () => { + const mock = await setup({ delayedPalette }); + const answer = mock.open(); + mock.press("escape"); + if (priorEdit) mock.press("x"); + mock.press(":"); + mock.press("x"); + mock.press("return"); + await waitFor(() => mock.hostActions.includes("palette:submit")); + expect(mock.committedAnswers).toEqual([]); + expect(mock.hostActions).toEqual(["palette", "palette:submit"]); + expect(mock.paletteSubmissions).toEqual(["x"]); + expect(mock.dispatched).toEqual([...(priorEdit ? ["input.delete"] : []), "command.palette.show"]); + expect(answer.plainText).toBe(priorEdit ? "hello worl" : "hello world"); + expect(mock.renderer.currentFocusedEditor?.traits.status).toBe("PALETTE"); + expect(mock.renderer.currentFocusedEditor?.plainText).toBe("x"); + mock.press("escape"); + await waitFor(() => mock.renderer.currentFocusedEditor === answer); + mock.press("h"); + await flush(); + expect(answer.cursorOffset).toBe(9); + }); + } + } + it("orders native paste with keys during delayed palette focus", async () => { + const mock = await setup({ delayedPalette: true }); + const answer = mock.open(); + const metadata = { kind: "text" as const, mimeType: "text/plain" }; + mock.press("escape"); + mock.press(":"); + mock.press("a"); + const paste = mock.paste("x", metadata); + mock.press("b"); + mock.press("return"); + expect(paste.defaultPrevented).toBe(true); + await waitFor(() => mock.hostActions.includes("palette:submit")); + expect(mock.paletteSubmissions).toEqual(["axb"]); + expect(mock.pasteReplays).toEqual([{ text: "x", metadata }]); + expect(mock.committedAnswers).toEqual([]); + expect(answer.plainText).toBe("hello world"); + }); + it("does not replay buffered paste into an answer after the palette closes", async () => { + const mock = await setup({ delayedPalette: true }); + const answer = mock.open(); + mock.press("escape"); + mock.press(":"); + mock.press("x"); + mock.press("escape"); + const paste = mock.paste("y"); + expect(paste.defaultPrevented).toBe(true); + await waitFor(() => mock.renderer.currentFocusedEditor === answer && mock.hostActions.includes("palette")); + await flush(); + expect(mock.pasteReplays).toEqual([]); + expect(answer.plainText).toBe("hello world"); + }); + it("does not replay buffered Enter into an answer after the palette closes", async () => { + const mock = await setup({ delayedPalette: true }); + const answer = mock.open(); + mock.press("escape"); + mock.press(":"); + mock.press("x"); + mock.press("escape"); + mock.press("return"); + await waitFor(() => mock.renderer.currentFocusedEditor === answer && mock.hostActions.includes("palette")); + await flush(); + expect(mock.committedAnswers).toEqual([]); + expect(mock.paletteSubmissions).toEqual([]); + expect(answer.plainText).toBe("hello world"); + }); + it("drops buffered palette keys on disposal", async () => { + const mock = await setup({ delayedPalette: true }); + const answer = mock.open(); + mock.press("escape"); + mock.press(":"); + mock.press("x"); + mock.press("return"); + mock.dispose(); + await flush(); + expect(mock.hostActions).toEqual([]); + expect(mock.dispatched).toEqual([]); + expect(answer.plainText).toBe("hello world"); + }); + it("does not drop a queued global palette command just because focus changes", async () => { + const mock = await setup(); + mock.open(); + mock.press("escape"); + mock.press(":"); + mock.close(); + await flush(); + expect(mock.dispatched).toContain("command.palette.show"); + }); + it("does not dispatch prompt-only actions from normal answer editing", async () => { + const mock = await setup(); + mock.open(); + mock.press("escape"); + for (const key of ["/", "[", "]", "{", "}", "p"]) mock.press(key); + await flush(); + expect(mock.dispatched).toEqual([]); + }); + it("leaves choices, generic forms, permission fields, modal dialogs and stale ANSWER traits alone", async () => { + const mock = await setup({ startMode: "normal" }); + mock.open(); + mock.focus(undefined); + expect(mock.press("h").propagationStopped).toBe(false); + const generic = mock.open(answerEditor("form", "FORM")); + expect(mock.press("h").propagationStopped).toBe(false); + expect(generic.plainText).toBe("formh"); + mock.close(); + mock.setPermission(true); + mock.focus(answerEditor("reason", "REJECT")); + expect(mock.press("h").propagationStopped).toBe(false); + mock.setPermission(false); + mock.setDialog(true); + mock.focus(answerEditor()); + expect(mock.press("h").propagationStopped).toBe(false); + mock.setDialog(false); + mock.setQuestion(false); + mock.focus(answerEditor()); + expect(mock.press("h").propagationStopped).toBe(false); + }); + if (version === "v1") { + for (const terminal of ["question.replied", "question.rejected"]) { + it(`balances child question event ownership after ${terminal} without host mode reporting`, async () => { + const mock = await setup({ noHostMode: true }); + mock.open(); + mock.setQuestion(false); + mock.emit("question.asked", "child"); + mock.press("escape"); + expect(mock.hostActions).toEqual([]); + mock.emit(terminal, "child"); + const answer = mock.renderer.currentFocusedEditor; + mock.press("h"); + await flush(); + expect(answer?.plainText).toBe("hello worlhd"); + expect(mock.dispatched).not.toContain("input.move.left"); + }); + } + it("requires an active question, not just a permission, on hosts without mode reporting", async () => { + const mock = await setup({ noHostMode: true, startMode: "normal" }); + mock.open(); + mock.setQuestion(false); + mock.setPermission(true); + mock.press("escape"); + expect(mock.toasts).not.toContain("NORMAL"); + mock.setPermission(false); + mock.open(); + mock.press("escape"); + expect(mock.hostActions).toEqual([]); + expect(mock.toasts).toContain("NORMAL"); + }); + } + it("does not style an unrelated overlay using the main Vim mode", async () => { + const mock = await setup({ startMode: "normal" }); + mock.open(answerEditor("reason", "REJECT")); + mock.setPermission(true); + const editor = mock.renderer.currentFocusedEditor; + if (!editor) throw new Error("No focused editor"); + editor.cursorStyle.style = "underline"; + await Bun.sleep(120); + expect(editor.cursorStyle.style).toBe("underline"); + }); + it("respects disabled Vim and restarts answer state when toggled", async () => { + const mock = await setup({ disabled: true }); + mock.open(); + mock.press("escape"); + expect(mock.hostActions).toEqual(["cancel"]); + await mock.toggle(); + const answer = mock.open(); + expect(mock.press("escape").propagationStopped).toBe(true); + mock.press("g"); + mock.press("g"); + mock.press("d"); + mock.press("i"); + mock.press("w"); + expect(answer.plainText).toBe(" world"); + mock.press("2"); + mock.press("d"); + await mock.toggle(); + await mock.toggle(); + expect(mock.renderer.currentFocusedEditor).toBe(answer); + mock.press("z"); + expect(answer.plainText).toBe("z world"); + mock.dispatched.length = 0; + mock.press("escape"); + mock.press("u"); + await flush(); + expect(mock.dispatched).toEqual(["input.undo"]); + expect(answer.plainText).toBe("z world"); + }); + it("requires actual focused-renderable ownership", async () => { + const mock = await setup(); + mock.open(); + mock.renderer.currentFocusedRenderable = {}; + mock.press("escape"); + expect(mock.hostActions).toEqual(["cancel"]); + expect(mock.toasts).not.toContain("NORMAL"); + }); + it("drops deferred undo and selection yank after answer focus changes", async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("u"); + mock.close(); + await flush(); + expect(mock.dispatched).not.toContain("input.undo"); + mock.open(); + mock.press("escape"); + mock.press("v"); + mock.press("l"); + mock.press("y"); + mock.close(); + await flush(); + expect(clipboardWrites).toEqual([]); + expect(answer.plainText).toBe("hello world"); + expect(mock.main.plainText).toBe("main prompt"); + }); + for (const change of ["focus", "overlay", "dispose"] as const) { + it(`drops queued answer edits after ${change}`, async () => { + const mock = await setup(); + const answer = mock.open(); + mock.press("escape"); + mock.press("x"); + if (change === "focus") mock.close(); + if (change === "overlay") mock.setDialog(true); + if (change === "dispose") mock.dispose(); + await flush(); + expect(mock.dispatched).not.toContain("input.delete"); + expect(answer.plainText).toBe("hello world"); + expect(mock.main.plainText).toBe("main prompt"); + }); + } + it("drops main commands when a question opens before the host clears prompt focus", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("x"); + mock.setQuestion(true); + mock.setMode(version === "v1" ? "question" : "form"); + await flush(); + expect(mock.dispatched).not.toContain("input.delete"); + expect(mock.main.plainText).toBe("main prompt"); + }); + it("drops main commands queued before an answer took focus", async () => { + const mock = await setup({ startMode: "normal" }); + mock.press("x"); + const answer = mock.open(); + await flush(); + expect(answer.plainText).toBe("hello world"); + expect(mock.dispatched).not.toContain("input.delete"); + }); + }); +}