From 109d76ac90fb2c67fd085c78e78b77ef0f6ceb27 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 29 Sep 2026 19:34:10 -0400 Subject: [PATCH 1/9] refactor: simplify Vim structure and standardize formatting Move Vim internals into src/vim, extract keymap and text-object helpers, and replace compatibility scaffolding with a typed editor interface. Remove unused code and unnecessary casts, enable unused-code checks, and add consistent Prettier formatting with CI enforcement. --- .github/workflows/ci.yml | 3 + .prettierrc.json | 5 + CONTRIBUTING.md | 9 + package-lock.json | 17 + package.json | 3 + scripts/build.ts | 40 +- src/clipboard.tsx | 11 +- src/form.tsx | 381 +++---- src/modules/vim/actions.ts | 82 -- src/modules/vim/config.ts | 151 --- src/modules/vim/edit.ts | 58 -- src/modules/vim/graphemes.ts | 42 - src/modules/vim/keys.ts | 38 - src/modules/vim/log.ts | 37 - src/modules/vim/map.ts | 124 --- src/modules/vim/state.tsx | 46 - src/modules/vim/vimee.ts | 1200 ----------------------- src/plugin.tsx | 187 ++-- src/readers/diff/data.ts | 35 +- src/readers/diff/index.tsx | 133 ++- src/readers/index.tsx | 7 +- src/readers/read/data.ts | 5 +- src/readers/read/index.tsx | 20 +- src/readers/shell/data.ts | 37 +- src/readers/shell/index.tsx | 93 +- src/readers/shell/syntax.tsx | 2 +- src/readers/syntax.tsx | 22 +- src/readers/text.tsx | 170 ++-- src/readers/types.ts | 2 +- src/session-keys.ts | 22 +- src/session.tsx | 84 +- src/transcript-items.tsx | 110 ++- src/transcript.tsx | 73 +- src/vim/config.ts | 150 +++ src/vim/edit.ts | 61 ++ src/vim/editor.ts | 30 + src/vim/graphemes.ts | 42 + src/vim/keymaps.ts | 80 ++ src/vim/keys.ts | 65 ++ src/vim/log.ts | 37 + src/vim/map.ts | 128 +++ src/vim/state.tsx | 46 + src/{modules => }/vim/status.tsx | 0 src/vim/text-objects.ts | 261 +++++ src/vim/vimee.ts | 1072 ++++++++++++++++++++ test/benchmark.ts | 42 +- test/e2e/data/file-changes.ts | 123 ++- test/e2e/data/navigation.ts | 77 +- test/e2e/data/permission.ts | 41 +- test/e2e/data/read.ts | 52 +- test/e2e/data/shell.ts | 156 ++- test/e2e/data/transcript.ts | 77 +- test/e2e/run.ts | 389 +++++--- test/e2e/scenarios/agent-switching.ts | 93 +- test/e2e/scenarios/background-shell.ts | 95 +- test/e2e/scenarios/clipboard-cancel.ts | 67 +- test/e2e/scenarios/dialog-focus.ts | 370 ++++--- test/e2e/scenarios/diff-reader.ts | 200 ++-- test/e2e/scenarios/file-changes.ts | 140 +-- test/e2e/scenarios/message-reader.ts | 168 ++-- test/e2e/scenarios/permission-tools.ts | 112 ++- test/e2e/scenarios/plugin-lifecycle.ts | 194 ++-- test/e2e/scenarios/prompt-input.ts | 249 ++--- test/e2e/scenarios/question-forms.ts | 411 ++++---- test/e2e/scenarios/read-reader.ts | 126 +-- test/e2e/scenarios/session-controls.ts | 260 ++--- test/e2e/scenarios/session-keymaps.ts | 149 +-- test/e2e/scenarios/shell-reader.ts | 146 +-- test/e2e/scenarios/tab-switching.ts | 39 +- test/e2e/scenarios/transcript-layout.ts | 370 +++---- test/e2e/scenarios/transcript-live.ts | 103 +- test/e2e/scenarios/transcript.ts | 207 ++-- test/e2e/support/clipboard-boundary.ts | 36 +- test/e2e/support/clipboard-server.ts | 56 +- test/e2e/support/colors.ts | 131 ++- test/e2e/support/fixture.ts | 354 ++++--- test/e2e/support/model.ts | 83 +- test/e2e/support/opencode.ts | 75 +- test/e2e/support/plugin.ts | 96 +- test/e2e/support/probe/tui.tsx | 101 +- test/e2e/support/screens.ts | 34 +- test/e2e/support/terminal.ts | 187 ++-- test/helpers/fixture.ts | 143 +-- test/helpers/neovim.ts | 48 +- test/helpers/reader.tsx | 121 ++- test/helpers/theme.ts | 35 +- test/integration/clipboard.test.ts | 364 +++---- test/integration/editor.test.ts | 825 ++++++++-------- test/integration/highlighting.test.ts | 285 ++++-- test/integration/readers/diff.test.ts | 216 ++-- test/integration/readers/read.test.ts | 124 ++- test/integration/readers/shell.test.ts | 206 ++-- test/integration/readers/text.test.ts | 239 ++--- test/integration/reference.test.ts | 300 +++--- test/unit/diff-reader.test.ts | 88 +- test/unit/edit.test.ts | 132 +-- test/unit/map.test.ts | 180 ++-- test/unit/read-reader.test.ts | 109 +- test/unit/session-keymaps.test.ts | 28 +- test/unit/shell-reader.test.ts | 203 ++-- test/unit/state.test.ts | 26 +- test/unit/vimee.test.ts | 265 +++-- tsconfig.json | 4 +- 103 files changed, 8406 insertions(+), 6365 deletions(-) create mode 100644 .prettierrc.json delete mode 100644 src/modules/vim/actions.ts delete mode 100644 src/modules/vim/config.ts delete mode 100644 src/modules/vim/edit.ts delete mode 100644 src/modules/vim/graphemes.ts delete mode 100644 src/modules/vim/keys.ts delete mode 100644 src/modules/vim/log.ts delete mode 100644 src/modules/vim/map.ts delete mode 100644 src/modules/vim/state.tsx delete mode 100644 src/modules/vim/vimee.ts create mode 100644 src/vim/config.ts create mode 100644 src/vim/edit.ts create mode 100644 src/vim/editor.ts create mode 100644 src/vim/graphemes.ts create mode 100644 src/vim/keymaps.ts create mode 100644 src/vim/keys.ts create mode 100644 src/vim/log.ts create mode 100644 src/vim/map.ts create mode 100644 src/vim/state.tsx rename src/{modules => }/vim/status.tsx (100%) create mode 100644 src/vim/text-objects.ts create mode 100644 src/vim/vimee.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10a9878..1f778fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Typecheck run: bun run typecheck + - name: Check formatting + run: bun run format:check + - name: Test units and components run: bun run test diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6c1772b --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "printWidth": 120, + "tabWidth": 2 +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9e662c..1302d49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,15 @@ resolve the latest OpenCode release. Downloaded binaries are cached under Use `bun run build` to build the plugin into `dist/`. +Run `bun run format` to format the code, or `bun run format:check` to check it. + +## Code layout + +- `tui.tsx` re-exports the plugin entry point from `src/plugin.tsx`. +- `src/vim/` contains the editing adapter, editor interface, keymaps, and text objects. +- `src/form.tsx` and `src/session.tsx` handle form and transcript navigation. +- `src/readers/` contains the message and tool-output readers. + ## Adding tests Put unit tests in `test/unit/`, integration tests in `test/integration/`, and diff --git a/package-lock.json b/package-lock.json index 2cbadee..21b4d3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@opencode/theme": "2.0.10", "@types/bun": "^1.3.14", "@types/node": "^24.0.0", + "prettier": "3.9.9", "typescript": "^5.9.0" }, "peerDependencies": { @@ -5251,6 +5252,22 @@ "node": ">=4" } }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", diff --git a/package.json b/package.json index cb04759..52735cd 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "build": "bun scripts/build.ts", "prepack": "bun run build", "typecheck": "tsc --noEmit", + "format": "prettier --write \"{src,test,scripts}/**/*.{ts,tsx}\" tui.tsx", + "format:check": "prettier --check \"{src,test,scripts}/**/*.{ts,tsx}\" tui.tsx", "test": "bun test --conditions=browser --preload @opentui/solid/preload ./test/unit ./test/integration", "test:e2e": "bun test/e2e/run.ts", "bench": "bun test/benchmark.ts" @@ -33,6 +35,7 @@ "@opencode/theme": "2.0.10", "@types/bun": "^1.3.14", "@types/node": "^24.0.0", + "prettier": "3.9.9", "typescript": "^5.9.0" }, "files": [ diff --git a/scripts/build.ts b/scripts/build.ts index 62fee0f..572e6bc 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -3,31 +3,27 @@ import { runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin" // npm sources live under node_modules, where the host's Solid source transform // does not run. Compile JSX here and explicitly use the host's shared modules. -const shared = new Set([ - "@opencode/plugin/tui", - "@opentui/core", - "@opentui/solid", - "solid-js", - "solid-js/store", -]) +const shared = new Set(["@opencode/plugin/tui", "@opentui/core", "@opentui/solid", "solid-js", "solid-js/store"]) const result = await Bun.build({ - entrypoints: ["./tui.tsx"], - outdir: "./dist", - target: "bun", - format: "esm", - packages: "external", - external: ["opentui:*"], - plugins: [createSolidTransformPlugin({ - moduleName: runtimeModuleIdForSpecifier("@opentui/solid"), - resolvePath(specifier) { - if (shared.has(specifier)) return runtimeModuleIdForSpecifier(specifier) - return null - }, - })], + entrypoints: ["./tui.tsx"], + outdir: "./dist", + target: "bun", + format: "esm", + packages: "external", + external: ["opentui:*"], + plugins: [ + createSolidTransformPlugin({ + moduleName: runtimeModuleIdForSpecifier("@opentui/solid"), + resolvePath(specifier) { + if (shared.has(specifier)) return runtimeModuleIdForSpecifier(specifier) + return null + }, + }), + ], }) if (!result.success) { - for (const log of result.logs) console.error(log) - process.exit(1) + for (const log of result.logs) console.error(log) + process.exit(1) } diff --git a/src/clipboard.tsx b/src/clipboard.tsx index 11dd131..7ba8384 100644 --- a/src/clipboard.tsx +++ b/src/clipboard.tsx @@ -1,5 +1,10 @@ // .tsx lets OpenCode's runtime loader supply its shared OpenTUI imports. -import { createClipboard, createHostClipboard, createRendererClipboardAdapter, type RendererClipboardBoundary } from "@opentui/core" +import { + createClipboard, + createHostClipboard, + createRendererClipboardAdapter, + type RendererClipboardBoundary, +} from "@opentui/core" export type VimClipboard = ReturnType @@ -22,7 +27,9 @@ export function createVimClipboard(renderer: RendererClipboardBoundary) { try { const result = await clipboard.writeText(text, { destination: "all-available", selection: "clipboard" }) return result.host.status === "written" || result.terminal.status === "attempted" - } catch { return false } + } catch { + return false + } }) writes = result.then(() => {}) return result diff --git a/src/form.tsx b/src/form.tsx index 183ee12..1791943 100644 --- a/src/form.tsx +++ b/src/form.tsx @@ -1,203 +1,224 @@ import type { Context } from "@opencode/plugin/tui/context" import { KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core" import { createEffect, onCleanup, Show, untrack } from "solid-js" -import type { PromptContext } from "./modules/vim/actions" -import type { VimConfig } from "./modules/vim/config" -import { editInput } from "./modules/vim/edit" -import { keyNotation } from "./modules/vim/keys" -import type { VimLog } from "./modules/vim/log" -import { createVimState } from "./modules/vim/state" -import { createVimeeAdapter } from "./modules/vim/vimee" -import { VimStatus } from "./modules/vim/status" +import type { EditorContext } from "./vim/editor" +import type { VimConfig } from "./vim/config" +import { editInput } from "./vim/edit" +import { keyNotation } from "./vim/keys" +import type { VimLog } from "./vim/log" +import { createVimState } from "./vim/state" +import { createVimeeAdapter } from "./vim/vimee" +import { VimStatus } from "./vim/status" export function createFormMode(context: Context, config: VimConfig, log: VimLog, enabled: () => boolean) { - const state = createVimState("normal", log) - const adapter = createVimeeAdapter(state, config, log) - const active = () => enabled() && context.keymap.mode.current() === "form" - let input: typeof context.renderer.currentFocusedEditor = null - let cursorStyle: CursorStyleOptions | undefined - let forwarding = false - let opening: Array | undefined - const ctx: PromptContext = { - api: { - renderer: context.renderer, - keymap: { - dispatchCommand(command) { - const ok = context.keymap.commands().some((item) => item.id === command) - if (ok) context.keymap.dispatch(command) - return { ok } - }, - }, - theme: { get current() { return { - background: context.theme.background.base, - info: context.theme.text.feedback.info.base, - warning: context.theme.text.feedback.warning.base, - } } }, - }, - prompt: () => input ? { - current: { input: input.plainText, mode: "normal", parts: [] }, - set: (value) => { if (input) editInput(input, value.input, context.renderer.widthMethod) }, - submit: () => sendKey("return", "\r"), - blur: () => input?.blur(), - } : undefined, - requestRender: () => context.renderer.requestRender(), - } + const state = createVimState("normal", log) + const adapter = createVimeeAdapter(state, config, log) + const active = () => enabled() && context.keymap.mode.current() === "form" + let input: typeof context.renderer.currentFocusedEditor = null + let cursorStyle: CursorStyleOptions | undefined + let forwarding = false + let opening: Array | undefined + const editorContext: EditorContext = { + input: () => input ?? undefined, + get widthMethod() { + return context.renderer.widthMethod + }, + get colors() { + return { + background: context.theme.background.base, + yank: context.theme.text.feedback.info.base, + selection: context.theme.text.feedback.warning.base, + } + }, + setText(text) { + if (input) editInput(input, text, context.renderer.widthMethod) + }, + submit: () => sendKey("return", "\r"), + blur: () => input?.blur(), + dispatchCommand(command) { + const ok = context.keymap.commands().some((item) => item.id === command) + if (ok) context.keymap.dispatch(command) + return { ok } + }, + requestRender: () => context.renderer.requestRender(), + } - const removeStatus = context.ui.slot({ - append: "session.composer.top", - render: () => - - - - {state.mode() === "normal" ? "j/k select · i type an answer" : "esc normal"} - - - , - }) + const removeStatus = context.ui.slot({ + append: "session.composer.top", + render: () => ( + + + + + {state.mode() === "normal" ? "j/k select · i type an answer" : "esc normal"} + + + + ), + }) - createEffect(() => { - if (!active()) opening = undefined - untrack(focus) - }) - createEffect(() => { - const style = config.cursorStyles[state.mode()] - if (input && !input.isDestroyed) input.cursorStyle = style - }) - onCleanup(() => { - opening = undefined - adapter.cleanup() - restoreCursor() - removeStatus() - }) + createEffect(() => { + if (!active()) opening = undefined + untrack(focus) + }) + createEffect(() => { + const style = config.cursorStyles[state.mode()] + if (input && !input.isDestroyed) input.cursorStyle = style + }) + onCleanup(() => { + opening = undefined + adapter.cleanup() + restoreCursor() + removeStatus() + }) - function restoreCursor() { - if (input && !input.isDestroyed && cursorStyle) input.cursorStyle = cursorStyle - } + function restoreCursor() { + if (input && !input.isDestroyed && cursorStyle) input.cursorStyle = cursorStyle + } - function focus() { - const next = active() ? context.renderer.currentFocusedEditor : null - if (next === input) return - adapter.suspend() - restoreCursor() - input = next - cursorStyle = input?.cursorStyle - state.setMode(input ? "insert" : "normal") - if (input) input.cursorStyle = config.cursorStyles[state.mode()] - } + function focus() { + const next = active() ? context.renderer.currentFocusedEditor : null + if (next === input) return + adapter.suspend() + restoreCursor() + input = next + cursorStyle = input?.cursorStyle + state.setMode(input ? "insert" : "normal") + if (input) input.cursorStyle = config.cursorStyles[state.mode()] + } + + function consume(event: KeyEvent | PasteEvent) { + event.preventDefault() + event.stopPropagation() + } - function consume(event: KeyEvent | PasteEvent) { - event.preventDefault() - event.stopPropagation() + function sendKey(name: string, sequence: string) { + // Form actions are inline host bindings, without dispatchable command IDs. + forwarding = true + try { + context.renderer.keyInput.emit( + "keypress", + new KeyEvent({ + name, + sequence, + raw: sequence, + ctrl: false, + meta: false, + shift: false, + option: false, + number: false, + eventType: "press", + source: "raw", + }), + ) + } finally { + forwarding = false } + } - function sendKey(name: string, sequence: string) { - // Form actions are inline host bindings, without dispatchable command IDs. - forwarding = true - try { - context.renderer.keyInput.emit("keypress", new KeyEvent({ - name, sequence, raw: sequence, ctrl: false, meta: false, shift: false, - option: false, number: false, eventType: "press", source: "raw", - })) - } finally { - forwarding = false + function waitForEditor() { + const queued: Array = [] + opening = queued + // OpenCode publishes a newly opened answer editor in a microtask. Keep + // burst typing here so its first keys also use the configured mappings. + queueMicrotask(() => + queueMicrotask(() => { + if (opening !== queued) return + opening = undefined + focus() + for (const event of queued) { + if (!active()) break + if (event instanceof PasteEvent) context.renderer.keyInput.emit("paste", event) + else context.renderer.keyInput.emit("keypress", event) } - } + }), + ) + } - function waitForEditor() { - const queued: Array = [] - opening = queued - // OpenCode publishes a newly opened answer editor in a microtask. Keep - // burst typing here so its first keys also use the configured mappings. - queueMicrotask(() => queueMicrotask(() => { - if (opening !== queued) return - opening = undefined - focus() - for (const event of queued) { - if (!active()) break - if (event instanceof PasteEvent) context.renderer.keyInput.emit("paste", event) - else context.renderer.keyInput.emit("keypress", event) - } - })) + function paste(event: PasteEvent) { + if (!active()) return + if (opening) { + opening.push(new PasteEvent(event.bytes, event.metadata)) + consume(event) + } else if (!input) { + waitForEditor() } + } - function paste(event: PasteEvent) { - if (!active()) return - if (opening) { - opening.push(new PasteEvent(event.bytes, event.metadata)) - consume(event) - } else if (!input) { - waitForEditor() - } + function handle(event: KeyEvent) { + if (!active()) return false + if (forwarding) return true + if (opening) { + opening.push(new KeyEvent(event)) + consume(event) + return true } + if (context.keymap.pending().length) return true + const key = keyNotation(event) + if (!key) return true - function handle(event: KeyEvent) { - if (!active()) return false - if (forwarding) return true - if (opening) { - opening.push(new KeyEvent(event)) - consume(event) - return true - } - if (context.keymap.pending().length) return true - const key = keyNotation(event as never) - if (!key) return true - - if (state.mode() === "insert") { - const editor = input - const handled = adapter.handle(event as never, key, ctx) - if (handled) consume(event) - if (state.mode() === "normal" && input === editor) { - // Escape closes a custom answer, but dismisses a text-only form. - // Only forward it when the host exposes the close-edit action. - if (context.keymap.active().some((item) => item.group === "Form" && item.description === "Close answer edit")) { - sendKey("escape", "\x1b") - } - } - return true + if (state.mode() === "insert") { + const editor = input + const handled = adapter.handle(event, key, editorContext) + if (handled) consume(event) + if (state.mode() === "normal" && input === editor) { + // Escape closes a custom answer, but dismisses a text-only form. + // Only forward it when the host exposes the close-edit action. + if (context.keymap.active().some((item) => item.group === "Form" && item.description === "Close answer edit")) { + sendKey("escape", "\x1b") } + } + return true + } - if (event.ctrl || event.meta || event.option || event.super || event.hyper) { - if (key === "") { - consume(event) - sendKey("escape", "\x1b") - } - return true - } - if (key === "i") { - consume(event) - if (input) state.setMode("insert") - else { - // An empty native paste selects and opens the custom answer - // without inserting a character or submitting another option. - context.renderer.keyInput.emit("paste", new PasteEvent(new Uint8Array())) - } - return true - } - const arrows: Record = { - h: ["left", "\x1b[D"], j: ["down", "\x1b[B"], - k: ["up", "\x1b[A"], l: ["right", "\x1b[C"], - } - const arrow = arrows[key] - if (arrow) { - consume(event) - sendKey(...arrow) - return true - } - if (key === "" && !input) waitForEditor() - if (/^[1-9]$/.test(key) || key === "") { - consume(event) - // Keep the native selection shortcut, without triggering the - // custom row's printable-character interceptor. - sendKey(event.name, "") - if (!input) waitForEditor() - } else if (event.sequence && !/[\p{C}]/u.test(event.sequence) && key !== "" && key !== "") { - consume(event) - } - return true + if (event.ctrl || event.meta || event.option || event.super || event.hyper) { + if (key === "") { + consume(event) + sendKey("escape", "\x1b") + } + return true + } + if (key === "i") { + consume(event) + if (input) state.setMode("insert") + else { + // An empty native paste selects and opens the custom answer + // without inserting a character or submitting another option. + context.renderer.keyInput.emit("paste", new PasteEvent(new Uint8Array())) + } + return true + } + const arrows: Record = { + h: ["left", "\x1b[D"], + j: ["down", "\x1b[B"], + k: ["up", "\x1b[A"], + l: ["right", "\x1b[C"], + } + const arrow = arrows[key] + if (arrow) { + consume(event) + sendKey(...arrow) + return true + } + if (key === "" && !input) waitForEditor() + if (/^[1-9]$/.test(key) || key === "") { + consume(event) + // Keep the native selection shortcut, without triggering the + // custom row's printable-character interceptor. + sendKey(event.name, "") + if (!input) waitForEditor() + } else if (event.sequence && !/[\p{C}]/u.test(event.sequence) && key !== "" && key !== "") { + consume(event) } + return true + } - return { handle, focus, paste } + return { handle, focus, paste } } diff --git a/src/modules/vim/actions.ts b/src/modules/vim/actions.ts deleted file mode 100644 index bde1c8c..0000000 --- a/src/modules/vim/actions.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { CursorStyleOptions, LineInfo, RGBA, WidthMethod } from "@opentui/core" -import type { VimCursorStyle } from "./config" - -type PromptInfo = { - input: string - mode: string - parts: unknown[] -} - -type PromptRef = { - current: PromptInfo - set: (value: PromptInfo) => void - submit: () => void - blur: () => void -} - -export type PromptContext = { - api: { - renderer: { currentFocusedRenderable?: unknown; widthMethod?: WidthMethod } - keymap: { dispatchCommand: (command: string) => { ok: boolean } } - theme: { current: { warning: RGBA; info: RGBA; background: RGBA } } - } - prompt: () => PromptRef | undefined - requestRender: () => void -} - -export type EditBufferLike = { - isDestroyed?: boolean - cursorOffset?: number - plainText?: string - visualCursor?: VisualCursorLike - editorView?: { getLogicalLineInfo?: () => LineInfo; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void } - cursorStyle?: CursorStyleOptions - selectionBg?: RGBA - selectionFg?: RGBA - moveCursorLeft?: () => boolean - moveCursorRight?: () => boolean - moveCursorUp?: () => boolean - moveCursorDown?: () => boolean - setSelection?: (start: number, end: number) => void - insertText?: (text: string) => void - setSelectionInclusive?: (start: number, end: number) => void - clearSelection?: () => void - gotoLineEnd?: () => void -} - -export type VisualCursorLike = { - visualRow?: number - visualCol?: number - logicalRow?: number - logicalCol?: number - offset?: number -} - -export function applyVimCursorStyle(ctx: PromptContext, style: VimCursorStyle) { - const input = focusedInput(ctx) - if (!input) return false - input.cursorStyle = style - return true -} - -export function focusedInput(ctx: PromptContext): EditBufferLike | undefined { - const focused = ctx.api.renderer.currentFocusedRenderable as EditBufferLike | null | undefined - if (!focused || !hasEditBufferMethods(focused)) return undefined - return focused -} - -export function setInput(ref: PromptRef, input: string) { - ref.set(toPromptInfo(ref, input)) -} - -function hasEditBufferMethods(input: EditBufferLike) { - return typeof input.moveCursorLeft === "function" || typeof input.moveCursorRight === "function" || typeof input.moveCursorUp === "function" || typeof input.moveCursorDown === "function" || typeof input.gotoLineEnd === "function" -} - -function toPromptInfo(ref: PromptRef, input: string): PromptInfo { - return { - input, - mode: ref.current.mode, - parts: [...ref.current.parts], - } -} diff --git a/src/modules/vim/config.ts b/src/modules/vim/config.ts deleted file mode 100644 index 4221cc2..0000000 --- a/src/modules/vim/config.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { parseKeySequence } from "@vimee/core" -import type { VimMode } from "./state" - -export type VimCursorStyle = { - style: "block" | "line" | "underline" | "default" - blinking?: boolean -} - -export type VimConfig = { - defaultMode: VimMode - diffView: DiffView - sessionKey: string - keymapTimeout: number - pendingDisplayDelay: number - cursorStyles: Record - debug: boolean - debugPath?: string - keymaps: VimKeymaps -} - -export type VimOptions = { - defaultMode?: VimMode - diffView?: DiffView - sessionKey?: string - keymapTimeout?: number - pendingDisplayDelay?: number - cursorStyles?: Partial> - debug?: boolean - debugPath?: string - keymaps?: VimKeymaps -} - -export type DiffView = "after" | "before" | "diff" -export type SessionAction = "switch-panel" | "passthrough" -export type VimKeymaps = Partial>> & { - session?: Record -} -export type VimMappedAction = string - -const DEFAULT_CURSOR_STYLES: Record = { - insert: { style: "line", blinking: true }, - normal: { style: "block", blinking: true }, - visual: { style: "block", blinking: true }, - "visual-line": { style: "block", blinking: true }, -} - -export function createVimConfig(options: unknown): VimConfig { - const input = readOptions(options) - return { - defaultMode: input.defaultMode ?? "insert", - diffView: input.diffView ?? "after", - sessionKey: input.sessionKey ?? "s", - keymapTimeout: Math.max(0, input.keymapTimeout ?? 500), - pendingDisplayDelay: Math.max(0, input.pendingDisplayDelay ?? 120), - cursorStyles: { - insert: { ...DEFAULT_CURSOR_STYLES.insert, ...input.cursorStyles?.insert }, - normal: { ...DEFAULT_CURSOR_STYLES.normal, ...input.cursorStyles?.normal }, - visual: { ...DEFAULT_CURSOR_STYLES.visual, ...input.cursorStyles?.visual }, - "visual-line": { ...DEFAULT_CURSOR_STYLES["visual-line"], ...input.cursorStyles?.["visual-line"] }, - }, - debug: input.debug ?? process.env.VIM_PROMPT_DEBUG === "1", - debugPath: input.debugPath, - keymaps: input.keymaps ?? {}, - } -} - -function readOptions(options: unknown): VimOptions { - if (!options || typeof options !== "object") return {} - const raw = "vim" in options ? (options as { vim?: unknown }).vim : options - if (!raw || typeof raw !== "object") return {} - - const source = raw as Record - return { - defaultMode: isMode(source.defaultMode) ? source.defaultMode : undefined, - diffView: source.diffView === "after" || source.diffView === "before" || source.diffView === "diff" ? source.diffView : undefined, - sessionKey: readSessionKey(source.sessionKey), - keymapTimeout: readNumber(source.keymapTimeout), - pendingDisplayDelay: typeof source.pendingDisplayDelay === "number" ? source.pendingDisplayDelay : undefined, - cursorStyles: readCursorStyles(source.cursorStyles), - debug: typeof source.debug === "boolean" ? source.debug : undefined, - debugPath: typeof source.debugPath === "string" ? source.debugPath : undefined, - keymaps: readKeymaps(source.keymaps), - } -} - -function readSessionKey(input: unknown): string | undefined { - if (typeof input !== "string") return - try { - if (parseKeySequence(input).length === 1) return input - } catch {} -} - -function readKeymaps(input: unknown): VimKeymaps | undefined { - if (!input || typeof input !== "object") return undefined - const source = input as Record - const keymaps: VimKeymaps = {} - - for (const mode of ["insert", "normal", "visual", "visual-line"] as const) { - const raw = source[mode] - if (!raw || typeof raw !== "object") continue - keymaps[mode] = {} - for (const [key, action] of Object.entries(raw as Record)) { - if (isMappedAction(action)) keymaps[mode][key] = action - } - } - if (source.session && typeof source.session === "object") { - keymaps.session = {} - for (const [key, action] of Object.entries(source.session)) { - if (action === "switch-panel" || action === "passthrough") keymaps.session[key] = action - } - } - - return keymaps -} - -function readCursorStyles(input: unknown): VimOptions["cursorStyles"] { - if (!input || typeof input !== "object") return undefined - const source = input as Record - return { - insert: readCursorStyle(source.insert), - normal: readCursorStyle(source.normal), - visual: readCursorStyle(source.visual), - "visual-line": readCursorStyle(source["visual-line"]), - } -} - -function readCursorStyle(input: unknown): VimCursorStyle | undefined { - if (!input || typeof input !== "object") return undefined - const source = input as Record - if (!isCursorStyle(source.style)) return undefined - return { - style: source.style, - blinking: typeof source.blinking === "boolean" ? source.blinking : undefined, - } -} - -function isCursorStyle(value: unknown): value is VimCursorStyle["style"] { - return value === "block" || value === "line" || value === "underline" || value === "default" -} - -function isMode(value: unknown): value is VimMode { - return value === "insert" || value === "normal" || value === "visual" || value === "visual-line" -} - -function isMappedAction(value: unknown): value is VimMappedAction { - return typeof value === "string" && value.length > 0 -} - -function readNumber(value: unknown) { - return typeof value === "number" ? value : undefined -} diff --git a/src/modules/vim/edit.ts b/src/modules/vim/edit.ts deleted file mode 100644 index 483d38b..0000000 --- a/src/modules/vim/edit.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { WidthMethod } from "@opentui/core" -import { charToDisplay } from "./map" - -const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) - -type Input = { - plainText: string - cursorOffset: number - setSelection: (start: number, end: number) => void - insertText: (text: string) => void - clearSelection: () => unknown -} - -export function editInput(input: Input, value: string, widthMethod: WidthMethod) { - const before = input.plainText - if (before === value) return - input.clearSelection() - - // Find the changed span without allocating grapheme objects for both buffers. - let start = 0 - while (start < before.length && start < value.length && before[start] === value[start]) start++ - const beforeSegments = graphemes.segment(before) - const afterSegments = graphemes.segment(value) - start = Math.min(segmentAt(before, beforeSegments, start)?.index ?? start, segmentAt(value, afterSegments, start)?.index ?? start) - - let end = before.length - let valueEnd = value.length - while (end > start && valueEnd > start && before[end - 1] === value[valueEnd - 1]) { - end-- - valueEnd-- - } - - // Expand the ends together until neither cuts a grapheme in the shared suffix. - while (true) { - const left = segmentAt(before, beforeSegments, end) - const right = segmentAt(value, afterSegments, valueEnd) - const leftExtra = left && left.index < end ? left.index + left.segment.length - end : 0 - const rightExtra = right && right.index < valueEnd ? right.index + right.segment.length - valueEnd : 0 - const extra = Math.max(leftExtra, rightExtra) - if (!extra) break - end += extra - valueEnd += extra - } - - const startOffset = charToDisplay(before, start, widthMethod) - - if (start === end) input.cursorOffset = startOffset - else input.setSelection(startOffset, startOffset + charToDisplay(before.slice(start, end), end - start, widthMethod)) - input.insertText(value.slice(start, valueEnd)) -} - -function segmentAt(text: string, segments: Intl.Segments, index: number) { - // Bun's containing() can include the previous grapheme at a high surrogate. - // Query its low surrogate instead, which is inside the same grapheme. - const point = text.codePointAt(index) - if (point !== undefined && point > 0xffff) index++ - return segments.containing(index) -} diff --git a/src/modules/vim/graphemes.ts b/src/modules/vim/graphemes.ts deleted file mode 100644 index 79e2843..0000000 --- a/src/modules/vim/graphemes.ts +++ /dev/null @@ -1,42 +0,0 @@ -const segments = new Intl.Segmenter(undefined, { granularity: "grapheme" }) - -// Vimee indexes JavaScript strings by UTF-16 code unit. Give each multi-unit -// grapheme one private-use character so motions, operators and registers cannot -// split it. The dictionary lives with the adapter, including its undo history. -export function createGraphemeCodec() { - const encoded = new Map() - const decoded = new Map() - let next = 0xe000 - - // The caller has already segmented this value; do not segment it again. - function encodeGrapheme(segment: string) { - const code = segment.charCodeAt(0) - if (segment.length === 1 && (code < 0xe000 || code > 0xf8ff)) return segment - let token = encoded.get(segment) - if (!token) { - if (next > 0xf8ff) throw new Error("Vim grapheme dictionary is full") - token = String.fromCharCode(next++) - encoded.set(segment, token) - decoded.set(token, segment) - } - return token - } - - return { - encodeGrapheme, - encode(text: string) { - let result = "" - for (const { segment } of segments.segment(text)) { - result += encodeGrapheme(segment) - } - return result - }, - decode(text: string) { - let result = "" - for (const token of text) result += decoded.get(token) ?? token - return result - }, - } -} - -export type GraphemeCodec = ReturnType diff --git a/src/modules/vim/keys.ts b/src/modules/vim/keys.ts deleted file mode 100644 index 48b574c..0000000 --- a/src/modules/vim/keys.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { KeyEvent } from "@opentui/core" - -const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) - -const NAMED_KEYS: Record = { - escape: "", - esc: "", - return: "", - enter: "", - tab: "", - backspace: "", - delete: "", - space: "", - up: "", - down: "", - left: "", - right: "", - home: "", - end: "", - pageup: "", - pagedown: "", -} - -export function keyNotation(event: KeyEvent) { - const name = event.name?.toLowerCase() - if (!name) return undefined - - if (event.ctrl) return `` - if (event.meta) return `` - if ([...graphemes.segment(name)].length === 1) return event.shift ? name.toUpperCase() : name - return NAMED_KEYS[name] ?? `<${name}>` -} - -function ctrlKey(name: string) { - if (name === "escape" || name === "esc") return "[" - if (name.length === 1) return name - return name -} diff --git a/src/modules/vim/log.ts b/src/modules/vim/log.ts deleted file mode 100644 index 275469f..0000000 --- a/src/modules/vim/log.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { appendFile, mkdir } from "node:fs/promises" -import { dirname, join } from "node:path" -import type { VimConfig } from "./config" - -export type VimLog = (event: string, data?: Record) => void - -export function createVimLog(config: VimConfig): VimLog { - if (!config.debug) return () => {} - - const path = config.debugPath ?? defaultPath() - void append(path, `\n--- opencode-vim debug ${new Date().toISOString()} ---\n`) - - return (event, data) => { - const suffix = data ? ` ${safeJson(data)}` : "" - void append(path, `${new Date().toISOString()} ${event}${suffix}\n`) - } -} - -function defaultPath() { - const home = process.env.HOME - return home ? join(home, ".cache/opencode/opencode-vim.log") : "/tmp/opencode-vim.log" -} - -async function append(path: string, text: string) { - try { - await mkdir(dirname(path), { recursive: true }) - await appendFile(path, text) - } catch {} -} - -function safeJson(data: Record) { - try { - return JSON.stringify(data) - } catch { - return "{}" - } -} diff --git a/src/modules/vim/map.ts b/src/modules/vim/map.ts deleted file mode 100644 index 2b0e011..0000000 --- a/src/modules/vim/map.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { CursorPosition } from "@vimee/core" -import type { WidthMethod } from "@opentui/core" -import { createGraphemeCodec } from "./graphemes" - -const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) - -function graphemeWidth(value: string, method: WidthMethod) { - // Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero. - // OpenTUI's edit buffer gives tabs a fixed two-column width by default. - if (value === "\t") return 2 - if (value === "\n") return 1 - if (method === "wcwidth") { - let width = 0 - for (const character of value) width += Bun.stringWidth(character) - return width - } - return Bun.stringWidth(value) -} - -export function charToDisplay(text: string, charIndex: number, method: WidthMethod = "unicode"): number { - let width = 0 - const limit = Math.max(0, Math.min(charIndex, text.length)) - for (const part of graphemes.segment(text)) { - if (part.index + part.segment.length > limit) break - width += graphemeWidth(part.segment, method) - } - return width -} - -export function displayToChar(text: string, displayOffset: number, method: WidthMethod = "unicode"): number { - let width = 0 - for (const part of graphemes.segment(text)) { - const next = width + graphemeWidth(part.segment, method) - if (next > displayOffset) return part.index - width = next - } - return text.length -} - -export function displayWidth(text: string, method: WidthMethod = "unicode"): number { - return charToDisplay(text, text.length, method) -} - -export type PromptMap = { - hostText: string - vimText: string - hostToVim: number[] - vimToHost: number[] - vimToDisplay: number[] - lineStarts: number[] - displayWidth: number -} - -export function createPromptMap(hostText: string, codec = createGraphemeCodec(), method: WidthMethod = "unicode"): PromptMap { - const hostToVim: number[] = [] - const vimToHost: number[] = [] - const vimToDisplay: number[] = [] - const lineStarts = [0] - let vimText = "" - let vimOffset = 0 - let width = 0 - - for (const { index: hostOffset, segment } of graphemes.segment(hostText)) { - for (let index = hostOffset; index < hostOffset + segment.length; index++) hostToVim[index] = vimOffset - const encoded = codec.encodeGrapheme(segment) - vimText += encoded - vimToHost[vimOffset] = hostOffset - vimToDisplay[vimOffset] = width - width += graphemeWidth(segment, method) - vimOffset++ - if (encoded === "\n") lineStarts.push(vimOffset) - } - - hostToVim[hostText.length] = vimOffset - vimToHost[vimOffset] = hostText.length - vimToDisplay[vimOffset] = width - return { hostText, vimText, hostToVim, vimToHost, vimToDisplay, lineStarts, displayWidth: width } -} - -export function hostPosition(map: PromptMap, hostDisplayOffset: number): CursorPosition { - const offset = indexAtOffset(map.vimToDisplay, hostDisplayOffset) - const line = indexAtOffset(map.lineStarts, offset) - return { line, col: offset - map.lineStarts[line] } -} - -export function hostCharOffset(map: PromptMap, hostDisplayOffset: number) { - return map.vimToHost[indexAtOffset(map.vimToDisplay, hostDisplayOffset)] -} - -export function hostOffset(map: PromptMap, position: CursorPosition) { - return hostFromVimOffset(map, vimOffsetFromPosition(map, position)) -} - -export function hostFromVimOffset(map: PromptMap, offset: number) { - return map.vimToDisplay[clamp(offset, 0, map.vimText.length)] -} - -export function vimLineLength(map: PromptMap, line: number) { - const start = map.lineStarts[line] - if (start === undefined) return 0 - const end = line + 1 < map.lineStarts.length ? map.lineStarts[line + 1] - 1 : map.vimText.length - return end - start -} - -export function vimOffsetFromPosition(map: PromptMap, position: CursorPosition) { - const line = clamp(position.line, 0, map.lineStarts.length - 1) - return map.lineStarts[line] + clamp(position.col, 0, vimLineLength(map, line)) -} - -// Find the last boundary at or before an offset, including zero-width graphemes. -function indexAtOffset(offsets: number[], offset: number) { - let low = 0 - let high = offsets.length - while (low < high) { - const middle = Math.floor((low + high) / 2) - if (offsets[middle] <= offset) low = middle + 1 - else high = middle - } - return Math.max(0, low - 1) -} - -function clamp(value: number, min: number, max: number) { - return Math.max(min, Math.min(max, value)) -} diff --git a/src/modules/vim/state.tsx b/src/modules/vim/state.tsx deleted file mode 100644 index fed8853..0000000 --- a/src/modules/vim/state.tsx +++ /dev/null @@ -1,46 +0,0 @@ -// Keep this module as TSX so OpenCode's plugin loader rewrites its Solid import -// to the host runtime shared by the footer and cursor effects. -import { createSignal } from "solid-js" -import type { VimLog } from "./log" - -export type VimMode = "normal" | "insert" | "visual" | "visual-line" -export type VimStateListener = () => void - -export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) { - const [mode, setMode] = createSignal(defaultMode) - const [pending, setPending] = createSignal("") - const listeners = new Set() - - log("state.init", { mode: defaultMode }) - - return { - mode, - setMode(next: VimMode) { - const changed = mode() !== next - if (changed) log("state.mode", { from: mode(), to: next }) - setMode(next) - if (changed) notify() - }, - pending, - setPending(next: string) { - const changed = pending() !== next - if (changed) log("state.pending", { from: pending(), to: next }) - setPending(next) - if (changed) notify() - }, - subscribe(listener: VimStateListener) { - listeners.add(listener) - return () => listeners.delete(listener) - }, - } - - function notify() { - for (const listener of listeners) { - try { - listener() - } catch (error) { - log("state.listener.error", { error: error instanceof Error ? error.message : String(error) }) - } - } - } -} diff --git a/src/modules/vim/vimee.ts b/src/modules/vim/vimee.ts deleted file mode 100644 index aa43d11..0000000 --- a/src/modules/vim/vimee.ts +++ /dev/null @@ -1,1200 +0,0 @@ -import type { KeyEvent, WidthMethod } from "@opentui/core" -import { TextBuffer, createInitialContext, createKeybindMap, executeOperatorOnRange, parseKeySequence, processKeystroke, resetContext, resolveMotion } from "@vimee/core" -import type { CursorPosition, KeybindDefinition, KeybindMap, MotionRange, Operator, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core" -import { focusedInput, setInput, type EditBufferLike, type PromptContext } from "./actions" -import type { VimConfig } from "./config" -import type { VimLog } from "./log" -import { createGraphemeCodec } from "./graphemes" -import { displayToChar, displayWidth, createPromptMap, hostCharOffset, hostFromVimOffset, hostOffset, hostPosition, vimLineLength, vimOffsetFromPosition, type PromptMap } from "./map" -import type { createVimState } from "./state" - -type VimState = ReturnType -type HostAction = VimeeAction & { register?: string } | { type: "submit" } | { type: "command"; command: string } -type HostKeybindAction = "normal" | "submit" | "command" -type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction; command?: string } -type HostRange = { start: number; end: number } - -export const YANK_FLASH_MS = 250 - -export type VimeeAdapter = ReturnType - -type AdapterOptions = { - readOnly?: boolean - onYank?: (text: string) => void - readClipboard?: () => Promise -} - -export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimLog, options: AdapterOptions = {}) { - const codec = createGraphemeCodec() - let buffer = new TextBuffer("") - let activeMap = createPromptMap("", codec) - let vim = createInitialContext({ line: 0, col: 0 }) - const keybinds = options.readOnly ? undefined : createKeybinds(config, log) - let timer: ReturnType | undefined - let yankTimer: ReturnType | undefined - let yankFlashActive = false - let pendingInsert = "" - let pendingTarget: { input: EditBufferLike; offset: number } | undefined - let pendingContext: PromptContext | undefined - let nativeInsertUndoSaved = false - let historyText: string | undefined - let activeInput: EditBufferLike | undefined - let widthMethod: WidthMethod = "unicode" - let preferredColumn: number | undefined - let preferredScreen = false - let generation = 0 - const defaultHistoryKeys = { - k: !hasNormalKeyPrefix(config, "k"), - j: !hasNormalKeyPrefix(config, "j"), - } - - return { - attach, - suspend, - setRegister(text: string) { vim = { ...vim, register: codec.encode(text) } }, - isPending: () => vim.phase !== "idle" || vim.count > 0 || !!keybinds?.isPending(), - handle(event: KeyEvent, key: string, ctx: PromptContext): boolean | Promise { - const ref = ctx.prompt() - if (!ref) return false - const input = focusedInput(ctx) - attach(ctx) - - let vimeeKey = keyForVimee(event, key) - if (!vimeeKey) return false - if (options.readOnly && !readOnlyKey(vimeeKey, event.ctrl, vim)) { - vim = resetContext(vim) - state.setPending("") - return true - } - vimeeKey = codec.encode(vimeeKey) - - if (state.mode() === "insert") { - if (vimeeKey === "Escape") { - cancelPendingInsert(ctx) - enterNormal(ctx) - state.setPending("") - updateTimeout(ctx) - log("vimee.key", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] }) - return true - } - return handleInsertKeybind(event, vimeeKey, ctx) - } - - const text = input?.plainText ?? ref.current.input - - const canBrowseHistory = !options.readOnly && vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending() - const historyCommand = canBrowseHistory ? defaultHistoryCommand(vimeeKey, text, historyText, defaultHistoryKeys) : undefined - if (historyCommand) { - const result = dispatchCommand(historyCommand, ctx) - historyText = result.ok ? (focusedInput(ctx)?.plainText ?? ref.current.input) : undefined - state.setPending("") - updateTimeout(ctx) - log("vimee.history", { key, command: historyCommand, handled: result.ok }) - return true - } - historyText = undefined - - const map = mapForHostText(text) - const displayOff = clamp(input?.cursorOffset ?? map.displayWidth, 0, map.displayWidth) - const cursor = hostPosition(map, displayOff) - - const wasPending = keybinds?.isPending() ?? false - const pendingBefore = pendingInsert - sync(cursor) - - const shouldFlashYank = shouldFlashYankFor(vimeeKey) - const visualYankRange = visualYankRangeFor(map) - vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey - const textObjectHandled = handleTextObject(vimeeKey, ctx, map) - if (textObjectHandled !== undefined) return textObjectHandled - const resolved = keybinds?.resolve(vimeeKey, vim.mode, event.ctrl) - if (resolved?.status === "pending") { - vim = { ...vim, statusMessage: resolved.display } - state.setPending(resolved.display) - updateTimeout(ctx) - log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: [] }) - return true - } - if (vimeeKey === "Enter" && state.mode() === "normal" && vim.phase === "idle" && !wasPending && resolved?.status !== "matched") { - ref.submit() - state.setPending("") - updateTimeout(ctx) - log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["submit"] }) - return true - } - - const commandKey = vimeeKey - const definition = resolved?.status === "matched" ? resolved.definition : undefined - if (options.readClipboard && needsClipboard(commandKey, event.ctrl, definition)) { - const before = generation - return options.readClipboard().then((text) => { - // A focus change, mouse move or external edit cancels a pending put. - if (before !== generation || input?.isDestroyed || input?.plainText !== map.hostText || input?.cursorOffset !== displayOff) return false - vim = { ...vim, register: codec.encode(text) } - return finish() - }) - } - return finish() - - function finish() { - let result - if (resolved?.status === "matched") { - const actions = applyKeybind(resolved.definition, ctx, map) - result = { newCtx: vim, actions } - } else { - // Use screen-row movement for uncounted j/k and arrows in normal/visual mode. - // Apply this at the input boundary so custom mapping sequences stay literal. - if (!event.ctrl && vim.phase === "idle" && vim.count === 0 && (commandKey === "j" || commandKey === "k" || commandKey === "ArrowDown" || commandKey === "ArrowUp")) { - vim = processKey("g").newCtx - } - result = processKey(commandKey, event.ctrl) - } - vim = result.newCtx - const actions = result.actions as HostAction[] - if (resolved?.status !== "matched") applyActions(actions, ctx, map) - if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(actions), visualYankRange) - syncMode(state, vim.mode) - - const keybindPending = keybinds?.isPending() ?? false - if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, hostCharOffset(map, displayOff)) - pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : "" - state.setPending(pendingDisplay(vim, keybindPending)) - updateTimeout(ctx) - log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) }) - return consumesKey(commandKey, actions, vim, keybindPending) - } - }, - cleanup: suspend, - } - - function needsClipboard(key: string, ctrl: boolean, definition?: KeybindDefinition) { - if (definition) { - if ("execute" in definition) return false - return parseKeySequence(definition.keys).some((token) => ["p", "P", ".", "@"].includes(token)) - } - if (ctrl) return false - if (vim.phase === "macro-execute-pending") return true - if (vim.phase !== "idle" || vim.selectedRegister) return false - if (key === "p" || key === "P") return true - return key === "." && vim.lastChange.some((token) => ["p", "P", "@"].includes(token)) - } - - function attach(ctx: PromptContext) { - const input = focusedInput(ctx) - if (activeInput === input) return - suspend() - activeInput = input - widthMethod = ctx.api.renderer.widthMethod ?? "unicode" - activeMap = createPromptMap(input?.plainText ?? ctx.prompt()?.current.input ?? "", codec, widthMethod) - buffer = new TextBuffer(activeMap.vimText) - nativeInsertUndoSaved = false - vim = { ...resetContext(vim), cursor: hostPosition(activeMap, input?.cursorOffset ?? 0), mode: state.mode() } - } - - function suspend() { - generation++ - preferredColumn = undefined - if (pendingContext) cancelPendingInsert(pendingContext) - if (timer) clearTimeout(timer) - timer = undefined - keybinds?.cancel() - cancelYankFlash() - if (activeInput && !activeInput.isDestroyed) clearVisualSelection(activeInput) - if (isVisualMode(state.mode())) state.setMode("normal") - vim = resetContext(vim) - state.setPending("") - pendingTarget = undefined - pendingContext = undefined - } - - function sync(cursor: CursorPosition) { - if (cursor.line !== vim.cursor.line || cursor.col !== vim.cursor.col) preferredColumn = undefined - vim = { ...vim, cursor, mode: state.mode() } - } - - function mapForHostText(text: string) { - if (activeMap.hostText === text) return activeMap - const nextMap = createPromptMap(text, codec, widthMethod) - if (state.mode() === "insert") recordNativeChange(nextMap) - activeMap = nextMap - if (state.mode() === "insert") { - if (!nativeInsertUndoSaved) { - buffer.saveUndoPoint(vim.cursor) - nativeInsertUndoSaved = true - } - buffer.replaceContent(activeMap.vimText) - } else { - buffer = new TextBuffer(activeMap.vimText) - nativeInsertUndoSaved = false - } - return activeMap - } - - function recordNativeChange(nextMap: PromptMap) { - const before = activeMap.vimText - const after = nextMap.vimText - const start = vimOffsetFromPosition(activeMap, vim.cursor) - let prefix = 0 - while (prefix < start && prefix < after.length && before[prefix] === after[prefix]) prefix++ - let suffix = 0 - while (suffix < before.length - start && suffix < after.length - prefix && before[before.length - suffix - 1] === after[after.length - suffix - 1]) suffix++ - const keys = [...vim.pendingChange] - if (!keys.length) keys.push("i") - for (let index = prefix; index < start; index++) keys.push("Backspace") - for (let index = start; index < before.length - suffix; index++) keys.push("Delete") - for (const key of after.slice(prefix, after.length - suffix)) keys.push(key === "\n" ? "Enter" : key === "\t" ? "Tab" : key) - vim = { ...vim, pendingChange: keys } - } - - function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap) { - const ref = ctx.prompt() - const input = focusedInput(ctx) - let currentMap = map - if (!ref) return - - for (const action of actions) { - switch (action.type) { - case "content-change": - currentMap = createPromptMap(codec.decode(action.content), codec, widthMethod) - activeMap = currentMap - setInput(ref, currentMap.hostText) - // Decoding can join neighboring graphemes; use the map's units. - buffer.replaceContent(currentMap.vimText) - break - case "yank": - if (!action.register) options.onYank?.(codec.decode(action.text)) - break - case "mode-change": - nativeInsertUndoSaved = action.mode === "insert" && actions.some((item) => item.type === "content-change") - if (action.mode === "insert") { - cancelYankFlash() - if (input) clearVisualSelection(input) - } - syncMode(state, action.mode) - break - case "quit": - ref.blur() - break - case "submit": - ref.submit() - break - case "command": - dispatchCommand(action.command, ctx) - return - } - } - - // The engine context already has the final cursor; move the editor once. - setCursor(input, currentMap, vim.cursor) - syncVisualSelection(input, currentMap, ctx) - } - - function handleInsertKeybind(event: KeyEvent, key: string, ctx: PromptContext): boolean { - if (!keybinds?.hasKeybinds("insert") && !keybinds?.isPending()) return false - - const wasPending = keybinds.isPending() - const pendingBefore = pendingInsert - const resolved = keybinds.resolve(key, "insert", event.ctrl) - - switch (resolved.status) { - case "pending": - if (!wasPending) { - const input = focusedInput(ctx) - pendingTarget = input ? { input, offset: input.cursorOffset ?? 0 } : undefined - pendingContext = ctx - } - pendingInsert = codec.decode(plainPending(resolved.display)) - state.setPending(resolved.display) - updateTimeout(ctx) - return true - case "matched": { - if (applyInsertKeybind(resolved.definition, ctx)) { - pendingInsert = "" - state.setPending("") - updateTimeout(ctx) - log("vimee.keybind", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] }) - return true - } - - if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore) - pendingInsert = "" - state.setPending("") - updateTimeout(ctx) - log("vimee.keybind.unsupported", { key }) - return false - } - case "none": - if (wasPending && pendingBefore) flushPendingInsert(ctx, pendingBefore) - pendingInsert = "" - state.setPending("") - updateTimeout(ctx) - return wasPending ? handleInsertKeybind(event, key, ctx) : false - } - } - - function applyInsertKeybind(definition: KeybindDefinition, ctx: PromptContext) { - switch (insertHostAction(definition)) { - case "normal": - enterNormal(ctx) - return true - case "submit": - ctx.prompt()?.submit() - return true - case "command": - dispatchCommand((definition as HostKeybindDefinition).command!, ctx) - return true - default: - return false - } - } - - function enterNormal(ctx: PromptContext) { - const ref = ctx.prompt() - const input = focusedInput(ctx) - const text = input?.plainText ?? ref?.current.input ?? "" - const map = mapForHostText(text) - - if (input && text.length > 0) { - const dw = map.displayWidth - const offset = clamp(input?.cursorOffset ?? dw, 0, dw) - const charOffset = hostCharOffset(map, offset) - if (charOffset > 0 && text[charOffset - 1] !== "\n") { - input.cursorOffset = hostOffset(map, hostPosition(map, Math.max(0, offset - 1))) - } - clampNormalCursor(input, map) - } - - const lastChange = nativeInsertUndoSaved ? [...vim.pendingChange, "Escape"] : vim.lastChange - vim = { ...resetContext(vim), cursor: hostPosition(map, input?.cursorOffset ?? 0), mode: "normal", statusMessage: "", lastChange, pendingChange: [] } - nativeInsertUndoSaved = false - syncMode(state, "normal") - } - - function insertHostAction(definition: KeybindDefinition): HostKeybindAction | undefined { - const action = (definition as HostKeybindDefinition).hostAction - if (action) return action - if ("execute" in definition) return undefined - if (definition.keys === "" || definition.keys === "" || definition.keys === "Escape") return "normal" - return undefined - } - - function applyKeybind(definition: KeybindDefinition, ctx: PromptContext, map: PromptMap) { - if ("execute" in definition) { - const actions = definition.execute(vim, buffer) as HostAction[] - vim = { ...vim, cursor: hostPosition(map, hostOffset(map, vim.cursor)) } - applyActions(actions, ctx, map) - return actions - } - - let actions: HostAction[] = [] - for (const token of parseKeySequence(definition.keys)) { - const result = processKey(codec.encode(keyToken(token)), tokenCtrl(token)) - vim = result.newCtx - // Keep the host layout current for screen motions later in the mapping. - applyActions(result.actions, ctx, activeMap) - actions = [...actions, ...(result.actions as HostAction[])] - } - return actions - } - - function processKey(key: string, ctrl = false): { newCtx: VimContext; actions: HostAction[] } { - const register = vim.selectedRegister - const moving = !ctrl && vim.mode !== "insert" && (vim.phase === "idle" || vim.phase === "operator-pending" || vim.phase === "g-pending") - const vertical = moving && (key === "j" || key === "k" || key === "ArrowDown" || key === "ArrowUp") - const lineEnd = moving && key === "$" && vim.phase === "idle" - const result = processKeyInner(key, ctrl) - if (register) { - for (const action of result.actions) { - if (action.type === "yank") action.register = register - } - } - if (result.actions.some((action) => action.type === "content-change" || action.type === "mode-change")) preferredColumn = undefined - else if (!vertical && result.actions.some((action) => action.type === "cursor-move")) preferredColumn = lineEnd ? Infinity : undefined - return result - } - - function processKeyInner(key: string, ctrl: boolean): { newCtx: VimContext; actions: HostAction[] } { - let range = ctrl ? undefined : lineMotion(key) - if (range && vim.operator && (key === "j" || key === "k" || key === "ArrowDown" || key === "ArrowUp") && range.start.line === range.end.line && range.start.col === range.end.col) { - return { newCtx: { ...resetContext(vim), pendingChange: [] }, actions: [] } - } - if (range && !vim.operator) { - return { newCtx: { ...resetContext(vim), cursor: range.end }, actions: [{ type: "cursor-move", position: range.end }] } - } - let wordRange: MotionRange | undefined - if (!ctrl && vim.mode !== "insert" && (key === "w" || key === "W") && (vim.phase === "idle" || vim.phase === "operator-pending")) { - wordRange = forwardWordRange(key, vim.cursor, buffer, vim.count || 1) - if (vim.phase === "idle") { - const cursor = { ...wordRange.end, col: Math.min(wordRange.end.col, Math.max(0, buffer.getLineLength(wordRange.end.line) - 1)) } - return { newCtx: { ...resetContext(vim), cursor }, actions: [{ type: "cursor-move", position: cursor }] } - } - } - if (key === "." && !ctrl && vim.mode === "normal" && vim.phase === "idle" && vim.lastChange.length) { - // A repeated insert/change is one undo step, including native typing. - const original = buffer - const before = buffer.getContent() - const cursor = vim.cursor - const keys = [...vim.lastChange] - if (vim.count) { - while (/^[0-9]$/.test(keys[0] ?? "")) keys.shift() - keys.unshift(...String(vim.count)) - } - vim = { ...vim, count: 0 } - const actions: HostAction[] = [] - buffer = new TextBuffer(before) - try { - for (const token of keys) { - const result = processKey(token) - vim = result.newCtx - actions.push(...result.actions) - } - if (buffer.getContent() !== before) { - original.saveUndoPoint(cursor) - original.replaceContent(buffer.getContent()) - } - } finally { - buffer = original - } - return { newCtx: { ...vim, lastChange: keys, pendingChange: [] }, actions } - } - - const operator = vim.operator - if (!range && !ctrl && operator && textObjectOperator(operator)) { - if (vim.phase === "operator-pending") { - const count = vim.count || 1 - if (key === "l" || key === "ArrowRight") { - // Vimee 0.3 treats l as inclusive; Vim's operator motion is exclusive. - range = { start: vim.cursor, end: { line: vim.cursor.line, col: Math.min(buffer.getLineLength(vim.cursor.line), vim.cursor.col + count) }, inclusive: false, linewise: false } - } else if (key === operator) { - range = { start: { line: vim.cursor.line, col: 0 }, end: { line: Math.min(buffer.getLineCount() - 1, vim.cursor.line + count - 1), col: 0 }, inclusive: true, linewise: true } - } else if (operator === "c" && key === "w" && /\S/.test(buffer.getLine(vim.cursor.line)[vim.cursor.col] ?? "")) { - range = resolveMotion("e", { ...vim.cursor, col: vim.cursor.col - 1 }, buffer, count, vim.count > 0, vim)?.range - if (range) range.start = vim.cursor - } else if (wordRange) { - range = wordRange - if (operator === "d" && range.end.line > range.start.line && /\S/.test(buffer.getLine(range.start.line)[range.start.col] ?? "") && range.end.col === Math.max(0, buffer.getLine(range.end.line).search(/\S/))) { - range.end = { line: range.end.line - 1, col: buffer.getLineLength(range.end.line - 1) } - } - } else if (operator === "c") { - const motion = resolveMotion(key, vim.cursor, buffer, count, vim.count > 0, vim) - if (motion?.range.linewise) range = motion.range - } - } else if (vim.phase === "text-object-pending" && vim.textObjectModifier) { - range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer) ?? undefined - } - } - if (range && operator) { - // Vim's exclusive motions ending at column zero stop before the newline. - // Starting in the first nonblank column makes that range linewise instead. - if (vim.phase === "g-pending" && !range.inclusive) { - const ordered = orderedRange(range) - if (ordered.end.line > ordered.start.line && ordered.end.col === 0) { - const linewise = ordered.start.col <= Math.max(0, buffer.getLine(ordered.start.line).search(/\S/)) - range = { start: ordered.start, end: { line: ordered.end.line - 1, col: buffer.getLineLength(ordered.end.line - 1) }, linewise, inclusive: false } - } - } - const keys: string[] = [] - if (vim.count) keys.push(...String(vim.count)) - for (const token of vim.pendingChange) { - if (!/^[0-9]$/.test(token)) keys.push(token) - } - keys.push(key) - const result = executeTextObject(operator, range, buffer, vim) - if (operator === "y") result.context.pendingChange = [] - else if (result.context.mode === "insert") result.context.pendingChange = keys - else { result.context.lastChange = keys; result.context.pendingChange = [] } - return { newCtx: result.context, actions: result.actions } - } - - // The engine saves undo points for yanks too. Run read-only operations - // against a disposable buffer so they cannot alter the editing history. - const yanking = operator === "y" || (isVisualMode(vim.mode) && key === "y") || (vim.mode === "normal" && key === "Y") - const register = vim.selectedRegister ? vim.registers[vim.selectedRegister] ?? "" : vim.register - const emptyLinePaste = !ctrl && vim.mode === "normal" && vim.phase === "idle" && (key === "p" || key === "P") - && buffer.getContent() === "" && register.endsWith("\n") - const result = processKeystroke(key, vim, yanking ? new TextBuffer(buffer.getContent()) : buffer, ctrl, false) - if (emptyLinePaste) { - // Linewise puts should fill an empty prompt, not keep its placeholder line. - // Remove just that line, keeping the engine's undo point and repeat keys. - buffer.deleteLines(key === "p" ? 0 : buffer.getLineCount() - 1, 1) - result.newCtx.cursor = { line: 0, col: 0 } - for (const action of result.actions) { - if (action.type === "content-change") action.content = buffer.getContent() - if (action.type === "cursor-move") action.position = result.newCtx.cursor - } - } - return result - } - - function lineMotion(key: string): MotionRange | undefined { - if (vim.mode !== "normal" && !isVisualMode(vim.mode)) return - const screen = vim.phase === "g-pending" - if (!screen && vim.phase !== "idle" && vim.phase !== "operator-pending") return - const down = key === "j" || key === "ArrowDown" - const up = key === "k" || key === "ArrowUp" - if (!down && !up && !(screen && (key === "0" || key === "^" || key === "$"))) return - - const map = buffer.getContent() === activeMap.vimText ? activeMap : createPromptMap(codec.decode(buffer.getContent()), codec, widthMethod) - const offset = hostOffset(map, vim.cursor) - const count = vim.count || 1 - let start: number - let end: number - let column: number - if (screen) { - const lines = activeInput?.editorView?.getLogicalLineInfo?.() - if (!lines?.lineStartCols.length) return - let row = 0 - while (row + 1 < lines.lineStartCols.length && lines.lineStartCols[row + 1] <= offset) row++ - column = offset - lines.lineStartCols[row] - const delta = down ? count : up ? -count : key === "$" ? count - 1 : 0 - const target = clamp(row + delta, 0, lines.lineStartCols.length - 1) - start = lines.lineStartCols[target] - end = start + Math.max(0, lines.lineWidthCols[target] - 1) - if ((down || up) && target === row) return { start: vim.cursor, end: vim.cursor, linewise: false, inclusive: false } - } else { - const lineStart = hostOffset(map, { line: vim.cursor.line, col: 0 }) - column = offset - lineStart - const line = clamp(vim.cursor.line + (down ? count : -count), 0, buffer.getLineCount() - 1) - start = hostOffset(map, { line, col: 0 }) - end = Math.max(start, hostOffset(map, { line, col: buffer.getLineLength(line) }) - 1) - } - - let target: number - if (down || up) { - if (preferredColumn === undefined || (preferredScreen !== screen && preferredColumn !== Infinity)) preferredColumn = column - preferredScreen = screen - target = Math.min(start + preferredColumn, end) - } else if (key === "$") { - target = end - } else { - target = start - if (key === "^") { - const from = hostCharOffset(map, start) - const to = hostCharOffset(map, end + 1) - const first = map.hostText.slice(from, to).search(/\S/) - if (first >= 0) target = hostFromVimOffset(map, map.hostToVim[from + first]) - } - } - return { start: vim.cursor, end: hostPosition(map, target), linewise: !screen, inclusive: !screen || key === "$" } - } - - function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) { - if (vim.phase !== "text-object-pending" || !vim.textObjectModifier) return undefined - const range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer) - if (!range) return undefined - - const flashRange = vim.operator === "y" ? motionHostRange(map, range) : undefined - - if (!vim.operator) { - vim = { ...resetContext(vim), visualAnchor: range.start, cursor: range.end } - applyActions([{ type: "cursor-move", position: range.end }], ctx, map) - state.setPending("") - updateTimeout(ctx) - log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["cursor-move"] }) - return true - } - if (!textObjectOperator(vim.operator)) return undefined - - const result = processKey(key) - vim = result.newCtx - applyActions(result.actions, ctx, map) - if (flashRange) flashYank(ctx, activeMap, result.actions.find((action): action is YankAction => action.type === "yank"), flashRange) - syncMode(state, vim.mode) - state.setPending("") - updateTimeout(ctx) - log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) }) - return true - } - - function updateTimeout(ctx: PromptContext) { - if (timer) clearTimeout(timer) - timer = undefined - if (!keybinds?.isPending()) return - timer = setTimeout(() => { - keybinds.cancel() - flushPendingInsert(ctx, pendingInsert) - pendingInsert = "" - state.setPending("") - ctx.requestRender() - }, config.keymapTimeout) - } - - function cancelPendingInsert(ctx: PromptContext, offset?: number, flush = true) { - if (!keybinds?.isPending()) return - if (timer) clearTimeout(timer) - timer = undefined - keybinds.cancel() - if (flush) flushPendingInsert(ctx, pendingInsert, offset) - pendingInsert = "" - pendingTarget = undefined - pendingContext = undefined - state.setPending("") - } - - function flushPendingInsert(ctx: PromptContext, value: string, charOffset?: number) { - if (!value || state.mode() !== "insert") return - if (pendingTarget?.input.isDestroyed) return - if (pendingTarget?.input.insertText && !pendingTarget.input.isDestroyed) { - const { input, offset } = pendingTarget - const cursor = input.cursorOffset ?? 0 - input.clearSelection?.() - input.cursorOffset = offset - input.insertText!(value) - input.cursorOffset = cursor >= offset ? cursor + displayWidth(value, widthMethod) : cursor - return - } - const ref = ctx.prompt() - if (!ref) return - const input = focusedInput(ctx) - const text = input?.plainText ?? ref.current.input - const currentDisplayOff = input?.cursorOffset ?? 0 - const currentCharOff = displayToChar(text, currentDisplayOff, widthMethod) - const insertAtChar = charOffset ?? currentCharOff - const next = text.slice(0, insertAtChar) + value + text.slice(insertAtChar) - setInput(ref, next) - const nextCharOff = currentCharOff >= insertAtChar ? currentCharOff + value.length : currentCharOff - if (input) input.cursorOffset = displayWidth(next.slice(0, nextCharOff), widthMethod) - } - - function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) { - if (!input) return - const offset = hostOffset(map, position) - if (input.cursorOffset !== offset) input.cursorOffset = offset - if (vim.mode !== "insert") clampNormalCursor(input, map) - } - - function syncVisualSelection(input: EditBufferLike | undefined, map: PromptMap, ctx: PromptContext) { - if (!input) return - if (!isVisualMode(vim.mode) || !vim.visualAnchor) { - if (!yankFlashActive) clearVisualSelection(input) - return - } - - cancelYankFlash() - const range = vim.mode === "visual-line" ? visualLineRange(map, vim.visualAnchor, vim.cursor) : visualCharRange(map, vim.visualAnchor, vim.cursor) - if (!range) { - clearVisualSelection(input) - return - } - - setSelection(input, range.start, range.end, ctx) - } - - function visualYankRangeFor(map: PromptMap) { - if (!isVisualMode(vim.mode) || !vim.visualAnchor) return undefined - return vim.mode === "visual-line" ? visualLineRange(map, vim.visualAnchor, vim.cursor) : visualCharRange(map, vim.visualAnchor, vim.cursor) - } - - function flashYank(ctx: PromptContext, map: PromptMap, action: YankAction | undefined, visualRange: HostRange | undefined) { - if (!action) return - const input = focusedInput(ctx) - if (!input) return - const range = visualRange ?? yankedTextRange(map, vim.cursor, action.text) - if (!range) return - - cancelYankFlash() - yankFlashActive = true - setYankSelection(input, range.start, range.end, ctx) - ctx.requestRender() - yankTimer = setTimeout(() => { - yankTimer = undefined - if (!yankFlashActive) return - yankFlashActive = false - clearVisualSelection(input) - ctx.requestRender() - }, YANK_FLASH_MS) - } - - function cancelYankFlash() { - if (yankTimer) clearTimeout(yankTimer) - yankTimer = undefined - yankFlashActive = false - } - - function shouldFlashYankFor(key: string) { - if (isVisualMode(vim.mode)) return key === "y" - return vim.operator === "y" - } -} - -function readOnlyKey(key: string, ctrl: boolean, vim: VimContext) { - if (key === "Escape") return true - if (ctrl) return false - if (vim.phase === "char-pending" || vim.phase === "text-object-pending" || vim.phase === "register-pending") return true - if (vim.phase === "g-pending") return ["g", "j", "k", "0", "^", "$", "ArrowDown", "ArrowUp"].includes(key) - if ((vim.phase === "operator-pending" || isVisualMode(vim.mode)) && (key === "i" || key === "a")) return true - return /^[0-9hjklwWbBeE$^gGfFtT;,vVy%{}()"]$/.test(key) || ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(key) -} - -function forwardWordRange(key: string, start: CursorPosition, buffer: TextBuffer, count: number): MotionRange { - let { line, col } = start - for (let index = 0; index < count; index++) { - const text = buffer.getLine(line) - const kind = wordClass(text[col], key === "W") - while (col < text.length && wordClass(text[col], key === "W") === kind) col++ - while (col < text.length && /\s/.test(text[col])) col++ - while (col >= buffer.getLineLength(line) && line < buffer.getLineCount() - 1) { - line++ - col = 0 - const next = buffer.getLine(line) - if (!next.length) break - while (col < next.length && /\s/.test(next[col])) col++ - if (col < next.length) break - } - } - return { start, end: { line, col }, linewise: false, inclusive: false } -} - -function wordClass(char: string | undefined, big: boolean) { - if (!char || /\s/.test(char)) return "space" - return big || /\w/.test(char) ? "word" : "punctuation" -} - -function visualCharRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined { - const anchorOffset = hostOffset(map, anchor) - const cursorOffset = hostOffset(map, cursor) - return hostRange(map, anchorOffset, cursorOffset) -} - -function visualLineRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined { - const startLine = Math.min(anchor.line, cursor.line) - const endLine = Math.max(anchor.line, cursor.line) - const start = hostOffset(map, { line: startLine, col: 0 }) - const end = hostOffset(map, { line: endLine, col: vimLineLength(map, endLine) }) - return hostRange(map, start, end) -} - -function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string): HostRange | undefined { - if (!text) return undefined - if (text.endsWith("\n")) { - const lineCount = text.split("\n").length - 1 - return visualLineRange(map, cursor, { line: cursor.line + Math.max(0, lineCount - 1), col: 0 }) - } - - const start = vimOffsetFromPosition(map, cursor) - return vimOffsetRange(map, start, start + text.length - 1) -} - -function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefined { - if (range.linewise) return visualLineRange(map, range.start, range.end) - const start = vimOffsetFromPosition(map, range.start) - const end = vimOffsetFromPosition(map, range.end) - return vimOffsetRange(map, start, end) -} - -function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined { - const start = hostFromVimOffset(map, Math.min(left, right)) - const end = hostFromVimOffset(map, Math.max(left, right)) - return hostRange(map, start, end) -} - -function hostRange(map: PromptMap, left: number, right: number): HostRange | undefined { - if (!map.hostText) return undefined - const dw = map.displayWidth - const start = clamp(Math.min(left, right), 0, Math.max(0, dw - 1)) - const end = clamp(Math.max(left, right), 0, Math.max(0, dw - 1)) - return { start, end } -} - -type YankAction = Extract - -function yankAction(actions: HostAction[]): YankAction | undefined { - return actions.find((action): action is YankAction => action.type === "yank") -} - -function setSelection(input: EditBufferLike, start: number, end: number, ctx: PromptContext) { - setSelectionColors(input, start, end, ctx.api.theme.current.warning, ctx.api.theme.current.background) -} - -function setYankSelection(input: EditBufferLike, start: number, end: number, ctx: PromptContext) { - setSelectionColors(input, start, end, ctx.api.theme.current.info, ctx.api.theme.current.background) -} - -function setSelectionColors(input: EditBufferLike, start: number, end: number, background: PromptContext["api"]["theme"]["current"]["warning"], foreground: PromptContext["api"]["theme"]["current"]["background"]) { - input.selectionBg = background - input.selectionFg = foreground - - if (input.setSelectionInclusive) { - input.setSelectionInclusive(start, end) - return - } - - const exclusiveEnd = end + 1 - if (input.setSelection) { - input.setSelection(start, exclusiveEnd) - return - } - - input.editorView?.setSelection?.(start, exclusiveEnd, background, foreground) -} - -function clearVisualSelection(input: EditBufferLike) { - if (input.clearSelection) { - input.clearSelection() - return - } - - input.editorView?.resetSelection?.() -} - -function isVisualMode(mode: VimContext["mode"]): mode is "visual" | "visual-line" { - return mode === "visual" || mode === "visual-line" -} - -function clampNormalCursor(input: EditBufferLike, map: PromptMap) { - const cursor = input.visualCursor - const offset = input.cursorOffset - const text = map.hostText - if (!cursor || offset === undefined) return - if (cursor.visualCol === 0) return - const dw = map.displayWidth - if (offset >= dw) { - input.cursorOffset = hostOffset(map, hostPosition(map, Math.max(0, dw - 1))) - return - } - const charIdx = hostCharOffset(map, offset) - if (charIdx < text.length && text[charIdx] === '\n') { - input.cursorOffset = hostOffset(map, hostPosition(map, Math.max(0, offset - 1))) - } -} - -function dispatchCommand(command: string, ctx: PromptContext) { - const input = focusedInput(ctx) - if (command === "prompt.history.previous" && input) input.cursorOffset = 0 - if (command === "prompt.history.next" && input) input.cursorOffset = input.plainText?.length ?? 0 - - const result = ctx.api.keymap.dispatchCommand(command) - - // The host currently checks history-next using character length, then leaves - // the cursor in that unit. Restore the display-width offset after dispatch. - if (command !== "prompt.history.next" || !result.ok) return result - const nextInput = focusedInput(ctx) - if (nextInput?.plainText !== undefined) nextInput.cursorOffset = displayWidth(nextInput.plainText, ctx.api.renderer.widthMethod) - return result -} - -function defaultHistoryCommand(key: string, text: string, historyText: string | undefined, enabled: Record<"j" | "k", boolean>) { - if (key !== "j" && key !== "k") return - if (!enabled[key]) return - if (text.length > 0 && text !== historyText) return - return key === "k" ? "prompt.history.previous" : "prompt.history.next" -} - -function hasNormalKeyPrefix(config: VimConfig, key: string) { - for (const sequence of Object.keys(config.keymaps.normal ?? {})) { - try { - if (keyToken(parseKeySequence(sequence)[0] ?? "") === key) return true - } catch {} - } - return false -} - -function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined { - const map = createKeybindMap() - let count = 0 - - for (const mode of ["insert", "normal", "visual", "visual-line"] as const) { - const keymaps = config.keymaps[mode] - if (!keymaps) continue - for (const [keys, action] of Object.entries(keymaps)) { - try { - const tokens = parseKeySequence(keys) - if (tokens.length > 1 && tokens[0] === "") throw new Error(" cannot start a multi-key mapping") - map.addKeybind(mode, keys as ValidKeySequence, keybindAction(action)) - count++ - } catch (error) { - log("vimee.keymap.invalid", { mode, keys, action, error: error instanceof Error ? error.message : String(error) }) - } - } - } - - return count > 0 ? map : undefined -} - -function keybindAction(action: string): HostKeybindDefinition { - if (action.startsWith("command:")) { - const command = action.slice(8).trim() - if (!command) throw new Error("Command name is required") - return { - execute: () => [{ type: "command", command } as unknown as VimeeAction], - hostAction: "command", - command, - } - } - switch (action) { - case "normal": - return { keys: "", hostAction: "normal" } - case "insert": - return { keys: "i" } - case "submit": - return { execute: () => [{ type: "submit" } as unknown as VimeeAction], hostAction: "submit" } - default: - return { keys: action } - } -} - -function keyForVimee(event: KeyEvent, key: string) { - if (key === "") return "Escape" - if (event.ctrl) return event.name?.toLowerCase() - const token = keyToken(key) - return token.startsWith("<") ? undefined : token -} - -function keyToken(token: string) { - if (token === "" || token === "") return "Escape" - if (token === "") return "Enter" - if (token === "") return "Tab" - if (token === "") return "Backspace" - if (token === "") return "Delete" - if (token === "") return " " - if (token === "") return "ArrowUp" - if (token === "") return "ArrowDown" - if (token === "") return "ArrowLeft" - if (token === "") return "ArrowRight" - if (token === "") return "Home" - if (token === "") return "End" - if (token.startsWith("")) return token.slice(3, -1).toLowerCase() - return token -} - -function tokenCtrl(token: string) { - return token.startsWith("" -} - -function syncMode(state: VimState, mode: VimContext["mode"]) { - state.setMode(mode === "insert" || mode === "visual" || mode === "visual-line" ? mode : "normal") -} - -function pendingDisplay(ctx: VimContext, keybindPending: boolean) { - const count = ctx.count > 0 ? String(ctx.count) : "" - if (ctx.phase === "operator-pending") return count + (ctx.operator ?? "") - if (ctx.phase === "text-object-pending") return count + (ctx.operator ?? "") + (ctx.textObjectModifier ?? "") - if (ctx.phase === "char-pending") return count + (ctx.operator ?? "") + (ctx.charCommand ?? "") - if (keybindPending) return ctx.statusMessage - return count -} - -function plainPending(value: string) { - return value.includes("<") || value.includes(">") ? "" : value -} - -function consumesKey(key: string, actions: HostAction[], ctx: VimContext, keybindPending: boolean) { - if (actions.length > 0) return true - if (keybindPending) return true - if (ctx.phase !== "idle") return true - return ctx.mode !== "insert" || key === "Escape" -} - -function textObjectAlias(key: string, ctx: VimContext) { - if (ctx.phase !== "text-object-pending") return undefined - if (key === "b") return "(" - if (key === "B") return "{" - return undefined -} - -function textObjectOperator(operator: Operator): operator is "y" | "d" | "c" { - return operator === "y" || operator === "d" || operator === "c" -} - -function resolvePromptTextObject(modifier: "i" | "a", key: string, cursor: CursorPosition, buffer: TextBuffer): MotionRange | null { - if (key === "q") return quoteRange(modifier, cursor, buffer) - if (key !== "p") return null - return paragraphRange(modifier, cursor, buffer) -} - -function quoteRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null { - const pair = quoteObjectPair(cursor, buffer) - if (!pair) return null - const start = modifier === "i" ? pair.open + 1 : pair.open - const end = modifier === "i" ? pair.close - 1 : pair.close - return { - start: { line: cursor.line, col: start }, - end: { line: cursor.line, col: Math.max(start, end) }, - linewise: false, - inclusive: end >= start, - } -} - -function paragraphRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null { - const count = buffer.getLineCount() - if (count === 0) return null - - let start = clamp(cursor.line, 0, count - 1) - while (start < count && blankLine(buffer.getLine(start))) start++ - if (start >= count) { - start = clamp(cursor.line, 0, count - 1) - while (start >= 0 && blankLine(buffer.getLine(start))) start-- - } - if (start < 0 || start >= count) return null - - let end = start - while (start > 0 && !blankLine(buffer.getLine(start - 1))) start-- - while (end < count - 1 && !blankLine(buffer.getLine(end + 1))) end++ - - if (modifier === "a") { - if (end < count - 1 && blankLine(buffer.getLine(end + 1))) { - end++ - while (end < count - 1 && blankLine(buffer.getLine(end + 1))) end++ - } else { - while (start > 0 && blankLine(buffer.getLine(start - 1))) start-- - } - } - - return { - start: { line: start, col: 0 }, - end: { line: end, col: Math.max(0, buffer.getLineLength(end) - 1) }, - linewise: true, - inclusive: true, - } -} - -function quoteObjectPair(cursor: CursorPosition, buffer: TextBuffer) { - let best: { open: number; close: number; distance: number } | undefined - for (const quote of ['"', "'", "`"] as const) { - const pair = quotePair(cursor, buffer, quote) - if (!pair) continue - if (!best || pair.distance < best.distance) best = pair - } - return best -} - -function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) { - const line = buffer.getLine(cursor.line) - let open = -1 - let close = -1 - let inQuote = false - let quoteStart = -1 - - for (let index = 0; index < line.length; index++) { - if (line[index] !== quote || escaped(line, index)) continue - if (!inQuote) { - quoteStart = index - inQuote = true - continue - } - if (cursor.col >= quoteStart && cursor.col <= index) { - return { open: quoteStart, close: index, distance: 0 } - } - inQuote = false - } - - for (let index = cursor.col; index < line.length; index++) { - if (line[index] !== quote || escaped(line, index)) continue - if (open === -1) open = index - else { - close = index - break - } - } - if (open !== -1 && close !== -1) return { open, close, distance: open - cursor.col } - - close = -1 - open = -1 - for (let index = cursor.col; index >= 0; index--) { - if (line[index] !== quote || escaped(line, index)) continue - if (close === -1) close = index - else { - open = index - break - } - } - if (open !== -1 && close !== -1) return { open, close, distance: cursor.col - close } - return undefined -} - -function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) { - if (operator !== "y") buffer.saveUndoPoint(ctx.cursor) - if (operator === ">" || operator === "<") { - const result = executeOperatorOnRange(operator, range, buffer, ctx.cursor, { style: ctx.indentStyle, width: ctx.indentWidth }) - const cursor = { line: result.newCursor.line, col: Math.max(0, buffer.getLine(result.newCursor.line).search(/\S/)) } - return { - actions: [...result.actions, { type: "cursor-move", position: cursor }] as VimeeAction[], - context: { ...resetContext(ctx), cursor }, - yankedText: "", - } - } - const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer, ctx.cursor) : executeCharwiseTextObject(operator, range, buffer) - const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers - const context = { - ...resetContext(ctx), - mode: result.mode, - cursor: result.cursor, - register: result.yankedText, - registers, - statusMessage: result.statusMessage, - } - const actions: VimeeAction[] = [{ type: "yank", text: result.yankedText }, ...result.actions, { type: "mode-change", mode: result.mode }, { type: "cursor-move", position: result.cursor }] - return { actions, context, yankedText: result.yankedText } -} - -function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, cursor: CursorPosition) { - const startLine = Math.min(range.start.line, range.end.line) - const endLine = Math.max(range.start.line, range.end.line) - const lineCount = endLine - startLine + 1 - const yankedText = buffer.getLines().slice(startLine, endLine + 1).join("\n") + "\n" - if (operator === "y") { - const col = Math.min(cursor.col, Math.max(0, buffer.getLineLength(startLine) - 1)) - return { actions: [] as VimeeAction[], cursor: { line: startLine, col }, mode: "normal" as VimeeMode, yankedText, statusMessage: lineCount >= 2 ? `${lineCount} lines yanked` : "" } - } - - buffer.deleteLines(startLine, lineCount) - if (operator === "c") buffer.insertLine(startLine, "") - else if (buffer.getLineCount() === 0) buffer.insertLine(0, "") - const line = Math.min(startLine, buffer.getLineCount() - 1) - return { - actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[], - cursor: { line, col: 0 }, - mode: (operator === "c" ? "insert" : "normal") as VimeeMode, - yankedText, - statusMessage: lineCount >= 2 ? `${lineCount} fewer lines` : "", - } -} - -function executeCharwiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) { - const ordered = orderedRange(range) - const endCol = range.inclusive ? ordered.end.col + 1 : ordered.end.col - const yankedText = textInRange(buffer, ordered.start, { line: ordered.end.line, col: endCol }) - if (operator === "y") { - return { actions: [] as VimeeAction[], cursor: ordered.start, mode: "normal" as VimeeMode, yankedText, statusMessage: yankedText.split("\n").length >= 2 ? `${yankedText.split("\n").length} lines yanked` : "" } - } - - const linesBefore = buffer.getLineCount() - buffer.deleteRange(ordered.start.line, ordered.start.col, ordered.end.line, endCol) - const linesRemoved = linesBefore - buffer.getLineCount() - return { - actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[], - cursor: { line: ordered.start.line, col: operator === "c" ? ordered.start.col : Math.min(ordered.start.col, Math.max(0, buffer.getLineLength(ordered.start.line) - 1)) }, - mode: (operator === "c" ? "insert" : "normal") as VimeeMode, - yankedText, - statusMessage: linesRemoved >= 2 ? `${linesRemoved} fewer lines` : "", - } -} - -function orderedRange(range: MotionRange) { - if (range.start.line > range.end.line || (range.start.line === range.end.line && range.start.col > range.end.col)) { - return { start: range.end, end: range.start } - } - return { start: range.start, end: range.end } -} - -function textInRange(buffer: TextBuffer, start: CursorPosition, end: CursorPosition) { - if (start.line === end.line) return buffer.getLine(start.line).slice(start.col, end.col) - const lines = [buffer.getLine(start.line).slice(start.col)] - for (let line = start.line + 1; line < end.line; line++) lines.push(buffer.getLine(line)) - lines.push(buffer.getLine(end.line).slice(0, end.col)) - return lines.join("\n") -} - -function blankLine(line: string) { - return line.trim().length === 0 -} - -function escaped(line: string, index: number) { - let slashCount = 0 - for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor--) slashCount++ - return slashCount % 2 === 1 -} - -function clamp(value: number, min: number, max: number) { - return Math.max(min, Math.min(max, value)) -} diff --git a/src/plugin.tsx b/src/plugin.tsx index 7afeed8..d4b377c 100644 --- a/src/plugin.tsx +++ b/src/plugin.tsx @@ -1,22 +1,21 @@ /** @jsxImportSource @opentui/solid */ import { Plugin } from "@opencode/plugin/tui" +import type { Context } from "@opencode/plugin/tui/context" import { InputRenderable, KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core" import { createEffect, createSignal, onCleanup, untrack } from "solid-js" -import { applyVimCursorStyle, focusedInput } from "./modules/vim/actions" -import { createVimConfig } from "./modules/vim/config" -import { editInput } from "./modules/vim/edit" -import { keyNotation } from "./modules/vim/keys" -import { createVimLog } from "./modules/vim/log" -import { displayToChar } from "./modules/vim/map" -import { createVimState } from "./modules/vim/state" -import { createVimeeAdapter } from "./modules/vim/vimee" -import { VimStatus } from "./modules/vim/status" +import type { EditorContext } from "./vim/editor" +import { createVimConfig } from "./vim/config" +import { editInput } from "./vim/edit" +import { keyNotation } from "./vim/keys" +import { createVimLog } from "./vim/log" +import { displayToChar } from "./vim/map" +import { createVimState, type VimMode } from "./vim/state" +import { createVimeeAdapter } from "./vim/vimee" +import { VimStatus } from "./vim/status" import { SESSION_MODE, createSessionMode } from "./session" import { createVimClipboard } from "./clipboard" import { createFormMode } from "./form" -type Context = Parameters[0]["setup"]>[0] - export default Plugin.define({ id: "opencode-vim", setup(context) { @@ -29,7 +28,12 @@ function VimHost(props: { context: Context }) { const normalMappings = Object.keys(config.keymaps.normal ?? {}) const log = createVimLog(config) const clipboard = createVimClipboard(props.context.renderer) - const clipboardOptions = { onYank: (text: string) => { void clipboard.write(text) }, readClipboard: clipboard.read } + const clipboardOptions = { + onYank: (text: string) => { + void clipboard.write(text) + }, + readClipboard: clipboard.read, + } const state = createVimState(config.defaultMode, log) const vimee = createVimeeAdapter(state, config, log, clipboardOptions) const dialogState = createVimState(config.defaultMode, log) @@ -41,7 +45,7 @@ function VimHost(props: { context: Context }) { const [saved, setSaved] = props.context.storage.store("state", { initial: { enabled: true } }) const enabled = () => saved.enabled const form = createFormMode(props.context, config, log, enabled) - const ctx = createCompatContext(props.context) + const editorContext = createEditorContext(props.context) let cursorMode = "" let cursorInput: typeof props.context.renderer.currentFocusedEditor = null let originalCursorStyle: CursorStyleOptions | undefined @@ -50,14 +54,24 @@ function VimHost(props: { context: Context }) { const removeStatus = props.context.ui.slot({ prepend: "prompt.footer", - render: (footer) => - footer.mode === "normal" ? session.active() && !dialogFocused() ? : ( + render: (footer) => { + if (footer.mode !== "normal") return null + if (session.active() && !dialogFocused()) return + return ( dialogFocused() ? dialogState.mode() : state.mode()} + mode={() => (dialogFocused() ? dialogState.mode() : state.mode())} enabled={enabled} - theme={compatTheme(props.context)} + theme={{ + get success() { + return props.context.theme.text.feedback.success.base + }, + get warning() { + return props.context.theme.text.feedback.warning.base + }, + }} /> - ) : null, + ) + }, }) props.context.keymap.layer(() => ({ @@ -102,7 +116,7 @@ function VimHost(props: { context: Context }) { if (!kind) return if (props.context.keymap.pending().length) return const { state, vimee } = kind === "dialog" ? dialogVim : promptVim - const key = keyNotation(event as never) + const key = keyNotation(event) if (!key) return const mapped = normalMappings.some((sequence) => sequence.startsWith(key)) if (kind === "prompt" && key === config.sessionKey && !mapped && state.mode() === "normal" && !vimee.isPending()) { @@ -134,28 +148,30 @@ function VimHost(props: { context: Context }) { const before = state.mode() let consumed = false try { - const result = vimee.handle(event as never, key, ctx as never) + const result = vimee.handle(event, key, editorContext) if (typeof result === "boolean") consumed = result else { consumed = true const queued: Array = [] pendingKeys = queued const input = props.context.renderer.currentFocusedEditor - void result.then((handled) => { - if (pendingKeys !== queued) return - pendingKeys = undefined - if (!handled) return - syncCursor(true) - // Replay through the host too: a queued i may be followed by native typing. - for (const event of queued) { - if (!enabled() || props.context.renderer.currentFocusedEditor !== input) break - if (event instanceof PasteEvent) props.context.renderer.keyInput.emit("paste", event) - else props.context.renderer.keyInput.emit("keypress", event) - } - }).catch((error) => { - if (pendingKeys === queued) pendingKeys = undefined - log("vim.clipboard.paste.error", { error: String(error) }) - }) + void result + .then((handled) => { + if (pendingKeys !== queued) return + pendingKeys = undefined + if (!handled) return + syncCursor(true) + // Replay through the host too: a queued i may be followed by native typing. + for (const event of queued) { + if (!enabled() || props.context.renderer.currentFocusedEditor !== input) break + if (event instanceof PasteEvent) props.context.renderer.keyInput.emit("paste", event) + else props.context.renderer.keyInput.emit("keypress", event) + } + }) + .catch((error) => { + if (pendingKeys === queued) pendingKeys = undefined + log("vim.clipboard.paste.error", { error: String(error) }) + }) } } finally { if (consumed || state.mode() !== before) { @@ -168,13 +184,14 @@ function VimHost(props: { context: Context }) { // Focus events can fire inside the host's prompt effects. Do not subscribe // those effects to Vim state or the keymap registry while syncing the editor. - const onFocus = () => untrack(() => { - pendingKeys = undefined - vimee.suspend() - dialogVimee.suspend() - syncCursor() - form.focus() - }) + const onFocus = () => + untrack(() => { + pendingKeys = undefined + vimee.suspend() + dialogVimee.suspend() + syncCursor() + form.focus() + }) const onPaste = (event: PasteEvent) => { if (event.defaultPrevented) return if (!pendingKeys) { @@ -225,7 +242,7 @@ function VimHost(props: { context: Context }) { dialogState.setMode(promptVim.state.mode()) } const mode = state.mode() - vimee.attach(ctx) + vimee.attach(editorContext) const inputChanged = cursorInput !== input if (inputChanged) { restoreCursor() @@ -233,10 +250,9 @@ function VimHost(props: { context: Context }) { originalCursorStyle = input.cursorStyle } if (!force && !inputChanged && cursorMode === mode) return - if (applyVimCursorStyle(ctx as never, config.cursorStyles[mode])) { - cursorMode = mode - props.context.renderer.requestRender() - } + input.cursorStyle = config.cursorStyles[mode] + cursorMode = mode + props.context.renderer.requestRender() } function restoreCursor() { @@ -246,18 +262,23 @@ function VimHost(props: { context: Context }) { } } -function createCompatContext(context: Context) { - const prompt = { - get current() { - return { input: focusedInputValue(context), mode: "normal", parts: [] } +function createEditorContext(context: Context): EditorContext { + return { + input: () => (inputKind(context) ? (context.renderer.currentFocusedEditor ?? undefined) : undefined), + get widthMethod() { + return context.renderer.widthMethod }, - get focused() { - return inputKind(context) !== undefined + get colors() { + return { + selection: context.theme.text.feedback.warning.base, + yank: context.theme.text.feedback.info.base, + background: context.theme.background.base, + } }, - set(value: { input: string }) { + setText(text) { const input = context.renderer.currentFocusedEditor - if (input instanceof InputRenderable) input.value = value.input - else if (input) editInput(input, value.input, context.renderer.widthMethod) + if (input instanceof InputRenderable) input.value = text + else if (input) editInput(input, text, context.renderer.widthMethod) }, submit() { if (inputKind(context) === "dialog") { @@ -272,22 +293,11 @@ function createCompatContext(context: Context) { blur() { context.renderer.currentFocusedEditor?.blur() }, - } - - return { - api: { - renderer: context.renderer, - keymap: { - dispatchCommand(command: string) { - const ok = context.keymap.commands().some((item) => item.id === command) - if (ok) context.keymap.dispatch(command) - return { ok } - }, - }, - theme: { current: compatTheme(context) }, + dispatchCommand(command) { + const ok = context.keymap.commands().some((item) => item.id === command) + if (ok) context.keymap.dispatch(command) + return { ok } }, - kind: context.ui.router.current().type === "session" ? "session" : "home", - prompt: () => (inputKind(context) ? prompt : undefined), requestRender: () => context.renderer.requestRender(), } } @@ -297,30 +307,20 @@ function inputKind(context: Context): "prompt" | "dialog" | undefined { if (!context.renderer.currentFocusedEditor) return const commands = context.keymap.commands() if (context.keymap.mode.current() === "modal") { - if (commands.some((item) => item.id === "dialog.select.submit" || item.id === "dialog.prompt.submit")) return "dialog" + if (commands.some((item) => item.id === "dialog.select.submit" || item.id === "dialog.prompt.submit")) + return "dialog" return } - if (commands.some( - (item) => - item.id === "prompt.submit" || item.id === "prompt.autocomplete.next" || item.id === "prompt.history.previous", - )) return "prompt" -} - -function focusedInputValue(context: Context) { - return context.renderer.currentFocusedEditor?.plainText ?? "" -} - -function compatTheme(context: Context) { - return { - get background() { return context.theme.background.base }, - get info() { return context.theme.text.feedback.info.base }, - get success() { return context.theme.text.feedback.success.base }, - get warning() { return context.theme.text.feedback.warning.base }, - get textMuted() { return context.theme.text.muted }, - } + if ( + commands.some( + (item) => + item.id === "prompt.submit" || item.id === "prompt.autocomplete.next" || item.id === "prompt.history.previous", + ) + ) + return "prompt" } -function passThroughKey(event: KeyEvent, key: string, mode: string, pending: boolean, mapped: boolean) { +function passThroughKey(event: KeyEvent, key: string, mode: VimMode, pending: boolean, mapped: boolean) { if (key === "" && event.shift) return true if (mode !== "normal") return false if (mapped) return false @@ -328,10 +328,11 @@ function passThroughKey(event: KeyEvent, key: string, mode: string, pending: boo return event.super === true || event.meta === true || (isArrowKey(key) && !pending) } -function sendCompletionKey(event: KeyEvent, context: Context, key: string, mode: string) { +function sendCompletionKey(event: KeyEvent, context: Context, key: string, mode: VimMode) { if (mode !== "normal") return false const command = key === "j" ? "prompt.autocomplete.next" : key === "k" ? "prompt.autocomplete.prev" : undefined - if (!command || !isCompletionToken(context) || !context.keymap.commands().some((item) => item.id === command)) return false + if (!command || !isCompletionToken(context) || !context.keymap.commands().some((item) => item.id === command)) + return false event.preventDefault() event.stopPropagation() context.keymap.dispatch(command) diff --git a/src/readers/diff/data.ts b/src/readers/diff/data.ts index f6087bf..71d6458 100644 --- a/src/readers/diff/data.ts +++ b/src/readers/diff/data.ts @@ -1,5 +1,5 @@ import { parsePatch, type StructuredPatchHunk } from "diff" -import type { DiffView } from "../../modules/vim/config" +import type { DiffView } from "../../vim/config" import type { TranscriptItem } from "../../transcript-items" export type DiffSnapshot = { @@ -12,15 +12,34 @@ export type DiffSnapshot = { export function diffSnapshot(message: TranscriptItem): DiffSnapshot | undefined { const source = message.source - if (source?.type !== "tool" || (source.name !== "edit" && source.name !== "patch") || source.state.status !== "completed") return + if ( + source?.type !== "tool" || + (source.name !== "edit" && source.name !== "patch") || + source.state.status !== "completed" + ) + return const raw = source.state.metadata?.files if (!Array.isArray(raw)) return const files: Array & { patch: string }> = [] for (const item of raw) { if (!item || typeof item !== "object" || Array.isArray(item)) continue const path = item.file ?? item.relativePath - const status = item.status ?? (item.type === "add" ? "added" : item.type === "delete" ? "deleted" : item.type === "update" ? "modified" : undefined) - if (typeof path !== "string" || typeof item.patch !== "string" || typeof item.additions !== "number" || typeof item.deletions !== "number") continue + const status = + item.status ?? + (item.type === "add" + ? "added" + : item.type === "delete" + ? "deleted" + : item.type === "update" + ? "modified" + : undefined) + if ( + typeof path !== "string" || + typeof item.patch !== "string" || + typeof item.additions !== "number" || + typeof item.deletions !== "number" + ) + continue if (status !== "added" && status !== "deleted" && status !== "modified") continue files.push({ path, status, additions: item.additions, deletions: item.deletions, patch: item.patch }) } @@ -31,7 +50,13 @@ export function diffSnapshot(message: TranscriptItem): DiffSnapshot | undefined try { const patches = parsePatch(file.patch) if (patches.length !== 1 || !patches[0].hunks.length) return - return { path: file.path, status: file.status, additions: file.additions, deletions: file.deletions, hunks: patches[0].hunks } + return { + path: file.path, + status: file.status, + additions: file.additions, + deletions: file.deletions, + hunks: patches[0].hunks, + } } catch { return } diff --git a/src/readers/diff/index.tsx b/src/readers/diff/index.tsx index e3098e5..4fabc80 100644 --- a/src/readers/diff/index.tsx +++ b/src/readers/diff/index.tsx @@ -2,7 +2,7 @@ import { pathToFiletype, type LineColorConfig, type LineSign } from "@opentui/core" import { useTerminalDimensions } from "@opentui/solid" import { createMemo, createSignal, For, onCleanup, Show } from "solid-js" -import type { DiffView } from "../../modules/vim/config" +import type { DiffView } from "../../vim/config" import { createReaderSyntax, highlightCode } from "../syntax" import { TextReader } from "../text" import type { ReaderProps } from "../types" @@ -23,47 +23,92 @@ export function DiffReader(props: ReaderProps & { snapshot: DiffSnapshot }) { setView(views[(views.indexOf(view()) + 1) % views.length]) } - return {(current) => { - const content = diffContent(data.hunks, current) - const controller = new AbortController() - onCleanup(() => controller.abort()) - const theme = () => props.context.theme.surface("dialog") - const signs = new Map() - for (const [row, sign] of content.signs) signs.set(row, { - after: sign, afterColor: sign === "+" ? theme().diff.text.added : theme().diff.text.removed, - }) - const lineColors = createMemo(() => { - if (current !== "diff") return - const colors = new Map() - for (const [row, sign] of content.signs) { - const kind = sign === "+" ? "added" : "removed" - colors.set(row, { content: theme().diff.background[kind], gutter: theme().diff.lineNumber.background[kind] }) - } - return colors - }) - const title = current === "after" ? "Code after change" : current === "before" ? "Code before change" : "Changes" - const range = content.empty ? " · No lines" : data.hunks.length > 1 ? " · Saved excerpts" - : current === "diff" ? "" : ` · Lines ${content.start}–${content.end}` - return { - positions.set(current, offset) - if (current === initial) props.remember(offset) - }} change={{ run: switchView, controls: () => ( - - View - {(value) => { event.stopPropagation(); setView(value) }}> - {` ${labels[value]} `} - } - - ) }} highlight={(input) => { - if (filetype) void highlightCode(input, syntax, filetype, controller.signal, props.context.renderer.widthMethod) - }} /> - }} + return ( + + + {(current) => { + const content = diffContent(data.hunks, current) + const controller = new AbortController() + onCleanup(() => controller.abort()) + const theme = () => props.context.theme.surface("dialog") + const signs = new Map() + for (const [row, sign] of content.signs) + signs.set(row, { + after: sign, + afterColor: sign === "+" ? theme().diff.text.added : theme().diff.text.removed, + }) + const lineColors = createMemo(() => { + if (current !== "diff") return + const colors = new Map() + for (const [row, sign] of content.signs) { + const kind = sign === "+" ? "added" : "removed" + colors.set(row, { + content: theme().diff.background[kind], + gutter: theme().diff.lineNumber.background[kind], + }) + } + return colors + }) + const title = + current === "after" ? "Code after change" : current === "before" ? "Code before change" : "Changes" + const range = content.empty + ? " · No lines" + : data.hunks.length > 1 + ? " · Saved excerpts" + : current === "diff" + ? "" + : ` · Lines ${content.start}–${content.end}` + return ( + { + positions.set(current, offset) + if (current === initial) props.remember(offset) + }} + change={{ + run: switchView, + controls: () => ( + + View + + {(value) => ( + { + event.stopPropagation() + setView(value) + }} + > + {` ${labels[value]} `} + + )} + + + ), + }} + highlight={(input) => { + if (filetype) + void highlightCode(input, syntax, filetype, controller.signal, props.context.renderer.widthMethod) + }} + /> + ) + }} + + + ) } diff --git a/src/readers/index.tsx b/src/readers/index.tsx index fbb1279..ef20194 100644 --- a/src/readers/index.tsx +++ b/src/readers/index.tsx @@ -10,8 +10,11 @@ import type { ReaderProps } from "./types" export function Reader(props: ReaderProps) { props.context.ui.dialog.set({ size: "large", centered: true }) const source = props.message.source - if (source?.type === "shell" || (source?.type === "tool" && source.name === "shell") || - (source?.type === "synthetic" && source.metadata?.source === "shell")) { + if ( + source?.type === "shell" || + (source?.type === "tool" && source.name === "shell") || + (source?.type === "synthetic" && source.metadata?.source === "shell") + ) { return } if (source?.type === "tool" && source.name === "read") { diff --git a/src/readers/read/data.ts b/src/readers/read/data.ts index 5474322..5517d61 100644 --- a/src/readers/read/data.ts +++ b/src/readers/read/data.ts @@ -37,7 +37,10 @@ export function readSnapshot(source: ReadSource): ReadSnapshot | undefined { lines.push(line) } return { - path: header[1], text: lines.join("\n"), start, end, + path: header[1], + text: lines.join("\n"), + start, + end, partial: start > 1 || continuation || state.metadata?.truncated === true || clipped, clipped, } diff --git a/src/readers/read/index.tsx b/src/readers/read/index.tsx index dd0776d..8148ab7 100644 --- a/src/readers/read/index.tsx +++ b/src/readers/read/index.tsx @@ -14,10 +14,18 @@ export function ReadReader(props: ReaderProps & { snapshot: ReadSnapshot }) { const filetype = pathToFiletype(data.path) const controller = new AbortController() onCleanup(() => controller.abort()) - return { if (filetype) void highlightCode(input, syntax, filetype, controller.signal, props.context.renderer.widthMethod) }} /> + return ( + { + if (filetype) void highlightCode(input, syntax, filetype, controller.signal, props.context.renderer.widthMethod) + }} + /> + ) } diff --git a/src/readers/shell/data.ts b/src/readers/shell/data.ts index d976383..72a8971 100644 --- a/src/readers/shell/data.ts +++ b/src/readers/shell/data.ts @@ -20,22 +20,32 @@ export function shellSnapshot(source: ShellSource): ShellSnapshot { const command = source.description ?? "" const shellID = typeof metadata.shellID === "string" ? metadata.shellID : undefined const prefix = `\n` - const output = source.text.startsWith(prefix) && source.text.endsWith("\n") - ? source.text.slice(prefix.length, -"\n".length) : source.text + const output = + source.text.startsWith(prefix) && source.text.endsWith("\n") + ? source.text.slice(prefix.length, -"\n".length) + : source.text let status = "Completed" if (metadata.state === "error") status = "Failed" else if (metadata.state === "cancelled") status = "Cancelled" else if (metadata.timeout === true) status = "Timed out" else if (typeof metadata.signal === "string") status = `Killed · ${metadata.signal}` else if (typeof metadata.exit === "number") status = shellStatus("exited", metadata.exit) - return { command, shellID, status, output: cleanOutput(output), notice: metadata.truncated === true ? "Saved output is truncated" : "" } - } - if (source.type === "shell") return { - command: source.command, shellID: source.shellID, - status: shellStatus(source.status, source.exit), - output: cleanOutput(source.output?.output ?? ""), - notice: source.output?.truncated ? "Saved output is truncated" : "", + return { + command, + shellID, + status, + output: cleanOutput(output), + notice: metadata.truncated === true ? "Saved output is truncated" : "", + } } + if (source.type === "shell") + return { + command: source.command, + shellID: source.shellID, + status: shellStatus(source.status, source.exit), + output: cleanOutput(source.output?.output ?? ""), + notice: source.output?.truncated ? "Saved output is truncated" : "", + } const state = source.state if (state.status === "streaming") return { command: "", status: "Receiving command…", output: "", notice: "" } const metadata = state.metadata ?? {} @@ -59,7 +69,9 @@ export function shellSnapshot(source: ShellSource): ShellSnapshot { command: typeof state.input.command === "string" ? state.input.command : "", workdir: typeof state.input.workdir === "string" ? state.input.workdir : undefined, shellID: typeof metadata.shellID === "string" ? metadata.shellID : undefined, - status, output: cleanOutput(output), notice: metadata.truncated === true ? "Saved output is truncated" : "", + status, + output: cleanOutput(output), + notice: metadata.truncated === true ? "Saved output is truncated" : "", } } @@ -87,7 +99,10 @@ export async function loadShell(context: Context, sessionID: string, source: She const info = shell.data const page = output.data return { - ...snapshot, command: info.command, workdir: info.cwd, status: shellStatus(info.status, info.exit), + ...snapshot, + command: info.command, + workdir: info.cwd, + status: shellStatus(info.status, info.exit), output: cleanOutput(page.output), notice: page.cursor < page.size ? "Output truncated · showing first 1 MiB" : "", } diff --git a/src/readers/shell/index.tsx b/src/readers/shell/index.tsx index 56d2586..1cc22ce 100644 --- a/src/readers/shell/index.tsx +++ b/src/readers/shell/index.tsx @@ -21,30 +21,73 @@ export function ShellReader(props: ReaderProps & { source: ShellSource }) { const result = await loadShell(props.context, props.sessionID, props.source, controller.signal) if (!controller.signal.aborted) setSnapshot(result) }) - return - - Loading output… + return ( + + + + + Loading output… + + + } + > + {(data) => ( + command} + highlight={(input) => highlightDiff(input, syntax, props.context.renderer.widthMethod)} + maxHeight={Math.max(1, Math.min(30, dimensions().height - commandHeight() - (data().notice ? 12 : 10)))} + details={ + + + + Command + + + {(directory) => ( + + {props.context.ui.format.path(directory())} + + )} + + +