diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9f474dc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing + +Bug reports, fixes, and improvements are welcome. + +## Reporting bugs + +Include the OpenCode and plugin versions, your operating system and terminal, +and steps to reproduce the problem. Mention the Vim mode, relevant custom +keybindings, and whether you use a terminal multiplexer such as tmux. Describe +what you expected and what happened instead. + +For larger changes, open an issue first so we can agree on the approach. + +## Development setup + +Fork and clone the repository, then create a branch based on `dev`. +Use Node.js 22 (the CI version), npm, and a current version of Bun. + +```sh +npm ci +``` + +The test helpers support Linux and macOS on x64 and ARM64. E2E tests also require +tmux. Downloads use `tar`, plus `unzip` on macOS. + +## Running tests + +```sh +bun run typecheck +bun run test +bun run test:e2e +``` + +- `test` covers editing behavior, plugin integration, source and npm package + loading, and comparisons against a pinned Neovim version downloaded + automatically. +- `test:e2e` builds and packs the current plugin, then runs real terminal + interactions against the latest stable OpenCode 2 release. Each scenario gets + isolated configuration and a fresh session, with imported fixture transcripts + for message-reader and history tests. No model requests are submitted. + Captures, logs, and results are saved under `test-results/e2e/`. + +Run individual scenarios with `bun run test:e2e message-reader`. + +Internet access is needed for initial binary downloads and for E2E tests to +resolve the latest OpenCode release. Downloaded binaries are cached under +`node_modules/.cache/`. + +Use `bun run build` to build the plugin into `dist/`. + +## Adding tests + +Put unit tests in `test/unit/`, integration tests in `test/integration/`, and +shared fixtures and setup in `test/helpers/`. + +Add focused regression coverage for bug fixes. Use E2E scenarios when the behavior +depends on real OpenCode keyboard handling, focus, dialogs, or tabs. + +E2E scenarios live in `test/e2e/scenarios/` and are registered in +`test/e2e/run.ts`. Reuse the shared fixture and terminal helpers, and wait for +expected screen content rather than using fixed delays. + +## Pull requests + +- Target `dev`. +- Follow the surrounding code style and favor straightforward, readable code. +- Keep PRs focused and descriptions short, clear, and to the point. Explain what + changed, why, and how you tested it. Respect reviewers' time: avoid walls of + text, repetitive summaries, and unnecessary detail. Link related issues. +- If you use AI, understand, review, and test the changes before submitting. + You are responsible for everything in your PR. + +## License + +Contributions are licensed under the project's [MIT License](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..95d4250 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tarquinen and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/configuration.md b/docs/configuration.md index 3083951..e47e90f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,8 +29,9 @@ All options below belong inside `options.vim`. | --- | --- | --- | | `defaultMode` | `"insert"` | Starting Vim mode; use `"normal"` to start in normal mode | | `sessionKey` | `"s"` | Single key to enter and leave session mode | +| `diffView` | `"after"` | Starting edit/patch view: `"after"`, `"before"`, or `"diff"`; added/deleted files use the available side | | `keymapTimeout` | `500` | Milliseconds to wait for the rest of a custom mapping | -| `keymaps` | `{}` | Prompt and search-dialog mappings, grouped by mode | +| `keymaps` | `{}` | Custom mappings, grouped by mode | | `cursorStyles` | See below | Cursor appearance for each editing mode | | `debug` | `false` | Enable debug logging | | `debugPath` | `~/.cache/opencode/opencode-vim.log` | Debug log file | @@ -47,8 +48,8 @@ entering session mode. ### Custom keymaps Mappings apply to `insert`, `normal`, `visual`, and `visual-line` editing modes in -the prompt and search dialogs. Session browsing and its read-only modal use their -own bindings. +the prompt and search dialogs. Use `keymaps.session` for transcript browsing and +its message/tool modals. See [Custom Keymaps](./keymap-actions.md) for actions, key notation, and examples. See [Keybindings and Modes](./vim-behavior.md) for the default behavior. diff --git a/docs/keymap-actions.md b/docs/keymap-actions.md index 221b71b..6d0da14 100644 --- a/docs/keymap-actions.md +++ b/docs/keymap-actions.md @@ -15,6 +15,10 @@ Each entry maps a key sequence to an action in one Vim mode. Put `keymaps` insid "H": "0", "L": "$", "q": "command:session.new" + }, + "session": { + "": "passthrough", + "w": "switch-panel" } } } @@ -23,8 +27,8 @@ Each entry maps a key sequence to an action in one Vim mode. Put `keymaps` insid ## Modes and actions Mappings apply while editing the prompt or a search dialog, in `insert`, `normal`, -`visual`, or `visual-line` mode. They do not replace session-browsing or reader -bindings; use `sessionKey` to change the session toggle. +`visual`, or `visual-line` mode. `session` mappings apply to transcript browsing +and its message/tool modals; use `sessionKey` to change the session toggle. | Action | Behavior | | --- | --- | @@ -33,13 +37,19 @@ bindings; use `sessionKey` to change the session toggle. | `submit` | Submit the prompt or confirm the search dialog | | `command:` | Dispatch an active OpenCode command | | Vim key sequence, such as `y$` | Run those Vim keys | +| `switch-panel` | Session only: switch between available panels | +| `passthrough` | Session only: leave a single key to OpenCode without consuming it | Insert-mode mappings support only `normal`, `submit`, `command:`, or Escape -(`""` / `""`). Other editing modes support all action types above. +(`""` / `""`). Other editing modes support all editing actions above. Mapping sequences are literal: mapping `j` to `j` uses an actual line, while mapping it to `gj` uses a wrapped row. +Session mappings override defaults: `` switches panels where available. +The example above releases Tab to OpenCode and uses Ctrl+W then w to switch panels. +Native commands still depend on the current UI context. + ## Key notation Use printable ASCII characters; uppercase letters represent shifted keys. Use diff --git a/package-lock.json b/package-lock.json index fe878a3..2cbadee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "opencode-vim", - "version": "0.0.26", + "version": "0.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-vim", - "version": "0.0.26", + "version": "0.0.27", + "license": "MIT", "dependencies": { - "@vimee/core": "^0.3.0" + "@vimee/core": "^0.3.0", + "diff": "^9.0.0" }, "devDependencies": { "@opencode/theme": "2.0.10", diff --git a/package.json b/package.json index 5196336..cb04759 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "opencode-vim", - "version": "0.0.26", + "version": "0.0.27", + "license": "MIT", "type": "module", "repository": { "type": "git", @@ -14,12 +15,13 @@ "build": "bun scripts/build.ts", "prepack": "bun run build", "typecheck": "tsc --noEmit", - "test": "bun test --conditions=browser --preload @opentui/solid/preload", + "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" }, "dependencies": { - "@vimee/core": "^0.3.0" + "@vimee/core": "^0.3.0", + "diff": "^9.0.0" }, "peerDependencies": { "@opencode/plugin": "2.0.10", diff --git a/src/modules/snippets/index.tsx b/src/modules/snippets/index.tsx deleted file mode 100644 index 8dfbc4c..0000000 --- a/src/modules/snippets/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { PromptModule } from "../../prompt/types" -import type { SnippetController } from "./types" -import { SnippetAutocomplete } from "./view" - -export function createSnippetsModule(controller: SnippetController): PromptModule { - return { - id: "snippets", - order: -10, - onSubmit() { - return controller.accept?.() === true - }, - renderAbove(ctx) { - return - }, - } -} diff --git a/src/modules/snippets/loader.ts b/src/modules/snippets/loader.ts deleted file mode 100644 index b656dab..0000000 --- a/src/modules/snippets/loader.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { basename, join } from "node:path" -import { mkdir, writeFile } from "node:fs/promises" -import type { SnippetInfo, SnippetSource } from "./types" - -const SNIPPET_EXT = ".md" -const EMPTY_SNIPPET = `--- -description: "" ---- - -` - -export async function loadSnippets(projectDir?: string) { - const registry = new Map() - const home = process.env.HOME - - if (home) { - await loadDir(registry, join(home, ".config/opencode/snippets"), "global") - await loadDir(registry, join(home, ".config/opencode/snippet"), "global") - } - - if (projectDir) { - await loadDir(registry, join(projectDir, ".opencode/snippets"), "project") - await loadDir(registry, join(projectDir, ".opencode/snippet"), "project") - } - - return listSnippets(registry).sort((left, right) => sourceRank(left) - sourceRank(right) || left.name.localeCompare(right.name)) -} - -async function loadDir(registry: Map, dir: string, source: SnippetSource) { - let entries: string[] - try { - entries = await Array.fromAsync(new Bun.Glob(`*${SNIPPET_EXT}`).scan({ cwd: dir, onlyFiles: true })) - } catch { - return - } - - for (const entry of entries) { - const snippet = await loadFile(join(dir, entry), source) - if (snippet) registerSnippet(registry, snippet) - } -} - -async function loadFile(filePath: string, source: SnippetSource) { - let raw: string - try { - raw = await Bun.file(filePath).text() - } catch { - return undefined - } - - const parsed = parseFrontmatter(raw) - return { - name: basename(filePath, SNIPPET_EXT), - content: parsed.content.trim(), - aliases: normalizeAliases(parsed.data.aliases ?? parsed.data.alias), - description: typeof parsed.data.description === "string" ? parsed.data.description : undefined, - filePath, - source, - } satisfies SnippetInfo -} - -export async function ensureSnippetDraft(name: string, projectDir?: string) { - const dir = await ensureSnippetsDir(projectDir) - const filePath = join(dir, `${name}${SNIPPET_EXT}`) - if (!(await Bun.file(filePath).exists())) await writeFile(filePath, EMPTY_SNIPPET) - return filePath -} - -async function ensureSnippetsDir(projectDir?: string) { - const home = process.env.HOME - const dir = projectDir ? join(projectDir, ".opencode/snippet") : home ? join(home, ".config/opencode/snippet") : undefined - if (!dir) throw new Error("Unable to resolve snippets directory") - await mkdir(dir, { recursive: true }) - return dir -} - -export function parseFrontmatter(raw: string) { - if (!raw.startsWith("---\n")) return { data: {} as Record, content: raw } - - const end = raw.indexOf("\n---", 4) - if (end < 0) return { data: {} as Record, content: raw } - - return { - data: parseYamlish(raw.slice(4, end)), - content: raw.slice(end + 4).replace(/^\r?\n/, ""), - } -} - -function parseYamlish(input: string) { - const data: Record = {} - const lines = input.split(/\r?\n/) - let currentList: string | undefined - - for (const line of lines) { - const listItem = /^\s*-\s*(.+)$/.exec(line) - if (listItem && currentList) { - const value = String(listItem[1]).trim() - data[currentList] = [...asArray(data[currentList]), unquote(value)] - continue - } - - const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line) - if (!pair) continue - - currentList = undefined - const key = pair[1] - const value = pair[2].trim() - if (!value) { - data[key] = [] - currentList = key - } else { - data[key] = unquote(value) - } - } - - return data -} - -export function normalizeAliases(value: unknown) { - return asArray(value).filter((entry) => typeof entry === "string" && entry.length > 0) as string[] -} - -function asArray(value: unknown) { - return Array.isArray(value) ? value : typeof value === "string" ? [value] : [] -} - -export function unquote(value: string) { - return value.replace(/^['"]|['"]$/g, "") -} - -function registerSnippet(registry: Map, snippet: SnippetInfo) { - const old = registry.get(snippet.name.toLowerCase()) - if (old) { - for (const alias of old.aliases) registry.delete(alias.toLowerCase()) - } - - registry.set(snippet.name.toLowerCase(), snippet) - for (const alias of snippet.aliases) registry.set(alias.toLowerCase(), snippet) -} - -function listSnippets(registry: Map) { - return [...new Map([...registry.values()].map((snippet) => [snippet.name, snippet])).values()] -} - -function sourceRank(snippet: SnippetInfo) { - return snippet.source === "project" ? 0 : 1 -} diff --git a/src/modules/snippets/search.ts b/src/modules/snippets/search.ts deleted file mode 100644 index 6f1f7dd..0000000 --- a/src/modules/snippets/search.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { SkillInfo, SnippetInfo } from "./types" - -export type HighlightPart = { - text: string - match: boolean -} - -function normalizeSearchText(input: string) { - return input.toLowerCase().replace(/[^a-z0-9]/g, "") -} - -function scoreText(input: string, query: string) { - const raw = input.toLowerCase() - const compact = normalizeSearchText(input) - const needle = query.toLowerCase().trim() - const compactNeedle = normalizeSearchText(query) - - if (raw === needle) return 0 - if (compactNeedle && compact === compactNeedle) return 1 - if (raw.startsWith(needle)) return 2 - if (compactNeedle && compact.startsWith(compactNeedle)) return 3 - if (raw.includes(needle)) return 4 - if (compactNeedle && compact.includes(compactNeedle)) return 5 - return Number.POSITIVE_INFINITY -} - -function snippetDescription(snippet: SnippetInfo) { - return (snippet.description || snippet.content).replace(/\s+/g, " ").trim() -} - -function scoreSnippet(snippet: SnippetInfo, query: string) { - if (!query) return 0 - - const nameScore = Math.min(scoreText(snippet.name, query), ...snippet.aliases.map((alias) => scoreText(alias, query))) - if (Number.isFinite(nameScore)) return nameScore - - const description = (snippet.description || "").replace(/\s+/g, " ").trim().toLowerCase() - const lowerQuery = query.toLowerCase() - if (description.startsWith(lowerQuery)) return 6 - if (description.includes(lowerQuery)) return 7 - return Number.POSITIVE_INFINITY -} - -function sourceRank(snippet: SnippetInfo) { - return snippet.source === "project" ? 0 : 1 -} - -export function filterSnippets(snippets: SnippetInfo[], query: string) { - const needle = query.trim() - return snippets - .map((snippet) => ({ snippet, score: scoreSnippet(snippet, needle) })) - .filter((entry) => Number.isFinite(entry.score)) - .sort((left, right) => left.score - right.score || sourceRank(left.snippet) - sourceRank(right.snippet) || left.snippet.name.localeCompare(right.snippet.name)) - .map((entry) => entry.snippet) -} - -export function matchedAliases(snippet: SnippetInfo, query: string) { - const needle = query.trim() - if (!needle) return [] - return snippet.aliases.filter((alias) => Number.isFinite(scoreText(alias, needle))) -} - -export function describeSnippet(snippet: SnippetInfo) { - return snippetDescription(snippet) -} - -export function highlightMatches(input: string, query: string): HighlightPart[] { - const needle = query.trim().toLowerCase() - if (!needle) return [{ text: input, match: false }] - - const lower = input.toLowerCase() - const parts: HighlightPart[] = [] - let cursor = 0 - - while (cursor < input.length) { - const index = lower.indexOf(needle, cursor) - if (index < 0) break - if (index > cursor) parts.push({ text: input.slice(cursor, index), match: false }) - parts.push({ text: input.slice(index, index + needle.length), match: true }) - cursor = index + needle.length - } - - if (parts.length === 0) return [{ text: input, match: false }] - if (cursor < input.length) parts.push({ text: input.slice(cursor), match: false }) - return parts -} - -function skillTag(skill: SkillInfo) { - return `skill(${skill.name})` -} - -function scoreSkill(skill: SkillInfo, query: string) { - if (!query) return 0 - const nameScore = Math.min(scoreText(skill.name, query), scoreText(skillTag(skill), query)) - if (Number.isFinite(nameScore)) return nameScore - const description = (skill.description || "").replace(/\s+/g, " ").trim().toLowerCase() - const lowerQuery = query.toLowerCase() - if (description.startsWith(lowerQuery)) return 6 - if (description.includes(lowerQuery)) return 7 - return Number.POSITIVE_INFINITY -} - -function skillSourceRank(skill: SkillInfo) { - return skill.source === "project" ? 0 : 1 -} - -export function filterSkills(skills: SkillInfo[], query: string) { - const needle = query.trim() - return skills - .map((skill) => ({ skill, score: scoreSkill(skill, needle) })) - .filter((entry) => Number.isFinite(entry.score)) - .sort((left, right) => left.score - right.score || skillSourceRank(left.skill) - skillSourceRank(right.skill) || left.skill.name.localeCompare(right.skill.name)) - .map((entry) => entry.skill) -} - -export function describeSkill(skill: SkillInfo) { - return (skill.description || skill.content).replace(/\s+/g, " ").trim() -} diff --git a/src/modules/snippets/skill-loader.ts b/src/modules/snippets/skill-loader.ts deleted file mode 100644 index e3aed31..0000000 --- a/src/modules/snippets/skill-loader.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { dirname, join, parse } from "node:path" -import type { SkillInfo, SnippetSource } from "./types" -import { parseFrontmatter } from "./loader" - -const SKILL_FILE = "SKILL.md" - -export async function loadSkills(projectDir?: string) { - const registry = new Map() - const home = process.env.HOME - - if (home) { - await loadSkillDir(registry, join(home, ".config/opencode/skill"), "global") - await loadSkillDir(registry, join(home, ".config/opencode/skills"), "global") - await loadSkillDir(registry, join(home, ".claude/skills"), "global") - await loadSkillDir(registry, join(home, ".agents/skills"), "global") - } - - for (const root of await projectRoots(projectDir)) { - await loadSkillDir(registry, join(root, ".opencode/skill"), "project") - await loadSkillDir(registry, join(root, ".opencode/skills"), "project") - await loadSkillDir(registry, join(root, ".claude/skills"), "project") - await loadSkillDir(registry, join(root, ".agents/skills"), "project") - } - - return [...registry.values()].sort((left, right) => sourceRank(left) - sourceRank(right) || left.name.localeCompare(right.name)) -} - -async function projectRoots(projectDir?: string) { - if (!projectDir) return [] - const roots: string[] = [] - let current = projectDir - - while (true) { - roots.push(current) - if (await Bun.file(join(current, ".git")).exists()) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - - return roots.reverse() -} - -async function loadSkillDir(registry: Map, dir: string, source: SnippetSource) { - let entries: string[] - try { - entries = await Array.fromAsync(new Bun.Glob(`*/${SKILL_FILE}`).scan({ cwd: dir, onlyFiles: true })) - } catch { - return - } - - for (const entry of entries) { - const skill = await loadSkill(join(dir, entry), source) - if (skill) registry.set(skill.name.toLowerCase(), skill) - } -} - -async function loadSkill(filePath: string, source: SnippetSource) { - let raw: string - try { - raw = await Bun.file(filePath).text() - } catch { - return undefined - } - - const parsed = parseFrontmatter(raw) - const folderName = parse(dirname(filePath)).base - const name = typeof parsed.data.name === "string" && parsed.data.name.trim() ? parsed.data.name.trim() : folderName - - return { - name, - content: parsed.content.trim(), - description: typeof parsed.data.description === "string" ? parsed.data.description : undefined, - filePath, - source, - } satisfies SkillInfo -} - -function sourceRank(skill: SkillInfo) { - return skill.source === "project" ? 0 : 1 -} diff --git a/src/modules/snippets/state.ts b/src/modules/snippets/state.ts deleted file mode 100644 index 7e6ea73..0000000 --- a/src/modules/snippets/state.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { dirname, join } from "node:path" -import { mkdir, writeFile } from "node:fs/promises" - -const SCOPE_GLOBAL = "__global__" - -export async function addPendingDraft(workspaceDir: string | undefined, name: string) { - const filePath = statePath("pending-drafts.json") - const state = await readState(filePath) - const scope = workspaceDir || SCOPE_GLOBAL - const current = Array.isArray(state[scope]) ? state[scope].filter((item): item is string => typeof item === "string") : [] - const next = new Set([...current, name.toLowerCase()]) - state[scope] = [...next].sort() - await writeState(filePath, state) -} - -export async function markSnippetReloadRequested(workspaceDir?: string) { - const filePath = statePath("snippet-reload.json") - const state = await readState(filePath) - state[workspaceDir || SCOPE_GLOBAL] = Date.now() - await writeState(filePath, state) -} - -function statePath(name: string) { - const home = process.env.HOME - if (!home) throw new Error("HOME is not set") - return join(home, ".config/opencode/state", name) -} - -async function readState(filePath: string) { - try { - const parsed = JSON.parse(await Bun.file(filePath).text()) - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record - } catch {} - return {} as Record -} - -async function writeState(filePath: string, state: Record) { - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`) -} diff --git a/src/modules/snippets/trigger.ts b/src/modules/snippets/trigger.ts deleted file mode 100644 index 2488d67..0000000 --- a/src/modules/snippets/trigger.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { HashtagTriggerMatch, SnippetInfo } from "./types" - -export function findTrailingHashtagTrigger(input: string): HashtagTriggerMatch | undefined { - let start = -1 - for (let i = input.length - 1; i >= 0; i--) { - const char = input[i] - if (char === "#") { - start = i - break - } - if (/\s/.test(char)) return undefined - } - - if (start < 0) return undefined - if (start > 0 && !/\s/.test(input[start - 1])) return undefined - - const query = input.slice(start + 1) - const token = `#${query}` - - return { - start, - end: input.length, - query, - token, - } -} - -export function replaceTrailingHashtag(input: string, name: string) { - const match = findTrailingHashtagTrigger(input) - if (!match) return input - return `${input.slice(0, match.start)}#${name} ` -} - -export function insertSnippetTag(input: string, name: string) { - const match = findTrailingHashtagTrigger(input) - if (match) return replaceTrailingHashtag(input, name) - const separator = input.length === 0 || /\s$/.test(input) ? "" : " " - return `${input}${separator}#${name} ` -} - -export function insertSkillLoad(input: string, name: string) { - const tag = `skill(${name})` - const match = findTrailingHashtagTrigger(input) - if (match) return replaceTrailingHashtag(input, tag) - const separator = input.length === 0 || /\s$/.test(input) ? "" : " " - return `${input}${separator}#${tag} ` -} - -export function preferredSnippetTag(input: string, snippet: SnippetInfo) { - const query = findTrailingHashtagTrigger(input)?.query - if (query && snippet.aliases.some((alias) => alias === query)) return query - return snippet.name -} - -export function insertSnippetTrigger(input: string) { - if (findTrailingHashtagTrigger(input)) return input - const separator = input.length === 0 || /\s$/.test(input) ? "" : " " - return `${input}${separator}#` -} - -export function isReloadCommand(input: string) { - return input.trim() === "/snippets:reload" -} - -export function stepSelection(current: number, total: number, delta: number) { - if (total <= 0) return 0 - return Math.max(0, Math.min(total - 1, current + delta)) -} diff --git a/src/modules/snippets/types.ts b/src/modules/snippets/types.ts deleted file mode 100644 index 09fc4f6..0000000 --- a/src/modules/snippets/types.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type SnippetSource = "global" | "project" - -export type SnippetInfo = { - name: string - content: string - aliases: string[] - description?: string - filePath: string - source: SnippetSource -} - -export type SkillInfo = { - name: string - content: string - description?: string - filePath: string - source: SnippetSource -} - -export type AutocompleteItem = - | { - kind: "snippet" - id: string - label: string - description: string - aliases: string[] - snippet: SnippetInfo - } - | { - kind: "skill" - id: string - label: string - description: string - aliases: string[] - skill: SkillInfo - } - -export type HashtagTriggerMatch = { - start: number - end: number - query: string - token: string -} - -export type SnippetController = { - accept?: () => boolean - reload?: () => void - insertTrigger?: () => void - navigate?: (delta: number) => boolean -} diff --git a/src/modules/snippets/view.tsx b/src/modules/snippets/view.tsx deleted file mode 100644 index 0b5c39c..0000000 --- a/src/modules/snippets/view.tsx +++ /dev/null @@ -1,674 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { spawn } from "node:child_process" -import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" -import { RGBA, type ScrollBoxRenderable } from "@opentui/core" -import type { TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui" -import type { PromptContext } from "../../prompt/types" -import { ensureSnippetDraft, loadSnippets } from "./loader" -import { loadSkills } from "./skill-loader" -import { addPendingDraft, markSnippetReloadRequested } from "./state" -import { describeSkill, describeSnippet, filterSkills, filterSnippets, highlightMatches, matchedAliases } from "./search" -import { findTrailingHashtagTrigger, insertSkillLoad, insertSnippetTag, insertSnippetTrigger, isReloadCommand, preferredSnippetTag, stepSelection } from "./trigger" -import type { AutocompleteItem, SnippetController, SnippetInfo } from "./types" - -const PROMPT_SYNC_MS = 50 -const MENU_MAX_HEIGHT = 10 -const MOUSE_HOVER_SUPPRESS_MS = 150 -const INLINE_BORDER = { - border: ["left", "right"] as Array<"left" | "right">, - customBorderChars: { - topLeft: "", - bottomLeft: "", - vertical: "┃", - topRight: "", - bottomRight: "", - horizontal: " ", - bottomT: "", - topT: "", - cross: "", - leftT: "", - rightT: "", - }, -} - -type SnippetAutocompleteProps = { - ctx: PromptContext - controller: SnippetController -} - -type InputMode = "keyboard" | "mouse" - -export function SnippetAutocomplete(props: SnippetAutocompleteProps) { - const [snippets, setSnippets] = createSignal([]) - const [skills, setSkills] = createSignal>>([]) - const [loading, setLoading] = createSignal(true) - const [input, setInput] = createSignal("") - const [syncingPrompt, setSyncingPrompt] = createSignal(false) - const [menuEpoch, setMenuEpoch] = createSignal(0) - const [selected, setSelected] = createSignal(0) - const [dismissed, setDismissed] = createSignal() - const [inputMode, setInputMode] = createSignal("keyboard") - const [ignoreMouseUntil, setIgnoreMouseUntil] = createSignal(0) - const [lastMousePos, setLastMousePos] = createSignal<{ x: number; y: number }>() - const [creating, setCreating] = createSignal(false) - const [dialogOpen, setDialogOpen] = createSignal(false) - const [dialogHandoffUntil, setDialogHandoffUntil] = createSignal(0) - - let syncInterval: ReturnType | undefined - let pendingPromptSync: ReturnType | undefined - let pendingPromptFocus: ReturnType | undefined - let pendingDialogHandoff: ReturnType | undefined - let commandTimer: ReturnType | undefined - let commandDispose: (() => void) | undefined - let disposed = false - let scroll: ScrollBoxRenderable | undefined - - const refresh = async () => { - setLoading(true) - try { - const [nextSnippets, nextSkills] = await Promise.all([loadSnippets(props.ctx.api.state.path.directory), loadSkills(props.ctx.api.state.path.directory)]) - if (!disposed) { - setSnippets(nextSnippets) - setSkills(nextSkills) - } - } finally { - if (!disposed) setLoading(false) - } - } - - void refresh() - - syncInterval = setInterval(() => { - const ref = props.ctx.prompt() - if (!ref) { - if (input()) setInput("") - setSyncingPrompt(false) - return - } - const next = ref.current.input ?? "" - if (next !== input()) setInput(next) - }, PROMPT_SYNC_MS) - - onCleanup(() => { - disposed = true - if (syncInterval) clearInterval(syncInterval) - if (pendingPromptSync) clearTimeout(pendingPromptSync) - if (pendingPromptFocus) clearTimeout(pendingPromptFocus) - if (pendingDialogHandoff) clearTimeout(pendingDialogHandoff) - if (commandTimer) clearTimeout(commandTimer) - commandDispose?.() - props.controller.accept = undefined - props.controller.reload = undefined - props.controller.insertTrigger = undefined - props.controller.navigate = undefined - }) - - const dialogBlockingInput = () => props.ctx.api.ui.dialog.open || dialogOpen() || dialogHandoffUntil() > Date.now() - const match = createMemo(() => { - if (props.ctx.disabled || props.ctx.visible === false) return undefined - return findTrailingHashtagTrigger(input()) - }) - const query = createMemo(() => match()?.query.trim() ?? "") - const options = createMemo(() => (match() ? optionsForQuery(query()) : [])) - const draftName = createMemo(() => normalizeSnippetName(query())) - const canCreate = createMemo(() => !loading() && options().length === 0 && query().length > 0 && draftName().length > 0) - const visible = createMemo(() => !!match() && !syncingPrompt() && dismissed() !== match()?.token) - const menuVisible = createMemo(() => visible() && (options().length > 0 || canCreate())) - const menuHeight = createMemo(() => Math.min(MENU_MAX_HEIGHT, Math.max(1, options().length || 1))) - const selectedFg = createMemo(() => selectedText(props.ctx.api.theme.current)) - const activeRowId = createMemo(() => options()[selected()]?.id ?? (canCreate() ? "create-snippet" : undefined)) - const optionKey = createMemo(() => options().map((option) => option.id).join("\n")) - - createEffect(() => { - menuEpoch() - if (visible()) scroll = undefined - }) - - createEffect(() => { - match()?.token - optionKey() - setSelected(0) - lockKeyboardSelection() - setTimeout(() => { - scroll?.scrollTo(0) - const first = activeRowId() - if (first) scroll?.scrollChildIntoView(first) - }, 0) - }) - - createEffect(() => { - const row = activeRowId() - if (row) scroll?.scrollChildIntoView(row) - }) - - const chooseItem = (item: AutocompleteItem) => { - const ref = props.ctx.prompt() - if (!ref) return false - const next = item.kind === "skill" ? insertSkillLoad(ref.current.input, item.skill.name) : insertSnippetTag(ref.current.input, preferredSnippetTag(ref.current.input, item.snippet)) - syncPromptInput(ref, next) - setDismissed(undefined) - ref.focus() - props.ctx.requestRender() - return true - } - - const choose = (index = selected()) => { - const item = options()[index] - return item ? chooseItem(item) : false - } - - const navigate = (delta: number) => { - const ref = props.ctx.prompt() - if (!ref || !ref.focused || dialogBlockingInput()) return false - - const current = findTrailingHashtagTrigger(ref.current.input) - if (!current || dismissed() === current.token) return false - - const value = current.query.trim() - const total = optionsForQuery(value).length - if (total === 0 && !canCreateForQuery(current.query)) return false - - if (pendingPromptSync) clearTimeout(pendingPromptSync) - pendingPromptSync = undefined - setInput(ref.current.input) - setSyncingPrompt(false) - lockKeyboardSelection() - setSelected((current) => stepSelection(current, total || 1, delta)) - props.ctx.requestRender() - return true - } - - const accept = () => { - const ref = props.ctx.prompt() - if (!ref) return false - - if (isReloadCommand(ref.current.input)) { - void executeReloadInPrompt(ref) - return true - } - - if (dialogBlockingInput()) return true - - const current = findTrailingHashtagTrigger(ref.current.input) - if (!current || dismissed() === current.token) return false - - const live = optionsForQuery(current.query.trim()) - const liveIndex = Math.min(selected(), Math.max(live.length - 1, 0)) - - if (syncingPrompt()) { - if (live.length > 0) { - chooseItem(live[liveIndex] ?? live[0]) - return true - } - if (canCreateForQuery(current.query)) { - void createSnippetDraft(current.query) - return true - } - return false - } - - if (visible() && options().length > 0) { - choose(Math.min(selected(), options().length - 1)) - return true - } - - if (loading()) return true - - if (live.length > 0) { - chooseItem(live[liveIndex] ?? live[0]) - return true - } - - if (canCreateForQuery(current.query)) { - void createSnippetDraft(current.query) - return true - } - - return false - } - - const canAcceptSubmit = (ref: TuiPromptRef) => { - if (isReloadCommand(ref.current.input)) return true - if (dialogBlockingInput()) return true - - const current = findTrailingHashtagTrigger(ref.current.input) - return !!current && dismissed() !== current.token - } - - props.controller.accept = accept - props.controller.reload = () => { - const ref = props.ctx.prompt() - if (ref) void executeReloadInPrompt(ref) - } - props.controller.insertTrigger = () => { - const ref = props.ctx.prompt() - if (!ref) return - syncPromptInput(ref, insertSnippetTrigger(ref.current.input)) - ref.focus() - } - props.controller.navigate = navigate - - createEffect(() => { - const ref = props.ctx.prompt() - if (!ref) return - - if (commandTimer) clearTimeout(commandTimer) - commandDispose?.() - commandDispose = undefined - - commandTimer = setTimeout(() => { - commandTimer = undefined - if (disposed) return - - commandDispose = props.ctx.api.keymap.registerLayer({ - commands: [ - { - namespace: "palette", - name: "snippets.reload", - title: "Reload snippets", - desc: "Reload snippet files from disk", - category: "Prompt", - slashName: "snippets:reload", - run() { - void executeReloadInPrompt(ref) - }, - }, - { - namespace: "palette", - name: "snippets.insert", - title: "Insert snippet", - desc: "Insert a snippet trigger into the prompt", - category: "Prompt", - run() { - syncPromptInput(ref, insertSnippetTrigger(ref.current.input)) - ref.focus() - }, - }, - { - namespace: "palette", - name: "snippets.accept", - title: "Accept snippet autocomplete", - category: "Prompt", - hidden: true, - enabled: () => ref.focused && canAcceptSubmit(ref), - run() { - if (accept()) return - ref.submit() - }, - }, - ], - bindings: acceptSnippetBindings(props.ctx.api), - }) - }, 0) - }) - - const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => { - const ref = props.ctx.prompt() - const name = event.name?.toLowerCase() - - if (ref && isReloadCommand(ref.current.input) && (name === "return" || name === "enter")) { - void executeReloadInPrompt(ref) - event.preventDefault() - event.stopPropagation() - return - } - - if (dialogBlockingInput()) return - if (!visible()) return - - if ((name === "up" || name === "down") && navigate(name === "up" ? -1 : 1)) { - event.preventDefault() - event.stopPropagation() - return - } - - if (name === "escape") { - setDismissed(match()?.token) - event.preventDefault() - event.stopPropagation() - return - } - - if (name === "tab" && (options().length > 0 || canCreate())) { - if (options().length > 0) choose() - else void createSnippetDraft() - event.preventDefault() - event.stopPropagation() - return - } - - schedulePromptSync() - }) - - onCleanup(() => offKeyboard()) - - return ( - - - (scroll = ref)} backgroundColor={props.ctx.api.theme.current.backgroundMenu} height={menuHeight()} scrollbarOptions={{ visible: false }}> - void createSnippetDraft()} />}> - {(item, index) => ( - { - if (!allowMouseHover()) return - if (!recordMouseMove(x, y)) return - setInputMode("mouse") - }} - onMouseOver={() => { - if (inputMode() === "mouse") setSelected(index()) - }} - onMouseDown={() => { - setInputMode("mouse") - setLastMousePos(undefined) - setSelected(index()) - }} - onMouseUp={() => choose(index())} - /> - )} - - - - - ) - - function optionsForQuery(value: string): AutocompleteItem[] { - const snippetOptions: AutocompleteItem[] = filterSnippets(snippets(), value).map((snippet) => ({ - kind: "snippet", - id: `snippet:${snippet.name}`, - label: `#${snippet.name}`, - description: describeSnippet(snippet), - aliases: matchedAliases(snippet, value), - snippet, - })) - const skillOptions: AutocompleteItem[] = filterSkills(skills(), value).map((skill) => ({ - kind: "skill", - id: `skill:${skill.name}`, - label: `#skill(${skill.name})`, - description: describeSkill(skill), - aliases: [], - skill, - })) - return [...snippetOptions, ...skillOptions] - } - - function canCreateForQuery(value: string) { - if (loading()) return false - const name = normalizeSnippetName(value) - return name.length > 0 && optionsForQuery(value).length === 0 - } - - function fallbackLabel() { - if (loading()) return "Loading snippets and skills..." - if (snippets().length === 0 && skills().length === 0) return "No snippets or skills found" - return "No matching snippets or skills" - } - - function lockKeyboardSelection() { - setInputMode("keyboard") - setIgnoreMouseUntil(Date.now() + MOUSE_HOVER_SUPPRESS_MS) - } - - function allowMouseHover() { - return Date.now() >= ignoreMouseUntil() - } - - function recordMouseMove(x: number, y: number) { - const last = lastMousePos() - if (last?.x === x && last.y === y) return false - setLastMousePos({ x, y }) - return true - } - - function beginDialogHandoff() { - const until = Date.now() + MOUSE_HOVER_SUPPRESS_MS - setDialogHandoffUntil(until) - if (pendingDialogHandoff) clearTimeout(pendingDialogHandoff) - pendingDialogHandoff = setTimeout(() => { - if (dialogHandoffUntil() === until) setDialogHandoffUntil(0) - props.ctx.requestRender() - }, MOUSE_HOVER_SUPPRESS_MS + 25) - } - - function restorePromptFocus(ref: TuiPromptRef) { - if (pendingPromptFocus) clearTimeout(pendingPromptFocus) - pendingPromptFocus = setTimeout(() => ref.focus(), 175) - } - - function schedulePromptSync() { - const ref = props.ctx.prompt() - if (!ref || dialogBlockingInput()) return - const previous = input() - setSyncingPrompt(true) - setMenuEpoch((current) => current + 1) - if (pendingPromptSync) clearTimeout(pendingPromptSync) - pendingPromptSync = setTimeout(() => { - const next = ref.current.input ?? "" - if (next !== previous) setInput(next) - setSyncingPrompt(false) - props.ctx.requestRender() - }, 0) - } - - async function executeReloadInPrompt(ref: TuiPromptRef) { - const count = await reloadSnippetsInTui(props.ctx.api, refresh) - syncPromptInput(ref, "") - setDismissed(undefined) - ref.focus() - props.ctx.requestRender() - setTimeout(() => { - props.ctx.api.ui.toast({ - title: "Snippets reloaded", - message: `Reloaded ${count} snippet${count === 1 ? "" : "s"}.`, - duration: 3000, - }) - props.ctx.requestRender() - }, 0) - } - - async function createSnippetDraft(rawQuery?: string) { - const ref = props.ctx.prompt() - const name = normalizeSnippetName(rawQuery ?? query()) - if (!ref || !name || creating()) return - const current = findTrailingHashtagTrigger(ref.current.input) - const nextInput = current ? `${ref.current.input.slice(0, current.start)}#${name}` : `#${name}` - const dismissedToken = `#${name}` - const editor = resolveExternalEditor() - - if (!editor) { - props.ctx.api.ui.toast({ variant: "warning", message: "Set VISUAL or EDITOR to create snippets from the TUI." }) - return - } - - props.ctx.api.ui.dialog.setSize("medium") - setDialogOpen(true) - props.ctx.api.ui.dialog.replace(() => ( - { - setDialogOpen(false) - beginDialogHandoff() - props.ctx.api.ui.dialog.clear() - restorePromptFocus(ref) - }} - onConfirm={() => { - setDialogOpen(false) - beginDialogHandoff() - props.ctx.api.ui.dialog.clear() - void (async () => { - setCreating(true) - try { - syncPromptInput(ref, nextInput) - const filePath = await ensureSnippetDraft(name, props.ctx.api.state.path.directory) - await addPendingDraft(props.ctx.api.state.path.directory, name) - setDismissed(dismissedToken) - setCreating(false) - await openExternalEditor(props.ctx.api, filePath, editor) - } catch (error) { - props.ctx.api.ui.toast({ variant: "error", message: `Failed to create snippet: ${error instanceof Error ? error.message : String(error)}` }) - syncPromptInput(ref, nextInput) - setDismissed(undefined) - } finally { - setCreating(false) - restorePromptFocus(ref) - } - })() - }} - /> - )) - } -} - -function OptionRow(props: { - ctx: PromptContext - item: AutocompleteItem - query: string - selected: boolean - selectedFg: RGBA - onMouseMove: (x: number, y: number) => void - onMouseOver: () => void - onMouseDown: () => void - onMouseUp: () => void -}) { - const fg = () => (props.selected ? props.selectedFg : props.ctx.api.theme.current.text) - const mutedFg = () => (props.selected ? props.selectedFg : props.ctx.api.theme.current.textMuted) - - return ( - props.onMouseMove(event.x, event.y)} - onMouseOver={props.onMouseOver} - onMouseDown={props.onMouseDown} - onMouseUp={props.onMouseUp} - > - - - - 0}> - - - - - - - - - - - ) -} - -function FallbackRow(props: { ctx: PromptContext; canCreate: boolean; creating: boolean; label: string; draftName: string; onCreate: () => void }) { - return ( - - {props.label} - - } - > - - {props.creating ? "Creating snippet..." : `Add new Snippet: #${props.draftName}`} - - - ) -} - -function Highlighted(props: { text: string; query: string; fg: RGBA }) { - return {(part) => (part.match ? {part.text} : part.text)} -} - -function syncPromptInput(prompt: TuiPromptRef, input: string) { - prompt.set({ input, mode: prompt.current.mode, parts: [...prompt.current.parts] }) -} - -function acceptSnippetBindings(api: TuiPluginApi) { - const command = "snippets.accept" - const title = "Accept snippet autocomplete" - - if (!api.tuiConfig.keybinds.has("input.submit")) { - return [{ key: "input_submit", cmd: command, desc: title }] - } - - return api.tuiConfig.keybinds.get("input.submit").map((binding) => ({ - ...binding, - cmd: command, - desc: binding.desc ?? title, - })) -} - -function selectedText(theme: PromptContext["api"]["theme"]["current"]) { - if (theme.background.a !== 0) return theme.background - const { r, g, b } = theme.primary - const luminance = 0.299 * r + 0.587 * g + 0.114 * b - return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255) -} - -function normalizeSnippetName(input: string) { - return input - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/-{2,}/g, "-") - .replace(/^-+|-+$/g, "") -} - -async function reloadSnippetsInTui(api: TuiPluginApi, refresh: () => Promise) { - await refresh() - await markSnippetReloadRequested(api.state.path.directory) - return loadSnippets(api.state.path.directory).then((items) => items.length) -} - -type Editor = { command: string; env: "VISUAL" | "EDITOR" } - -function resolveExternalEditor(): Editor | undefined { - const visual = Bun.env.VISUAL?.trim() - if (visual) return { command: visual, env: "VISUAL" } - const editor = Bun.env.EDITOR?.trim() - if (editor) return { command: editor, env: "EDITOR" } - return undefined -} - -function editorBinary(editor: Editor) { - return editor.command.split(/\s+/)[0] ?? editor.command -} - -function usesTerminalUi(editor: Editor) { - return !/^(code|cursor|windsurf|subl|zed|open|idea|webstorm|phpstorm|pycharm|rubymine|goland|clion|rider|datagrip)$/i.test(editorBinary(editor)) -} - -async function openExternalEditor(api: TuiPluginApi, filePath: string, editor: Editor) { - const args = editor.command.split(/\s+/).filter(Boolean) - const command = args.shift() - if (!command) return false - - if (usesTerminalUi(editor)) api.renderer.suspend() - try { - await new Promise((resolve, reject) => { - const child = spawn(command, [...args, filePath], { stdio: usesTerminalUi(editor) ? "inherit" : "ignore", detached: !usesTerminalUi(editor) }) - child.on("error", reject) - child.on("exit", () => resolve()) - if (!usesTerminalUi(editor)) { - child.unref() - resolve() - } - }) - return true - } finally { - if (usesTerminalUi(editor)) { - api.renderer.resume() - api.renderer.console.clear() - api.renderer.requestRender() - } - } -} diff --git a/src/modules/vim/actions.ts b/src/modules/vim/actions.ts index 589ad3a..bde1c8c 100644 --- a/src/modules/vim/actions.ts +++ b/src/modules/vim/actions.ts @@ -1,4 +1,4 @@ -import type { CursorStyleOptions, LineInfo, RGBA } from "@opentui/core" +import type { CursorStyleOptions, LineInfo, RGBA, WidthMethod } from "@opentui/core" import type { VimCursorStyle } from "./config" type PromptInfo = { @@ -16,7 +16,7 @@ type PromptRef = { export type PromptContext = { api: { - renderer: { currentFocusedRenderable?: unknown } + renderer: { currentFocusedRenderable?: unknown; widthMethod?: WidthMethod } keymap: { dispatchCommand: (command: string) => { ok: boolean } } theme: { current: { warning: RGBA; info: RGBA; background: RGBA } } } diff --git a/src/modules/vim/config.ts b/src/modules/vim/config.ts index c1ebc2c..4221cc2 100644 --- a/src/modules/vim/config.ts +++ b/src/modules/vim/config.ts @@ -8,6 +8,7 @@ export type VimCursorStyle = { export type VimConfig = { defaultMode: VimMode + diffView: DiffView sessionKey: string keymapTimeout: number pendingDisplayDelay: number @@ -19,6 +20,7 @@ export type VimConfig = { export type VimOptions = { defaultMode?: VimMode + diffView?: DiffView sessionKey?: string keymapTimeout?: number pendingDisplayDelay?: number @@ -28,7 +30,11 @@ export type VimOptions = { keymaps?: VimKeymaps } -export type VimKeymaps = Partial>> +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 = { @@ -42,6 +48,7 @@ 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), @@ -65,6 +72,7 @@ function readOptions(options: unknown): VimOptions { 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, @@ -95,6 +103,12 @@ function readKeymaps(input: unknown): VimKeymaps | undefined { 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 } diff --git a/src/modules/vim/edit.ts b/src/modules/vim/edit.ts index 42e9aac..df6c1e7 100644 --- a/src/modules/vim/edit.ts +++ b/src/modules/vim/edit.ts @@ -11,23 +11,47 @@ type Input = { } export function editInput(input: Input, value: string) { - if (input.plainText === value) return + const before = input.plainText + if (before === value) return input.clearSelection() - const before = [...graphemes.segment(input.plainText)] - const after = [...graphemes.segment(value)] - let prefix = 0 - while (prefix < before.length && prefix < after.length && before[prefix].segment === after[prefix].segment) prefix++ + // Find the changed span without allocating grapheme objects for both buffers. + let start = 0 + while (start < before.length && start < value.length && before[start] === value[start]) start++ + const beforeSegments = graphemes.segment(before) + const afterSegments = graphemes.segment(value) + start = Math.min(segmentAt(before, beforeSegments, start)?.index ?? start, segmentAt(value, afterSegments, start)?.index ?? start) - let suffix = 0 - while (suffix + prefix < before.length && suffix + prefix < after.length && before[before.length - suffix - 1].segment === after[after.length - suffix - 1].segment) suffix++ + let end = before.length + let valueEnd = value.length + while (end > start && valueEnd > start && before[end - 1] === value[valueEnd - 1]) { + end-- + valueEnd-- + } - const start = before[prefix]?.index ?? input.plainText.length - const end = suffix ? before[before.length - suffix].index : input.plainText.length - const valueEnd = suffix ? after[after.length - suffix].index : value.length - const startOffset = charToDisplay(input.plainText, start) + // Expand the ends together until neither cuts a grapheme in the shared suffix. + while (true) { + const left = segmentAt(before, beforeSegments, end) + const right = segmentAt(value, afterSegments, valueEnd) + const leftExtra = left && left.index < end ? left.index + left.segment.length - end : 0 + const rightExtra = right && right.index < valueEnd ? right.index + right.segment.length - valueEnd : 0 + const extra = Math.max(leftExtra, rightExtra) + if (!extra) break + end += extra + valueEnd += extra + } + + const startOffset = charToDisplay(before, start) if (start === end) input.cursorOffset = startOffset - else input.setSelection(startOffset, charToDisplay(input.plainText, end)) + else input.setSelection(startOffset, startOffset + charToDisplay(before.slice(start, end), end - start)) input.insertText(value.slice(start, valueEnd)) } + +function segmentAt(text: string, segments: Intl.Segments, index: number) { + // Bun's containing() can include the previous grapheme at a high surrogate. + // Query its low surrogate instead, which is inside the same grapheme. + const point = text.codePointAt(index) + if (point !== undefined && point > 0xffff) index++ + return segments.containing(index) +} diff --git a/src/modules/vim/graphemes.ts b/src/modules/vim/graphemes.ts index 0fcb373..79e2843 100644 --- a/src/modules/vim/graphemes.ts +++ b/src/modules/vim/graphemes.ts @@ -7,23 +7,27 @@ export function createGraphemeCodec() { const encoded = new Map() const decoded = new Map() let next = 0xe000 + + // The caller has already segmented this value; do not segment it again. + function encodeGrapheme(segment: string) { + const code = segment.charCodeAt(0) + if (segment.length === 1 && (code < 0xe000 || code > 0xf8ff)) return segment + let token = encoded.get(segment) + if (!token) { + if (next > 0xf8ff) throw new Error("Vim grapheme dictionary is full") + token = String.fromCharCode(next++) + encoded.set(segment, token) + decoded.set(token, segment) + } + return token + } + return { + encodeGrapheme, encode(text: string) { let result = "" for (const { segment } of segments.segment(text)) { - const code = segment.charCodeAt(0) - if (segment.length === 1 && (code < 0xe000 || code > 0xf8ff)) { - result += segment - continue - } - let token = encoded.get(segment) - if (!token) { - if (next > 0xf8ff) throw new Error("Vim grapheme dictionary is full") - token = String.fromCharCode(next++) - encoded.set(segment, token) - decoded.set(token, segment) - } - result += token + result += encodeGrapheme(segment) } return result }, diff --git a/src/modules/vim/index.tsx b/src/modules/vim/index.tsx deleted file mode 100644 index cbf8035..0000000 --- a/src/modules/vim/index.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { KeyEvent, ParsedKey } from "@opentui/core" -import { onCleanup } from "solid-js" -import type { Accessor } from "solid-js" -import type { PromptContext, PromptModule } from "../../prompt/types" -import type { SnippetController } from "../snippets/types" -import { applyVimCursorStyle, focusedInput } from "./actions" -import type { VimConfig } from "./config" -import { createVimConfig } from "./config" -import { displayToChar } from "./map" -import { keyNotation } from "./keys" -import { createVimLog } from "./log" -import type { VimLog } from "./log" -import { createVimState } from "./state" -import { createVimeeAdapter } from "./vimee" -import { VimStatus } from "./view" - -export function createVimModule(options?: unknown, enabled: Accessor = () => true, snippets?: SnippetController): PromptModule { - const config = createVimConfig(options) - const log = createVimLog(config) - const state = createVimState(config.defaultMode, log) - log("module.create", { defaultMode: config.defaultMode, keymapTimeout: config.keymapTimeout }) - - return { - id: "vim", - order: 0, - setup(ctx) { - log("module.setup", { kind: ctx.kind, sessionID: ctx.sessionID, workspaceID: ctx.workspaceID }) - }, - renderAbove(ctx) { - return - }, - renderRight(ctx) { - return readablePending(state.pending())} subscribe={state.subscribe} enabled={enabled} theme={ctx.api.theme.current} pendingDisplayDelay={config.pendingDisplayDelay} disabled={ctx.disabled} log={log} requestRender={ctx.requestRender} /> - }, - } -} - -function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType; snippets?: SnippetController; enabled: Accessor; log: VimLog }) { - let cursorStyleMode = "" - const vimee = createVimeeAdapter(props.state, props.config, props.log) - props.log("keyboard.mount", { kind: props.ctx.kind }) - - const cursorStyleTimer = setInterval(syncCursorStyle, 50) - const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => { - props.log("keyboard.event", { - name: event.name, - ctrl: event.ctrl, - meta: event.meta, - shift: event.shift, - defaultPrevented: event.defaultPrevented, - propagationStopped: event.propagationStopped, - mode: props.state.mode(), - pending: props.state.pending(), - }) - - if (!canHandleKeys(props)) { - props.log("keyboard.skip", { enabled: props.enabled(), disabled: props.ctx.disabled, visible: props.ctx.visible, dialogOpen: props.ctx.api.ui.dialog.open }) - return - } - - const key = keyNotation(event) - if (!key) { - props.log("keyboard.no_key") - return - } - - if (passThroughKey(event, key, props.state.mode())) return - - if (key === "" && props.state.mode() === "normal" && !props.state.pending()) { - props.log("keyboard.fallthrough", { key, mode: props.state.mode() }) - return - } - - if (sendNavigationKey(event, props.ctx, key, props.state.mode(), props.snippets)) { - syncCursorStyle(true) - props.ctx.requestRender() - return - } - - const modeBefore = props.state.mode() - let consumed = false - try { - consumed = vimee.handle(event, key, props.ctx) - } finally { - if (consumed || props.state.mode() !== modeBefore) { - event.preventDefault() - event.stopPropagation() - } - } - if (consumed) { - syncCursorStyle(true) - props.ctx.requestRender() - } - }) - - onCleanup(() => { - props.log("keyboard.cleanup", { kind: props.ctx.kind }) - offKeyboard() - vimee.cleanup() - clearInterval(cursorStyleTimer) - }) - - return - - function syncCursorStyle(force = false) { - const mode = props.state.mode() - if (!force && cursorStyleMode === mode) return - if (applyVimCursorStyle(props.ctx, props.config.cursorStyles[mode])) { - cursorStyleMode = mode - props.log("cursor.style", { mode, style: props.config.cursorStyles[mode].style, blinking: props.config.cursorStyles[mode].blinking }) - } - } -} - -function canHandleKeys(props: { ctx: PromptContext; enabled: Accessor }) { - return props.enabled() && !props.ctx.disabled && props.ctx.visible !== false && !props.ctx.api.ui.dialog.open -} - -function readablePending(sequence: string) { - if (!sequence) return undefined - return sequence.replaceAll("><", " ") -} - -function passThroughKey(event: KeyEvent, key: string, mode: string) { - if (mode !== "normal") return false - return event.super === true || isArrowKey(key) || key === "" -} - -function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string, snippets?: SnippetController) { - if (mode !== "normal") return false - const delta = snippetNavigationDelta(key) - if (delta !== 0 && snippets?.navigate?.(delta)) { - event.preventDefault() - event.stopPropagation() - return true - } - - const forwarded = commandNavigationKey(ctx, key) - if (!forwarded) return false - - event.preventDefault() - event.stopPropagation() - ctx.api.renderer.keyInput.processParsedKey(forwarded) - return true -} - -function snippetNavigationDelta(key: string) { - if (key === "j") return 1 - if (key === "k") return -1 - return 0 -} - -function commandNavigationKey(ctx: PromptContext, key: string): ParsedKey | undefined { - if (!isPromptFocused(ctx)) return navigationKey(key) - if (!isNativeCompletionToken(ctx)) return undefined - if (key === "j") return arrowKey("down", "\u001B[B") - if (key === "k") return arrowKey("up", "\u001B[A") - return undefined -} - -function isPromptFocused(ctx: PromptContext) { - return ctx.prompt()?.focused === true && !!focusedInput(ctx) -} - -function isNativeCompletionToken(ctx: PromptContext) { - const text = ctx.prompt()?.current.input ?? focusedInput(ctx)?.plainText ?? "" - const input = focusedInput(ctx) - const charIdx = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0)) - const beforeCursor = text.slice(0, Math.min(charIdx + 1, text.length)) - return /^\/\S*$/.test(beforeCursor) || /(?:^|\s)@\S*$/.test(beforeCursor) -} - -function navigationKey(key: string): ParsedKey | undefined { - if (key === "h") return arrowKey("left", "\u001B[D") - if (key === "j") return arrowKey("down", "\u001B[B") - if (key === "k") return arrowKey("up", "\u001B[A") - if (key === "l") return arrowKey("right", "\u001B[C") - return undefined -} - -function isArrowKey(key: string) { - return key === "" || key === "" || key === "" || key === "" -} - -function arrowKey(name: string, sequence: string): ParsedKey { - return { - name, - ctrl: false, - meta: false, - shift: false, - option: false, - sequence, - number: false, - raw: sequence, - eventType: "press", - source: "raw", - super: false, - } -} diff --git a/src/modules/vim/map.test.ts b/src/modules/vim/map.test.ts deleted file mode 100644 index ed7a1c9..0000000 --- a/src/modules/vim/map.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { charToDisplay, displayToChar, displayWidth } from "./map" - -describe("vim display offsets", () => { - test("maps ASCII and CJK offsets", () => { - const text = "a中b" - - expect(displayWidth(text)).toBe(4) - expect(charToDisplay(text, 2)).toBe(3) - expect(displayToChar(text, 2)).toBe(1) - expect(displayToChar(text, 3)).toBe(2) - }) - - test("does not split supplementary characters", () => { - const text = "𠀀x" - - expect(displayWidth(text)).toBe(3) - expect(charToDisplay(text, 1)).toBe(0) - expect(charToDisplay(text, 2)).toBe(2) - expect(displayToChar(text, 1)).toBe(0) - expect(displayToChar(text, 2)).toBe(2) - }) - - test("does not split grapheme clusters", () => { - const emoji = "👨‍👩‍👧‍👦" - const accent = "e\u0301" - - expect(displayWidth(emoji)).toBe(2) - expect(charToDisplay(emoji, 1)).toBe(0) - expect(displayToChar(emoji, 1)).toBe(0) - expect(displayToChar(emoji, 2)).toBe(emoji.length) - expect(displayWidth(accent)).toBe(1) - expect(charToDisplay(accent, 1)).toBe(0) - expect(displayToChar(accent, 1)).toBe(accent.length) - }) - - test("counts newlines as textarea offsets", () => { - const text = "中\nx" - - expect(displayWidth(text)).toBe(4) - expect(displayToChar(text, 2)).toBe(1) - expect(displayToChar(text, 3)).toBe(2) - expect(charToDisplay(text, 2)).toBe(3) - }) -}) diff --git a/src/modules/vim/map.ts b/src/modules/vim/map.ts index 9ea30a0..2b0e011 100644 --- a/src/modules/vim/map.ts +++ b/src/modules/vim/map.ts @@ -1,37 +1,44 @@ 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) { +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 - return value === "\n" ? 1 : Bun.stringWidth(value) + 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): number { +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) + width += graphemeWidth(part.segment, method) } return width } -export function displayToChar(text: string, displayOffset: number): number { +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) + const next = width + graphemeWidth(part.segment, method) if (next > displayOffset) return part.index width = next } return text.length } -export function displayWidth(text: string): number { - return charToDisplay(text, text.length) +export function displayWidth(text: string, method: WidthMethod = "unicode"): number { + return charToDisplay(text, text.length, method) } export type PromptMap = { @@ -39,50 +46,77 @@ export type PromptMap = { vimText: string hostToVim: number[] vimToHost: number[] + vimToDisplay: number[] + lineStarts: number[] + displayWidth: number } -export function createPromptMap(hostText: string, codec = createGraphemeCodec()): PromptMap { +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 - vimText += codec.encode(segment) + 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 - return { hostText, vimText, hostToVim, vimToHost } + vimToDisplay[vimOffset] = width + return { hostText, vimText, hostToVim, vimToHost, vimToDisplay, lineStarts, displayWidth: width } } export function hostPosition(map: PromptMap, hostDisplayOffset: number): CursorPosition { - const charIdx = displayToChar(map.hostText, hostDisplayOffset) - return positionFromOffset(map.vimText, map.hostToVim[clamp(charIdx, 0, map.hostText.length)]) + const offset = indexAtOffset(map.vimToDisplay, hostDisplayOffset) + const line = indexAtOffset(map.lineStarts, offset) + return { line, col: offset - map.lineStarts[line] } +} + +export function hostCharOffset(map: PromptMap, hostDisplayOffset: number) { + return map.vimToHost[indexAtOffset(map.vimToDisplay, hostDisplayOffset)] } export function hostOffset(map: PromptMap, position: CursorPosition) { - return hostFromVimOffset(map, offsetFromPosition(map.vimText, position)) + return hostFromVimOffset(map, vimOffsetFromPosition(map, position)) } export function hostFromVimOffset(map: PromptMap, offset: number) { - return charToDisplay(map.hostText, map.vimToHost[clamp(offset, 0, map.vimText.length)]) + return map.vimToDisplay[clamp(offset, 0, map.vimText.length)] } -function positionFromOffset(text: string, offset: number): CursorPosition { - const lines = text.slice(0, offset).split("\n") - return { line: lines.length - 1, col: lines[lines.length - 1]?.length ?? 0 } +export function vimLineLength(map: PromptMap, line: number) { + const start = map.lineStarts[line] + if (start === undefined) return 0 + const end = line + 1 < map.lineStarts.length ? map.lineStarts[line + 1] - 1 : map.vimText.length + return end - start } -function offsetFromPosition(text: string, position: CursorPosition) { - const lines = text.split("\n") - const line = clamp(position.line, 0, Math.max(0, lines.length - 1)) - let offset = 0 - for (let index = 0; index < line; index++) offset += lines[index].length + 1 - return offset + clamp(position.col, 0, lines[line]?.length ?? 0) +export function vimOffsetFromPosition(map: PromptMap, position: CursorPosition) { + const line = clamp(position.line, 0, map.lineStarts.length - 1) + return map.lineStarts[line] + clamp(position.col, 0, vimLineLength(map, line)) +} + +// Find the last boundary at or before an offset, including zero-width graphemes. +function indexAtOffset(offsets: number[], offset: number) { + let low = 0 + let high = offsets.length + while (low < high) { + const middle = Math.floor((low + high) / 2) + if (offsets[middle] <= offset) low = middle + 1 + else high = middle + } + return Math.max(0, low - 1) } function clamp(value: number, min: number, max: number) { diff --git a/src/modules/vim/view.tsx b/src/modules/vim/view.tsx deleted file mode 100644 index 3cc85d9..0000000 --- a/src/modules/vim/view.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { TextRenderable } from "@opentui/core" -import { onCleanup } from "solid-js" -import type { Accessor } from "solid-js" -import type { PromptContext } from "../../prompt/types" -import type { VimLog } from "./log" -import type { VimMode, VimStateListener } from "./state" - -const STATUS_SYNC_MS = 50 - -type VimStatusProps = { - mode: Accessor - pending: Accessor - subscribe: (listener: VimStateListener) => () => void - enabled: Accessor - theme: PromptContext["api"]["theme"]["current"] - pendingDisplayDelay?: number - disabled?: boolean - log?: VimLog - requestRender?: () => void -} - -export function VimStatus(props: VimStatusProps) { - let modeText: TextRenderable | undefined - let pendingText: TextRenderable | undefined - let displayedMode = props.mode() - let displayedPending: string | undefined - let displayedEnabled = props.enabled() - let pendingTimer: ReturnType | undefined - let scheduledPending: string | undefined - - const sync = () => { - const nextMode = props.mode() - const nextPending = props.pending() - let changed = false - - if (displayedMode !== nextMode) { - props.log?.("status.sync", { fromMode: displayedMode, toMode: nextMode, fromPending: displayedPending, toPending: displayedPending }) - displayedMode = nextMode - changed = true - } - - const nextEnabled = props.enabled() - if (displayedEnabled !== nextEnabled) { - props.log?.("status.enabled", { from: displayedEnabled, to: nextEnabled }) - displayedEnabled = nextEnabled - changed = true - } - - if (changed) updateModeText() - syncPending(nextPending) - if (changed) props.requestRender?.() - } - - const unsubscribe = props.subscribe(sync) - const timer = setInterval(sync, STATUS_SYNC_MS) - onCleanup(() => { - unsubscribe() - clearInterval(timer) - if (pendingTimer) clearTimeout(pendingTimer) - modeText = undefined - pendingText = undefined - }) - - return ( - - { pendingText = ref; updatePendingText() }} fg={props.theme.info}>{displayedPending ? `${displayedPending} ` : ""} - { modeText = ref; updateModeText() }} fg={modeColor(displayedMode, displayedEnabled, props.theme, props.disabled)}>{displayedEnabled ? modeLabel(displayedMode) : ""} - - ) - - function updateModeText() { - if (!modeText) return - modeText.content = displayedEnabled ? modeLabel(displayedMode) : "" - modeText.fg = modeColor(displayedMode, displayedEnabled, props.theme, props.disabled) - } - - function updatePendingText() { - if (!pendingText) return - pendingText.content = displayedEnabled && displayedPending ? `${displayedPending} ` : "" - } - - function syncPending(nextPending: string | undefined) { - if (!nextPending) { - if (pendingTimer) clearTimeout(pendingTimer) - pendingTimer = undefined - scheduledPending = undefined - setDisplayedPending(undefined) - return - } - - if (displayedPending === nextPending) return - if (scheduledPending === nextPending) return - - if (pendingTimer) clearTimeout(pendingTimer) - scheduledPending = nextPending - const delay = props.pendingDisplayDelay ?? 120 - if (delay <= 0) { - scheduledPending = undefined - setDisplayedPending(nextPending) - return - } - - pendingTimer = setTimeout(() => { - pendingTimer = undefined - scheduledPending = undefined - if (props.pending() === nextPending) setDisplayedPending(nextPending) - }, delay) - } - - function setDisplayedPending(nextPending: string | undefined) { - if (displayedPending === nextPending) return - props.log?.("status.sync", { fromMode: displayedMode, toMode: displayedMode, fromPending: displayedPending, toPending: nextPending }) - displayedPending = nextPending - updatePendingText() - props.requestRender?.() - } -} - -function modeColor(mode: VimMode, enabled: boolean, theme: PromptContext["api"]["theme"]["current"], disabled?: boolean) { - if (!enabled) return undefined - if (disabled) return theme.textMuted - return mode === "insert" ? theme.success : theme.warning -} - -function modeLabel(mode: VimMode) { - if (mode === "visual") return "VISUAL" - if (mode === "visual-line") return "VISUAL LINE" - return mode === "normal" ? "NORMAL" : "INSERT" -} diff --git a/src/modules/vim/vimee.ts b/src/modules/vim/vimee.ts index 68195fa..aa43d11 100644 --- a/src/modules/vim/vimee.ts +++ b/src/modules/vim/vimee.ts @@ -1,11 +1,11 @@ -import type { KeyEvent } from "@opentui/core" +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 { charToDisplay, displayToChar, displayWidth, createPromptMap, hostFromVimOffset, hostOffset, hostPosition, type PromptMap } from "./map" +import { displayToChar, displayWidth, createPromptMap, hostCharOffset, hostFromVimOffset, hostOffset, hostPosition, vimLineLength, vimOffsetFromPosition, type PromptMap } from "./map" import type { createVimState } from "./state" type VimState = ReturnType @@ -39,6 +39,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL 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 @@ -80,9 +81,6 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } const text = input?.plainText ?? ref.current.input - const dw = displayWidth(text) - const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw) - const charOff = displayToChar(text, displayOff) const canBrowseHistory = !options.readOnly && vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending() const historyCommand = canBrowseHistory ? defaultHistoryCommand(vimeeKey, text, historyText, defaultHistoryKeys) : undefined @@ -97,11 +95,12 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL historyText = undefined const map = mapForHostText(text) + const displayOff = clamp(input?.cursorOffset ?? map.displayWidth, 0, map.displayWidth) const cursor = hostPosition(map, displayOff) const wasPending = keybinds?.isPending() ?? false const pendingBefore = pendingInsert - sync(map, cursor) + sync(cursor) const shouldFlashYank = shouldFlashYankFor(vimeeKey) const visualYankRange = visualYankRangeFor(map) @@ -157,7 +156,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL syncMode(state, vim.mode) const keybindPending = keybinds?.isPending() ?? false - if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff) + if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, hostCharOffset(map, displayOff)) pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : "" state.setPending(pendingDisplay(vim, keybindPending)) updateTimeout(ctx) @@ -185,7 +184,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL if (activeInput === input) return suspend() activeInput = input - activeMap = createPromptMap(input?.plainText ?? ctx.prompt()?.current.input ?? "", codec) + 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() } @@ -207,16 +207,16 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL pendingContext = undefined } - function sync(map: PromptMap, cursor: CursorPosition) { + function sync(cursor: CursorPosition) { if (cursor.line !== vim.cursor.line || cursor.col !== vim.cursor.col) preferredColumn = undefined - if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText) vim = { ...vim, cursor, mode: state.mode() } } function mapForHostText(text: string) { if (activeMap.hostText === text) return activeMap - if (state.mode() === "insert") recordNativeChange(text) - activeMap = createPromptMap(text, codec) + const nextMap = createPromptMap(text, codec, widthMethod) + if (state.mode() === "insert") recordNativeChange(nextMap) + activeMap = nextMap if (state.mode() === "insert") { if (!nativeInsertUndoSaved) { buffer.saveUndoPoint(vim.cursor) @@ -230,10 +230,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL return activeMap } - function recordNativeChange(text: string) { - const before = codec.encode(activeMap.hostText) - const after = codec.encode(text) - const start = codec.encode(activeMap.hostText.slice(0, displayToChar(activeMap.hostText, hostOffset(activeMap, vim.cursor)))).length + function recordNativeChange(nextMap: PromptMap) { + const before = activeMap.vimText + const after = nextMap.vimText + const start = vimOffsetFromPosition(activeMap, vim.cursor) let prefix = 0 while (prefix < start && prefix < after.length && before[prefix] === after[prefix]) prefix++ let suffix = 0 @@ -255,13 +255,11 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL for (const action of actions) { switch (action.type) { case "content-change": - currentMap = createPromptMap(codec.decode(action.content), codec) + currentMap = createPromptMap(codec.decode(action.content), codec, widthMethod) activeMap = currentMap setInput(ref, currentMap.hostText) - buffer.replaceContent(action.content) - break - case "cursor-move": - setCursor(input, currentMap, action.position) + // Decoding can join neighboring graphemes; use the map's units. + buffer.replaceContent(currentMap.vimText) break case "yank": if (!action.register) options.onYank?.(codec.decode(action.text)) @@ -286,6 +284,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } } + // The engine context already has the final cursor; move the editor once. setCursor(input, currentMap, vim.cursor) syncVisualSelection(input, currentMap, ctx) } @@ -356,13 +355,13 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL const map = mapForHostText(text) if (input && text.length > 0) { - const dw = displayWidth(text) + const dw = map.displayWidth const offset = clamp(input?.cursorOffset ?? dw, 0, dw) - const charOffset = displayToChar(text, offset) + const charOffset = hostCharOffset(map, offset) if (charOffset > 0 && text[charOffset - 1] !== "\n") { - input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1))) + input.cursorOffset = hostOffset(map, hostPosition(map, Math.max(0, offset - 1))) } - clampNormalCursor(input) + clampNormalCursor(input, map) } const lastChange = nativeInsertUndoSaved ? [...vim.pendingChange, "Escape"] : vim.lastChange @@ -535,7 +534,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL 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) + 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 @@ -570,10 +569,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL } else { target = start if (key === "^") { - const from = displayToChar(map.hostText, start) - const to = displayToChar(map.hostText, end + 1) + const from = hostCharOffset(map, start) + const to = hostCharOffset(map, end + 1) const first = map.hostText.slice(from, to).search(/\S/) - if (first >= 0) target = charToDisplay(map.hostText, from + first) + if (first >= 0) target = hostFromVimOffset(map, map.hostToVim[from + first]) } } return { start: vim.cursor, end: hostPosition(map, target), linewise: !screen, inclusive: !screen || key === "$" } @@ -641,7 +640,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL input.clearSelection?.() input.cursorOffset = offset input.insertText!(value) - input.cursorOffset = cursor >= offset ? cursor + displayWidth(value) : cursor + input.cursorOffset = cursor >= offset ? cursor + displayWidth(value, widthMethod) : cursor return } const ref = ctx.prompt() @@ -649,18 +648,19 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL const input = focusedInput(ctx) const text = input?.plainText ?? ref.current.input const currentDisplayOff = input?.cursorOffset ?? 0 - const currentCharOff = displayToChar(text, currentDisplayOff) + 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)) + if (input) input.cursorOffset = displayWidth(next.slice(0, nextCharOff), widthMethod) } function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) { if (!input) return - input.cursorOffset = hostOffset(map, position) - if (vim.mode !== "insert") clampNormalCursor(input) + const offset = hostOffset(map, position) + if (input.cursorOffset !== offset) input.cursorOffset = offset + if (vim.mode !== "insert") clampNormalCursor(input, map) } function syncVisualSelection(input: EditBufferLike | undefined, map: PromptMap, ctx: PromptContext) { @@ -760,7 +760,7 @@ function visualLineRange(map: PromptMap, anchor: CursorPosition, cursor: CursorP const startLine = Math.min(anchor.line, cursor.line) const endLine = Math.max(anchor.line, cursor.line) const start = hostOffset(map, { line: startLine, col: 0 }) - const end = hostOffset(map, { line: endLine, col: vimLineLength(map.vimText, endLine) }) + const end = hostOffset(map, { line: endLine, col: vimLineLength(map, endLine) }) return hostRange(map, start, end) } @@ -771,14 +771,14 @@ function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string): return visualLineRange(map, cursor, { line: cursor.line + Math.max(0, lineCount - 1), col: 0 }) } - const start = vimOffsetFromPosition(map.vimText, cursor) + const start = vimOffsetFromPosition(map, cursor) return vimOffsetRange(map, start, start + text.length - 1) } function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefined { if (range.linewise) return visualLineRange(map, range.start, range.end) - const start = vimOffsetFromPosition(map.vimText, range.start) - const end = vimOffsetFromPosition(map.vimText, range.end) + const start = vimOffsetFromPosition(map, range.start) + const end = vimOffsetFromPosition(map, range.end) return vimOffsetRange(map, start, end) } @@ -790,7 +790,7 @@ function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange function hostRange(map: PromptMap, left: number, right: number): HostRange | undefined { if (!map.hostText) return undefined - const dw = displayWidth(map.hostText) + const dw = map.displayWidth const start = clamp(Math.min(left, right), 0, Math.max(0, dw - 1)) const end = clamp(Math.max(left, right), 0, Math.max(0, dw - 1)) return { start, end } @@ -841,32 +841,20 @@ function isVisualMode(mode: VimContext["mode"]): mode is "visual" | "visual-line return mode === "visual" || mode === "visual-line" } -function vimLineLength(text: string, line: number) { - return text.split("\n")[line]?.length ?? 0 -} - -function vimOffsetFromPosition(text: string, position: CursorPosition) { - const lines = text.split("\n") - const line = clamp(position.line, 0, Math.max(0, lines.length - 1)) - let offset = 0 - for (let index = 0; index < line; index++) offset += lines[index].length + 1 - return offset + clamp(position.col, 0, lines[line]?.length ?? 0) -} - -function clampNormalCursor(input: EditBufferLike) { +function clampNormalCursor(input: EditBufferLike, map: PromptMap) { const cursor = input.visualCursor const offset = input.cursorOffset - const text = input.plainText - if (!cursor || offset === undefined || text === undefined) return + const text = map.hostText + if (!cursor || offset === undefined) return if (cursor.visualCol === 0) return - const dw = displayWidth(text) + const dw = map.displayWidth if (offset >= dw) { - input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, dw - 1))) + input.cursorOffset = hostOffset(map, hostPosition(map, Math.max(0, dw - 1))) return } - const charIdx = displayToChar(text, offset) + const charIdx = hostCharOffset(map, offset) if (charIdx < text.length && text[charIdx] === '\n') { - input.cursorOffset = charToDisplay(text, displayToChar(text, Math.max(0, offset - 1))) + input.cursorOffset = hostOffset(map, hostPosition(map, Math.max(0, offset - 1))) } } @@ -881,7 +869,7 @@ function dispatchCommand(command: string, ctx: PromptContext) { // 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) + if (nextInput?.plainText !== undefined) nextInput.cursorOffset = displayWidth(nextInput.plainText, ctx.api.renderer.widthMethod) return result } @@ -905,7 +893,8 @@ function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined const map = createKeybindMap() let count = 0 - for (const [mode, keymaps] of Object.entries(config.keymaps) as Array<[VimeeMode, Record | undefined]>) { + 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 { diff --git a/src/plugin.tsx b/src/plugin.tsx deleted file mode 100644 index bd32058..0000000 --- a/src/plugin.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" -import { createSignal } from "solid-js" -import { PromptRoot } from "./prompt/root" -import type { PromptModule } from "./prompt/types" -import { createVimModule } from "./modules/vim" -import { createSnippetsModule } from "./modules/snippets" -import type { SnippetController } from "./modules/snippets/types" -import { checkAutoUpdate } from "./update" - -const tui: TuiPlugin = async (api, options, meta) => { - const vimEnabledKey = `${meta.id}:enabled` - const [vimEnabled, setVimEnabled] = createSignal(api.kv.get(vimEnabledKey, true) === true) - - api.keymap.registerLayer({ - commands: [ - { - namespace: "palette", - name: "opencode-vim.toggle", - title: "Toggle Vim Mode", - desc: "Enable or disable Vim key handling", - category: "Vim", - slashName: "vim", - run() { - const next = !vimEnabled() - setVimEnabled(next) - api.kv.set(vimEnabledKey, next) - api.ui.toast({ variant: "info", message: `Vim mode ${next ? "enabled" : "disabled"}` }) - }, - }, - ], - }) - - if (readAutoUpdate(options)) { - let timer: Timer | undefined - api.lifecycle.onDispose(() => { - if (timer) clearTimeout(timer) - }) - - void checkAutoUpdate(meta, api.lifecycle.signal).then((result) => { - if (!result.updated) return - - timer = setTimeout(() => { - api.ui.toast({ variant: "info", message: "Updating opencode-vim. Restart OpenCode to finish." }) - }, 5000) - }).catch(() => {}) - } - - const moduleCache = new Map() - const createModules = (key: string) => { - const cached = moduleCache.get(key) - if (cached) return cached - - const modules: PromptModule[] = [] - const snippets: SnippetController = {} - if (hasSnippetsPlugin(api)) modules.push(createSnippetsModule(snippets)) - modules.push(createVimModule(options, vimEnabled, snippets)) - moduleCache.set(key, modules) - return modules - } - - api.slots.register({ - order: 50, - slots: { - home_prompt(ctx, props) { - return - }, - session_prompt(ctx, props) { - return ( - - ) - }, - }, - }) -} - -function readAutoUpdate(options: unknown) { - if (!options || typeof options !== "object") return true - const value = (options as { autoUpdate?: unknown }).autoUpdate - return typeof value === "boolean" ? value : true -} - -function hasSnippetsPlugin(api: TuiPluginApi) { - return configHasSnippets(api.state.config.plugin) || configHasSnippets(api.tuiConfig.plugin) || api.plugins.list().some(pluginHasSnippets) -} - -function configHasSnippets(plugins: unknown) { - if (!Array.isArray(plugins)) return false - return plugins.some((plugin) => specHasSnippets(Array.isArray(plugin) ? plugin[0] : plugin)) -} - -function pluginHasSnippets(plugin: ReturnType[number]) { - return plugin.enabled && (specHasSnippets(plugin.id) || specHasSnippets(plugin.spec)) -} - -function specHasSnippets(spec: unknown) { - if (typeof spec !== "string") return false - return spec === "opencode-snippets" || spec.startsWith("opencode-snippets@") || spec.includes("/opencode-snippets") -} - -const plugin: TuiPluginModule & { id: string } = { - id: "local.opencode-vim", - tui, -} - -export default plugin diff --git a/src/prompt/host.tsx b/src/prompt/host.tsx deleted file mode 100644 index 917de59..0000000 --- a/src/prompt/host.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { TuiPromptRef } from "@opencode-ai/plugin/tui" -import type { JSX } from "@opentui/solid" -import type { PromptContext, PromptModule } from "./types" -import { submitModules } from "./modules" - -type HostPromptProps = { - ctx: PromptContext - modules: PromptModule[] - ref?: (ref: TuiPromptRef | undefined) => void - right?: JSX.Element -} - -export function HostPrompt(props: HostPromptProps) { - const api = props.ctx.api - - const setRef = (ref: TuiPromptRef | undefined) => { - props.ctx.setPromptRef(ref) - props.ref?.(ref) - } - - const onSubmit = () => { - if (submitModules(props.modules, props.ctx)) return - props.ctx.submitHost() - } - - return ( - - ) -} diff --git a/src/prompt/modules.ts b/src/prompt/modules.ts deleted file mode 100644 index 9e1e5af..0000000 --- a/src/prompt/modules.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { PromptContext, PromptModule } from "./types" - -export function sortModules(modules: PromptModule[]) { - return [...modules].sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id)) -} - -export function submitModules(modules: PromptModule[], ctx: PromptContext) { - for (const module of modules) { - if (module.onSubmit?.(ctx) === true) return true - } - return false -} - -export function notifyPromptRef(modules: PromptModule[], ref: ReturnType, ctx: PromptContext) { - for (const module of modules) module.onPromptRef?.(ref, ctx) -} diff --git a/src/prompt/root.tsx b/src/prompt/root.tsx deleted file mode 100644 index a400fdd..0000000 --- a/src/prompt/root.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { onCleanup } from "solid-js" -import type { JSX } from "@opentui/solid" -import { HostPrompt } from "./host" -import { notifyPromptRef, sortModules } from "./modules" -import type { PromptContext, PromptRootProps } from "./types" - -export function PromptRoot(props: PromptRootProps) { - const modules = sortModules(props.modules) - const api = props.api - let current: ReturnType - const ctx: PromptContext = { - api, - slot: props.slot, - kind: props.kind, - get sessionID() { - return props.sessionID - }, - get workspaceID() { - return props.workspaceID - }, - get visible() { - return props.visible - }, - get disabled() { - return props.disabled - }, - prompt: () => current, - setPromptRef(ref) { - current = ref - notifyPromptRef(modules, ref, ctx) - }, - submitHost: () => props.onSubmit?.(), - requestRender: () => api.renderer.requestRender(), - } - - const cleanups = modules.flatMap((module) => { - const cleanup = module.setup?.(ctx) - return typeof cleanup === "function" ? [cleanup] : [] - }) - onCleanup(() => { - for (const cleanup of cleanups) cleanup() - }) - - const moduleAbove = modules.map((module) => module.renderAbove?.(ctx)).filter(Boolean) as JSX.Element[] - const moduleBelow = modules.map((module) => module.renderBelow?.(ctx)).filter(Boolean) as JSX.Element[] - const moduleRight = modules.map((module) => module.renderRight?.(ctx)).filter(Boolean) as JSX.Element[] - const hostRight = renderHostRight(props) - - return ( - - {moduleAbove} - } /> - {moduleBelow} - - ) -} - -function renderHostRight(props: PromptRootProps) { - const Slot = props.api.ui.Slot - if (props.kind === "home") return - return -} - -function RightItems(props: { items: JSX.Element[] }) { - const items = props.items.filter(Boolean) - if (items.length === 0) return undefined - return {items} -} diff --git a/src/prompt/types.ts b/src/prompt/types.ts deleted file mode 100644 index 589a1c9..0000000 --- a/src/prompt/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { JSX } from "@opentui/solid" -import type { TuiPluginApi, TuiPromptRef, TuiSlotContext } from "@opencode-ai/plugin/tui" - -export type PromptKind = "home" | "session" - -export type PromptContext = { - api: TuiPluginApi - slot: TuiSlotContext - kind: PromptKind - sessionID?: string - workspaceID?: string - visible?: boolean - disabled?: boolean - prompt: () => TuiPromptRef | undefined - setPromptRef: (ref: TuiPromptRef | undefined) => void - submitHost: () => void - requestRender: () => void -} - -export type PromptModule = { - id: string - order?: number - setup?: (ctx: PromptContext) => void | (() => void) - onPromptRef?: (ref: TuiPromptRef | undefined, ctx: PromptContext) => void - onSubmit?: (ctx: PromptContext) => boolean | void - renderAbove?: (ctx: PromptContext) => JSX.Element - renderBelow?: (ctx: PromptContext) => JSX.Element - renderRight?: (ctx: PromptContext) => JSX.Element -} - -export type PromptRootProps = { - api: TuiPluginApi - slot: TuiSlotContext - kind: PromptKind - sessionID?: string - workspaceID?: string - visible?: boolean - disabled?: boolean - onSubmit?: () => void - ref?: (ref: TuiPromptRef | undefined) => void - modules: PromptModule[] -} diff --git a/src/readers/default.tsx b/src/readers/default.tsx new file mode 100644 index 0000000..72c56da --- /dev/null +++ b/src/readers/default.tsx @@ -0,0 +1,7 @@ +/** @jsxImportSource @opentui/solid */ +import { TextReader } from "./text" +import type { ReaderProps } from "./types" + +export function DefaultReader(props: ReaderProps) { + return +} diff --git a/src/readers/diff/data.ts b/src/readers/diff/data.ts new file mode 100644 index 0000000..f6087bf --- /dev/null +++ b/src/readers/diff/data.ts @@ -0,0 +1,81 @@ +import { parsePatch, type StructuredPatchHunk } from "diff" +import type { DiffView } from "../../modules/vim/config" +import type { TranscriptItem } from "../../transcript-items" + +export type DiffSnapshot = { + path: string + status: "added" | "deleted" | "modified" + additions: number + deletions: number + hunks: StructuredPatchHunk[] +} + +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 + 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 + if (status !== "added" && status !== "deleted" && status !== "modified") continue + files.push({ path, status, additions: item.additions, deletions: item.deletions, patch: item.patch }) + } + // A multi-file result needs the file selected in the native transcript. + if (source.name === "patch" && files.length > 1 && message.fileIndex === undefined) return + const file = files[message.fileIndex ?? 0] + if (!file) return + 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 } + } catch { + return + } +} + +export function availableViews(snapshot: DiffSnapshot): DiffView[] { + if (snapshot.status === "added") return ["after", "diff"] + if (snapshot.status === "deleted") return ["before", "diff"] + return ["after", "before", "diff"] +} + +export function diffContent(hunks: StructuredPatchHunk[], view: DiffView) { + const lines: string[] = [] + const numbers = new Map() + const signs = new Map() + const hideLineNumbers = new Set() + let start: number | undefined + let end = 0 + for (const hunk of hunks) { + let before = hunk.oldStart + let after = hunk.newStart + let first = true + for (const line of hunk.lines) { + const sign = line[0] + if (sign === "\\") continue + const include = view === "diff" || (view === "before" ? sign !== "+" : sign !== "-") + if (include) { + if (first && lines.length) { + hideLineNumbers.add(lines.length) + lines.push("") + } + first = false + const row = lines.length + const number = sign === "-" || view === "before" ? before : after + start ??= number + end = number + numbers.set(row, number) + if (view === "diff" && (sign === "+" || sign === "-")) signs.set(row, sign) + lines.push(line.slice(1)) + } + if (sign !== "+") before++ + if (sign !== "-") after++ + } + } + if (!lines.length) hideLineNumbers.add(0) + return { text: lines.join("\n"), numbers, signs, hideLineNumbers, start: start ?? 0, end, empty: !lines.length } +} diff --git a/src/readers/diff/index.tsx b/src/readers/diff/index.tsx new file mode 100644 index 0000000..e3098e5 --- /dev/null +++ b/src/readers/diff/index.tsx @@ -0,0 +1,69 @@ +/** @jsxImportSource @opentui/solid */ +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 { createReaderSyntax, highlightCode } from "../syntax" +import { TextReader } from "../text" +import type { ReaderProps } from "../types" +import { availableViews, diffContent, type DiffSnapshot } from "./data" + +export function DiffReader(props: ReaderProps & { snapshot: DiffSnapshot }) { + const data = props.snapshot + const views = availableViews(data) + const labels = { after: "After", before: "Before", diff: "Diff" } + const initial = views.includes(props.config.diffView) ? props.config.diffView : views[0] + const [view, setView] = createSignal(initial) + const dimensions = useTerminalDimensions() + const syntax = createReaderSyntax(props.context) + const filetype = pathToFiletype(data.path) + const positions = new Map([[initial, props.offset]]) + + function switchView() { + 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) + }} /> + }} +} diff --git a/src/readers/index.tsx b/src/readers/index.tsx new file mode 100644 index 0000000..fbb1279 --- /dev/null +++ b/src/readers/index.tsx @@ -0,0 +1,26 @@ +/** @jsxImportSource @opentui/solid */ +import { DefaultReader } from "./default" +import { ShellReader } from "./shell" +import { ReadReader } from "./read" +import { readSnapshot } from "./read/data" +import { DiffReader } from "./diff" +import { diffSnapshot } from "./diff/data" +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")) { + return + } + if (source?.type === "tool" && source.name === "read") { + const snapshot = readSnapshot(source) + if (snapshot) return + } + if (source?.type === "tool" && (source.name === "edit" || source.name === "patch")) { + const snapshot = diffSnapshot(props.message) + if (snapshot) return + } + return +} diff --git a/src/readers/read/data.ts b/src/readers/read/data.ts new file mode 100644 index 0000000..5474322 --- /dev/null +++ b/src/readers/read/data.ts @@ -0,0 +1,44 @@ +import type { TranscriptSource } from "../../transcript-items" + +type ReadSource = Extract + +export type ReadSnapshot = { + path: string + text: string + start: number + end: number + partial: boolean + clipped: boolean +} + +export function readSnapshot(source: ReadSource): ReadSnapshot | undefined { + const state = source.state + if (state.status !== "completed" || state.content.length !== 1) return + const content = state.content[0] + if (content.type !== "text") return + const rows = content.text.replace(/\r\n/g, "\n").split("\n") + const header = /^Read file (.+), (?:lines ([1-9]\d*)-([1-9]\d*)|0 lines)$/.exec(rows[0]) + if (!header) return + const start = Number(header[2] ?? 1) + const end = Number(header[3] ?? 0) + const continuation = /^\[Output truncated\. Continue reading with offset: \d+\]$/.test(rows.at(-1)!) + if (continuation) rows.pop() + const count = header[2] ? end - start + 1 : 0 + if ((header[2] && end < start) || rows.length !== count + 1) return + + const lines: string[] = [] + let clipped = false + for (let index = 0; index < count; index++) { + const prefix = `${start + index}: ` + const row = rows[index + 1] + if (!row.startsWith(prefix)) return + const line = row.slice(prefix.length) + if (line.endsWith("... (line truncated to 2000 chars)")) clipped = true + lines.push(line) + } + return { + 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 new file mode 100644 index 0000000..dd0776d --- /dev/null +++ b/src/readers/read/index.tsx @@ -0,0 +1,23 @@ +/** @jsxImportSource @opentui/solid */ +import { pathToFiletype } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { onCleanup } from "solid-js" +import { createReaderSyntax, highlightCode } from "../syntax" +import { TextReader } from "../text" +import type { ReaderProps } from "../types" +import type { ReadSnapshot } from "./data" + +export function ReadReader(props: ReaderProps & { snapshot: ReadSnapshot }) { + const data = props.snapshot + const dimensions = useTerminalDimensions() + const syntax = createReaderSyntax(props.context) + 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) }} /> +} diff --git a/src/readers/shell/data.ts b/src/readers/shell/data.ts new file mode 100644 index 0000000..d976383 --- /dev/null +++ b/src/readers/shell/data.ts @@ -0,0 +1,101 @@ +import { stripVTControlCharacters } from "node:util" +import type { Context } from "@opencode/plugin/tui/context" +import type { TranscriptSource } from "../../transcript-items" + +export type ShellSource = Extract +export type ShellSnapshot = { + command: string + workdir?: string + shellID?: string + status: string + output: string + notice: string +} + +export const OUTPUT_LIMIT = 1024 * 1024 + +export function shellSnapshot(source: ShellSource): ShellSnapshot { + if (source.type === "synthetic") { + const metadata = source.metadata ?? {} + 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 + 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" : "", + } + const state = source.state + if (state.status === "streaming") return { command: "", status: "Receiving command…", output: "", notice: "" } + const metadata = state.metadata ?? {} + let output = "" + if (state.status === "completed" || state.status === "error") { + for (const part of state.content ?? []) { + if (part.type !== "text") continue + if (output) output += "\n\n" + output += part.text + } + } + let status = state.status === "running" ? "Running · snapshot" : "Completed" + if (state.status === "error") { + status = "Failed" + if (!output) output = state.error.message + } else if (metadata.timeout === true) status = "Timed out" + else if (typeof metadata.signal === "string") status = `Killed · ${metadata.signal}` + else if (metadata.status === "running") status = "Backgrounded" + else if (typeof metadata.exit === "number") status = shellStatus("exited", metadata.exit) + return { + 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" : "", + } +} + +function shellStatus(status: string, exit?: number | string) { + if (status === "running") return "Running · snapshot" + if (status === "timeout") return "Timed out" + if (status === "killed") return "Killed" + return exit === undefined ? "Exited" : `Exited · code ${exit}` +} + +function cleanOutput(text: string) { + return stripVTControlCharacters(text).replace(/\r\n?/g, "\n") +} + +export async function loadShell(context: Context, sessionID: string, source: ShellSource, signal: AbortSignal) { + const snapshot = shellSnapshot(source) + if (!snapshot.shellID) return snapshot + const location = context.data.session.get(sessionID)?.location ?? context.location + const input = { id: snapshot.shellID, location } + try { + const [shell, output] = await Promise.all([ + context.client.shell.get(input, { signal }), + context.client.shell.output({ ...input, cursor: 0, limit: OUTPUT_LIMIT }, { signal }), + ]) + const info = shell.data + const page = output.data + return { + ...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" : "", + } + } catch { + if (signal.aborted) return snapshot + snapshot.notice = snapshot.notice + ? `Capture unavailable · showing saved result · ${snapshot.notice}` + : "Capture unavailable · showing saved result" + return snapshot + } +} diff --git a/src/readers/shell/index.tsx b/src/readers/shell/index.tsx new file mode 100644 index 0000000..56d2586 --- /dev/null +++ b/src/readers/shell/index.tsx @@ -0,0 +1,50 @@ +/** @jsxImportSource @opentui/solid */ +import { createSignal, onCleanup, onMount, Show } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" +import type { TextareaRenderable } from "@opentui/core" +import { loadShell, type ShellSnapshot, type ShellSource } from "./data" +import { ReaderHeader, TextReader } from "../text" +import type { ReaderProps } from "../types" +import { createReaderSyntax, highlightCode } from "../syntax" +import { highlightDiff } from "./syntax" + +export function ShellReader(props: ReaderProps & { source: ShellSource }) { + const theme = () => props.context.theme.surface("dialog") + const dimensions = useTerminalDimensions() + const syntax = createReaderSyntax(props.context) + let command!: TextareaRenderable + const commandHeight = () => Math.max(1, Math.min(4, Math.floor(dimensions().height / 6))) + const controller = new AbortController() + onCleanup(() => controller.abort()) + const [snapshot, setSnapshot] = createSignal() + onMount(async () => { + const result = await loadShell(props.context, props.sessionID, props.source, controller.signal) + if (!controller.signal.aborted) setSnapshot(result) + }) + 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())} + } + +