From 7101f9b4175948a8a090ff8a243b3b60e1076aee Mon Sep 17 00:00:00 2001 From: ori Date: Tue, 6 Oct 2026 01:18:38 +0300 Subject: [PATCH 1/3] fix: enable Vim editing in question answers --- AGENTS.md | 9 +- CHANGELOG.md | 4 + README.md | 10 +- src/editing.ts | 28 ++ src/index.ts | 233 ++++++++++++---- src/v2.ts | 8 +- test/compat/cases.py | 43 ++- test/compat/fixture/tui.ts | 2 + test/question-answer-support.ts | 388 ++++++++++++++++++++++++++ test/question-answer.test.ts | 466 ++++++++++++++++++++++++++++++++ 10 files changed, 1128 insertions(+), 63 deletions(-) create mode 100644 src/editing.ts create mode 100644 test/question-answer-support.ts create mode 100644 test/question-answer.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6ddc74d..077d212 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 (606 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 (248 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,6 +91,8 @@ 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 (466) #78: common v1/v2 answer editing and host-key ownership regressions + question-answer-support.ts (388) 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) ``` @@ -171,6 +174,8 @@ just compat v1 /absolute/opencode 1.18.33 /canonical/empty/output # Isolated in 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; live-host checks remain optional. +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 edits drain before the next answer key, so coalesced Enter cannot overtake them; the global `:` palette shortcut remains available. 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. ## Git Workflow diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c54670..05c22dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### 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)). + ## [0.19.1] — 2026-10-04 ### Changed diff --git a/README.md b/README.md index 739e016..6cf9b56 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/src/editing.ts b/src/editing.ts new file mode 100644 index 0000000..0788cc5 --- /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 }>; + deferredEdits: Array<() => void>; + visualEditorOwner?: unknown; + leaderPending: boolean; + leaderTimer?: ReturnType; +}; + +export function createEditingContext(): EditingContext { + return { state: createVimState(), undoSnapshots: [], deferredEdits: [], 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..e5f3696 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,56 @@ 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; }); + 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 +176,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 deferEdit(run: () => void, global = false) { + if (!isAnswer || global) { + setTimeout(run, 0); + return; + } + context.deferredEdits.push(run); + setTimeout(() => { + const index = context.deferredEdits.indexOf(run); + if (index < 0) return; + context.deferredEdits.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 +226,51 @@ 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 && + (command === "input.submit" || command.startsWith("prompt.") || command.startsWith("session.")) + ) + break; + deferEdit(() => { 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 ( 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); + }, globalCommand); 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 +295,8 @@ const plugin = { break; case "yankSelection": { // Deferred so it runs after any preceding select commands - setTimeout(() => { - const editor = api.renderer?.currentFocusedEditor; + deferEdit(() => { + if (!stillOwnsEditor()) return; const text = editor?.editorView?.getSelectedText?.() ?? ""; if (text) { state.yankRegister = text; @@ -211,7 +308,7 @@ const plugin = { }); } editor?.editorView?.resetSelection?.(); - }, 0); + }); break; } case "clearSelection": @@ -220,7 +317,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 +328,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 +337,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 +346,9 @@ const plugin = { editor.cursorOffset = undoSnapshot.cursor; } } else { - setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0); + deferEdit(() => { + if (stillOwnsEditor()) api.keymap.dispatchCommand("input.undo"); + }); } break; } @@ -271,9 +370,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 +399,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 +446,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 +460,24 @@ 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; + const answer = answerContextForFocus(); + // Terminal input can contain several keys before timers run. Apply + // prior edits before a new key reads the cursor or commits the answer. + if (answer) for (const edit of answer.deferredEdits.splice(0)) edit(); + 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 +493,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 +524,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 +546,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,7 +580,7 @@ const plugin = { } if (consume) ctx.consume(); - applyActions(actions); + applyActions(actions, context); }, { priority: 10_000 }, ); diff --git a/src/v2.ts b/src/v2.ts index 56252df..6636217 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"; @@ -157,7 +158,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 +169,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 +189,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..a82239e 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,52 @@ 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"}}) + 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, "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 +217,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/question-answer-support.ts b/test/question-answer-support.ts new file mode 100644 index 0000000..11aeb06 --- /dev/null +++ b/test/question-answer-support.ts @@ -0,0 +1,388 @@ +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); + }, +})); + +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 commands: Array<{ id: string; run(): void | Promise }> = []; + const dispatched: string[] = []; + const hostActions: string[] = []; + const committedAnswers: string[] = []; + const navigatedAnswers: string[] = []; + 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(_event: "keypress", listener: (event: TestKeymapEvent) => void) { + rawListeners.unshift(listener); + }, + off(_event: "keypress", listener: (event: TestKeymapEvent) => void) { + rawListeners.splice(rawListeners.indexOf(listener), 1); + }, + }, + }; + 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 }; + dialog = true; + mode = "modal"; + focus(answerEditor("", "PALETTE")); + hostActions.push("palette"); + }, + }, + ], + }); + keymap.registerLayer({ + enabled: () => mode === "modal" && paletteReturn !== undefined, + commands: [ + { + name: "palette.input", + run() { + hostActions.push("palette:h"); + }, + }, + { + 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: "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); + if (version === "v2") { + 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 dispose() { + for (const fn of disposals.splice(0).reverse()) fn(); + } + return { + main, + renderer, + dispatched, + hostActions, + committedAnswers, + navigatedAnswers, + toasts, + diagnostics, + open, + close, + press, + 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..19a5df8 --- /dev/null +++ b/test/question-answer.test.ts @@ -0,0 +1,466 @@ +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); +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("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.press("u"); + expect(answer.plainText).toBe("hello world"); + 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"); + }); + 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(); + mock.open(); + expect(mock.press("escape").propagationStopped).toBe(true); + await mock.toggle(); + await mock.toggle(); + const answer = mock.open(); + mock.press("z"); + expect(answer.plainText.endsWith("z")).toBe(true); + }); + 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"); + }); + }); +} From c36d9f2f3d51736928e8aeff9debb65e354d058c Mon Sep 17 00:00:00 2001 From: ori Date: Wed, 7 Oct 2026 12:53:46 +0300 Subject: [PATCH 2/3] fix: preserve input order when opening answer palette --- AGENTS.md | 10 +-- src/editing.ts | 4 +- src/index.ts | 120 ++++++++++++++++++++++++++--- src/v2.ts | 2 + test/compat/cases.py | 16 ++++ test/question-answer-support.ts | 102 +++++++++++++++++++++---- test/question-answer.test.ts | 131 +++++++++++++++++++++++++++++++- 7 files changed, 350 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 077d212..38534cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,10 +58,10 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (606 lines) Dual v1 tui/v2 setup entry: intercepts, answer ownership, action application + index.ts (702 lines) Dual v1 tui/v2 setup entry: intercepts, answer ownership, action application editor.ts (28 lines) Host-coordinate horizontal selection bounds, preserving native anchor editing.ts (28 lines) Editor-local Vim contexts and shared ANSWER focus trait check - v2.ts (248 lines) Experimental v2 TUI facade (host input, commands, state, events) + 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 @@ -91,8 +91,8 @@ 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 (466) #78: common v1/v2 answer editing and host-key ownership regressions - question-answer-support.ts (388) Typed focused editor fixture with real OpenTUI keymap dispatch + question-answer.test.ts (591) #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) ``` @@ -174,7 +174,7 @@ just compat v1 /absolute/opencode 1.18.33 /canonical/empty/output # Isolated in 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; live-host checks remain optional. -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 edits drain before the next answer key, so coalesced Enter cannot overtake them; the global `:` palette shortcut remains available. 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. +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's keyboard ownership is resolved. 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/src/editing.ts b/src/editing.ts index 0788cc5..3bb0a61 100644 --- a/src/editing.ts +++ b/src/editing.ts @@ -3,14 +3,14 @@ import { createVimState, type VimState } from "./vim"; export type EditingContext = { state: VimState; undoSnapshots: Array<{ text: string; cursor: number }>; - deferredEdits: Array<() => void>; + deferredActions: Array<() => void>; visualEditorOwner?: unknown; leaderPending: boolean; leaderTimer?: ReturnType; }; export function createEditingContext(): EditingContext { - return { state: createVimState(), undoSnapshots: [], deferredEdits: [], leaderPending: false }; + return { state: createVimState(), undoSnapshots: [], deferredActions: [], leaderPending: false }; } // The host assigns this trait to question textareas on both v1 and v2. diff --git a/src/index.ts b/src/index.ts index e5f3696..6e5f8ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,72 @@ const plugin = { disposed = true; }); + 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?.() @@ -199,16 +265,16 @@ const plugin = { : !mainPromptBlocked() && !answerContextForFocus() && !hasAnswerFocus(renderer ?? {})) ); }; - function deferEdit(run: () => void, global = false) { - if (!isAnswer || global) { + function deferAction(run: () => void) { + if (!isAnswer) { setTimeout(run, 0); return; } - context.deferredEdits.push(run); + context.deferredActions.push(run); setTimeout(() => { - const index = context.deferredEdits.indexOf(run); + const index = context.deferredActions.indexOf(run); if (index < 0) return; - context.deferredEdits.splice(index, 1); + context.deferredActions.splice(index, 1); run(); }, 0); } @@ -238,12 +304,15 @@ const plugin = { 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; - deferEdit(() => { + deferAction(() => { if ( (globalCommand ? disposed : !stillOwnsEditor()) || (visualEditor && @@ -253,6 +322,10 @@ const plugin = { ) return; 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 && @@ -266,7 +339,7 @@ const plugin = { ) { selectVisualCharacterRange(visualEditor, visualAnchor); } - }, globalCommand); + }); break; } case "mode": @@ -295,7 +368,7 @@ const plugin = { break; case "yankSelection": { // Deferred so it runs after any preceding select commands - deferEdit(() => { + deferAction(() => { if (!stillOwnsEditor()) return; const text = editor?.editorView?.getSelectedText?.() ?? ""; if (text) { @@ -346,7 +419,7 @@ const plugin = { editor.cursorOffset = undoSnapshot.cursor; } } else { - deferEdit(() => { + deferAction(() => { if (stillOwnsEditor()) api.keymap.dispatchCommand("input.undo"); }); } @@ -461,10 +534,11 @@ const plugin = { // If vim mode is disabled, pass all keys through unmodified. 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(); - // Terminal input can contain several keys before timers run. Apply - // prior edits before a new key reads the cursor or commits the answer. - if (answer) for (const edit of answer.deferredEdits.splice(0)) edit(); const context = answer ?? mainContext; const state = context.state; if (hasAnswerFocus(api.renderer ?? {}) && !answer) return; @@ -584,6 +658,28 @@ const plugin = { }, { 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) => { + 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 6636217..b690ac4 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -36,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: { diff --git a/test/compat/cases.py b/test/compat/cases.py index a82239e..5196a35 100644 --- a/test/compat/cases.py +++ b/test/compat/cases.py @@ -163,6 +163,22 @@ def run_v2_prompts(ctx): 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") diff --git a/test/question-answer-support.ts b/test/question-answer-support.ts index 11aeb06..eef3bcf 100644 --- a/test/question-answer-support.ts +++ b/test/question-answer-support.ts @@ -13,6 +13,16 @@ mock.module("../src/clipboard", () => ({ }, })); +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 = { @@ -89,11 +99,37 @@ export async function questionHost(version: "v1" | "v2", options: Record 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"); @@ -101,11 +137,16 @@ export async function questionHost(version: "v1" | "v2", options: Record | undefined, currentFocusedRenderable: main as object | undefined, keyInput: { - prependListener(_event: "keypress", listener: (event: TestKeymapEvent) => void) { - rawListeners.unshift(listener); + prependListener: prependInput, + off: removeInput, + processParsedKey(event: TestKeyModifierOptions & { name: string }) { + press(event.name, event); + return true; }, - off(_event: "keypress", listener: (event: TestKeymapEvent) => void) { - rawListeners.splice(rawListeners.indexOf(listener), 1); + processPaste(bytes: Uint8Array, metadata?: PasteMetadata) { + const text = new TextDecoder().decode(bytes); + pasteReplays.push({ text, metadata }); + paste(text, metadata); }, }, }; @@ -121,10 +162,16 @@ export async function questionHost(version: "v1" | "v2", options: Record focus(input)); + else focus(input); + hostActions.push("palette"); + } + if (options.delayedPalette) queueMicrotask(openPalette); + else openPalette(); }, }, ], @@ -138,6 +185,13 @@ export async function questionHost(version: "v1" | "v2", options: Record 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(); @@ -146,8 +153,28 @@ for (const version of ["v1", "v2"] as const) { 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"); @@ -310,6 +337,90 @@ for (const version of ["v1", "v2"] as const) { 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(); @@ -395,13 +506,27 @@ for (const version of ["v1", "v2"] as const) { mock.press("escape"); expect(mock.hostActions).toEqual(["cancel"]); await mock.toggle(); - mock.open(); + 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(); - const answer = mock.open(); + expect(mock.renderer.currentFocusedEditor).toBe(answer); mock.press("z"); - expect(answer.plainText.endsWith("z")).toBe(true); + 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(); From 5975709ba4b2222bea7520b91c64d867dc993a8a Mon Sep 17 00:00:00 2001 From: ori Date: Sun, 11 Oct 2026 12:41:55 +0300 Subject: [PATCH 3/3] test: retain native e2e startup failure diagnostics --- .github/workflows/test.yml | 1 + AGENTS.md | 2 +- CHANGELOG.md | 1 + docs/test-e2e.md | 6 +-- test/e2e/driver.integration.ts | 82 ++++++++++++++++++++++++++++++++-- test/e2e/host.ts | 28 +++++++++++- 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3662504..67f819d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -177,5 +177,6 @@ jobs: ${{ 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 9915d1a..05679bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,7 +175,7 @@ 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 99d56e4..61de506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version - 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/docs/test-e2e.md b/docs/test-e2e.md index c009723..f87a6da 100644 --- a/docs/test-e2e.md +++ b/docs/test-e2e.md @@ -51,7 +51,7 @@ The suites test different boundaries: | 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. Keyboard raw-write timeouts use the same failure/finally cleanup checks as type/press. +`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. Five product E2E scenarios run against both host generations: @@ -86,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. @@ -104,4 +104,4 @@ Both pinned hosts expose the same bordered home prompt. Assertions compare every 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/test/e2e/driver.integration.ts b/test/e2e/driver.integration.ts index 55000da..53d7e08 100644 --- a/test/e2e/driver.integration.ts +++ b/test/e2e/driver.integration.ts @@ -1,6 +1,6 @@ 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"; @@ -18,23 +18,40 @@ import { 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 ╹▀▀▀▀▀", @@ -46,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 })); } @@ -160,6 +182,60 @@ for (const send of ["type", "press", "write"] 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/host.ts b/test/e2e/host.ts index 09eb5e4..24db92e 100644 --- a/test/e2e/host.ts +++ b/test/e2e/host.ts @@ -58,6 +58,7 @@ export type HostRun = { driver?: OwnedDriver; session?: Session; scenario?: string; + hostLogOffset?: number; }; export function saveReceipt(run: HostRun) { @@ -261,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); @@ -278,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", @@ -419,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); }