opencode-vim 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/bun.lock +15 -0
- package/docs/configuration.md +403 -0
- package/package.json +12 -0
- package/src/modules/snippets/index.tsx +19 -0
- package/src/modules/snippets/loader.ts +147 -0
- package/src/modules/snippets/search.ts +118 -0
- package/src/modules/snippets/skill-loader.ts +81 -0
- package/src/modules/snippets/state.ts +40 -0
- package/src/modules/snippets/trigger.ts +60 -0
- package/src/modules/snippets/types.ts +49 -0
- package/src/modules/snippets/view.tsx +626 -0
- package/src/modules/vim/actions.ts +54 -0
- package/src/modules/vim/config.ts +119 -0
- package/src/modules/vim/index.tsx +96 -0
- package/src/modules/vim/keys.ts +34 -0
- package/src/modules/vim/log.ts +37 -0
- package/src/modules/vim/map.ts +159 -0
- package/src/modules/vim/state.ts +24 -0
- package/src/modules/vim/view.tsx +86 -0
- package/src/modules/vim/vimee.ts +364 -0
- package/src/plugin.tsx +69 -0
- package/src/prompt/host.tsx +38 -0
- package/src/prompt/modules.ts +16 -0
- package/src/prompt/ref.ts +17 -0
- package/src/prompt/root.tsx +56 -0
- package/src/prompt/types.ts +42 -0
- package/tsconfig.json +12 -0
- package/tui.json.example +37 -0
- package/tui.tsx +1 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { VimMode } from "./state"
|
|
2
|
+
|
|
3
|
+
export type VimCursorStyle = {
|
|
4
|
+
style: "block" | "line" | "underline" | "default"
|
|
5
|
+
blinking?: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type VimConfig = {
|
|
9
|
+
defaultMode: VimMode
|
|
10
|
+
keymapTimeout: number
|
|
11
|
+
pendingDisplayDelay: number
|
|
12
|
+
cursorStyles: Record<VimMode, VimCursorStyle>
|
|
13
|
+
debug: boolean
|
|
14
|
+
debugPath?: string
|
|
15
|
+
keymaps: VimKeymaps
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type VimOptions = {
|
|
19
|
+
defaultMode?: VimMode
|
|
20
|
+
keymapTimeout?: number
|
|
21
|
+
pendingDisplayDelay?: number
|
|
22
|
+
cursorStyles?: Partial<Record<VimMode, VimCursorStyle>>
|
|
23
|
+
debug?: boolean
|
|
24
|
+
debugPath?: string
|
|
25
|
+
keymaps?: VimKeymaps
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type VimKeymaps = Partial<Record<VimMode, Record<string, VimMappedAction>>>
|
|
29
|
+
export type VimMappedAction = string
|
|
30
|
+
|
|
31
|
+
const DEFAULT_CURSOR_STYLES: Record<VimMode, VimCursorStyle> = {
|
|
32
|
+
insert: { style: "line", blinking: true },
|
|
33
|
+
normal: { style: "block", blinking: true },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createVimConfig(options: unknown): VimConfig {
|
|
37
|
+
const input = readOptions(options)
|
|
38
|
+
return {
|
|
39
|
+
defaultMode: input.defaultMode ?? "insert",
|
|
40
|
+
keymapTimeout: Math.max(0, input.keymapTimeout ?? 500),
|
|
41
|
+
pendingDisplayDelay: Math.max(0, input.pendingDisplayDelay ?? 120),
|
|
42
|
+
cursorStyles: {
|
|
43
|
+
insert: { ...DEFAULT_CURSOR_STYLES.insert, ...input.cursorStyles?.insert },
|
|
44
|
+
normal: { ...DEFAULT_CURSOR_STYLES.normal, ...input.cursorStyles?.normal },
|
|
45
|
+
},
|
|
46
|
+
debug: input.debug ?? process.env.VIM_PROMPT_DEBUG === "1",
|
|
47
|
+
debugPath: input.debugPath,
|
|
48
|
+
keymaps: input.keymaps ?? {},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readOptions(options: unknown): VimOptions {
|
|
53
|
+
if (!options || typeof options !== "object") return {}
|
|
54
|
+
const raw = "vim" in options ? (options as { vim?: unknown }).vim : options
|
|
55
|
+
if (!raw || typeof raw !== "object") return {}
|
|
56
|
+
|
|
57
|
+
const source = raw as Record<string, unknown>
|
|
58
|
+
return {
|
|
59
|
+
defaultMode: isMode(source.defaultMode) ? source.defaultMode : undefined,
|
|
60
|
+
keymapTimeout: readNumber(source.keymapTimeout),
|
|
61
|
+
pendingDisplayDelay: typeof source.pendingDisplayDelay === "number" ? source.pendingDisplayDelay : undefined,
|
|
62
|
+
cursorStyles: readCursorStyles(source.cursorStyles),
|
|
63
|
+
debug: typeof source.debug === "boolean" ? source.debug : undefined,
|
|
64
|
+
debugPath: typeof source.debugPath === "string" ? source.debugPath : undefined,
|
|
65
|
+
keymaps: readKeymaps(source.keymaps),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readKeymaps(input: unknown): VimKeymaps | undefined {
|
|
70
|
+
if (!input || typeof input !== "object") return undefined
|
|
71
|
+
const source = input as Record<string, unknown>
|
|
72
|
+
const keymaps: VimKeymaps = {}
|
|
73
|
+
|
|
74
|
+
for (const mode of ["insert", "normal"] as const) {
|
|
75
|
+
const raw = source[mode]
|
|
76
|
+
if (!raw || typeof raw !== "object") continue
|
|
77
|
+
keymaps[mode] = {}
|
|
78
|
+
for (const [key, action] of Object.entries(raw as Record<string, unknown>)) {
|
|
79
|
+
if (isMappedAction(action)) keymaps[mode][key] = action
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return keymaps
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readCursorStyles(input: unknown): VimOptions["cursorStyles"] {
|
|
87
|
+
if (!input || typeof input !== "object") return undefined
|
|
88
|
+
const source = input as Record<string, unknown>
|
|
89
|
+
return {
|
|
90
|
+
insert: readCursorStyle(source.insert),
|
|
91
|
+
normal: readCursorStyle(source.normal),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readCursorStyle(input: unknown): VimCursorStyle | undefined {
|
|
96
|
+
if (!input || typeof input !== "object") return undefined
|
|
97
|
+
const source = input as Record<string, unknown>
|
|
98
|
+
if (!isCursorStyle(source.style)) return undefined
|
|
99
|
+
return {
|
|
100
|
+
style: source.style,
|
|
101
|
+
blinking: typeof source.blinking === "boolean" ? source.blinking : undefined,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isCursorStyle(value: unknown): value is VimCursorStyle["style"] {
|
|
106
|
+
return value === "block" || value === "line" || value === "underline" || value === "default"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isMode(value: unknown): value is VimMode {
|
|
110
|
+
return value === "insert" || value === "normal"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isMappedAction(value: unknown): value is VimMappedAction {
|
|
114
|
+
return typeof value === "string" && value.length > 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readNumber(value: unknown) {
|
|
118
|
+
return typeof value === "number" ? value : undefined
|
|
119
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
3
|
+
import { onCleanup } from "solid-js"
|
|
4
|
+
import type { PromptContext, PromptModule } from "../../prompt/types"
|
|
5
|
+
import { applyVimCursorStyle } from "./actions"
|
|
6
|
+
import type { VimConfig } from "./config"
|
|
7
|
+
import { createVimConfig } from "./config"
|
|
8
|
+
import { keyNotation } from "./keys"
|
|
9
|
+
import { createVimLog } from "./log"
|
|
10
|
+
import type { VimLog } from "./log"
|
|
11
|
+
import { createVimState } from "./state"
|
|
12
|
+
import { createVimeeAdapter } from "./vimee"
|
|
13
|
+
import { VimStatus } from "./view"
|
|
14
|
+
|
|
15
|
+
export function createVimModule(options?: unknown): PromptModule {
|
|
16
|
+
const config = createVimConfig(options)
|
|
17
|
+
const log = createVimLog(config)
|
|
18
|
+
const state = createVimState(config.defaultMode, log)
|
|
19
|
+
log("module.create", { defaultMode: config.defaultMode, keymapTimeout: config.keymapTimeout })
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
id: "vim",
|
|
23
|
+
order: 0,
|
|
24
|
+
setup(ctx) {
|
|
25
|
+
log("module.setup", { kind: ctx.kind, sessionID: ctx.sessionID, workspaceID: ctx.workspaceID })
|
|
26
|
+
},
|
|
27
|
+
renderAbove(ctx) {
|
|
28
|
+
return <VimKeyboard ctx={ctx} config={config} state={state} log={log} />
|
|
29
|
+
},
|
|
30
|
+
renderRight(ctx) {
|
|
31
|
+
return <VimStatus mode={state.mode} pending={() => readablePending(state.pending())} theme={ctx.api.theme.current} pendingDisplayDelay={config.pendingDisplayDelay} disabled={ctx.disabled} log={log} requestRender={ctx.requestRender} />
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType<typeof createVimState>; log: VimLog }) {
|
|
37
|
+
let cursorStyleMode = ""
|
|
38
|
+
const vimee = createVimeeAdapter(props.state, props.config, props.log)
|
|
39
|
+
props.log("keyboard.mount", { kind: props.ctx.kind })
|
|
40
|
+
|
|
41
|
+
const cursorStyleTimer = setInterval(syncCursorStyle, 50)
|
|
42
|
+
|
|
43
|
+
useKeyboard((event) => {
|
|
44
|
+
props.log("keyboard.event", {
|
|
45
|
+
name: event.name,
|
|
46
|
+
ctrl: event.ctrl,
|
|
47
|
+
meta: event.meta,
|
|
48
|
+
shift: event.shift,
|
|
49
|
+
defaultPrevented: event.defaultPrevented,
|
|
50
|
+
propagationStopped: event.propagationStopped,
|
|
51
|
+
mode: props.state.mode(),
|
|
52
|
+
pending: props.state.pending(),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (props.ctx.disabled || props.ctx.visible === false) {
|
|
56
|
+
props.log("keyboard.skip", { disabled: props.ctx.disabled, visible: props.ctx.visible })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const key = keyNotation(event)
|
|
61
|
+
if (!key) {
|
|
62
|
+
props.log("keyboard.no_key")
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const consumed = vimee.handle(event, key, props.ctx)
|
|
67
|
+
if (consumed) {
|
|
68
|
+
event.preventDefault()
|
|
69
|
+
event.stopPropagation()
|
|
70
|
+
}
|
|
71
|
+
syncCursorStyle(true)
|
|
72
|
+
props.ctx.requestRender()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
onCleanup(() => {
|
|
76
|
+
props.log("keyboard.cleanup", { kind: props.ctx.kind })
|
|
77
|
+
vimee.cleanup()
|
|
78
|
+
clearInterval(cursorStyleTimer)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
return <box height={0} />
|
|
82
|
+
|
|
83
|
+
function syncCursorStyle(force = false) {
|
|
84
|
+
const mode = props.state.mode()
|
|
85
|
+
if (!force && cursorStyleMode === mode) return
|
|
86
|
+
if (applyVimCursorStyle(props.ctx, props.config.cursorStyles[mode])) {
|
|
87
|
+
cursorStyleMode = mode
|
|
88
|
+
props.log("cursor.style", { mode, style: props.config.cursorStyles[mode].style, blinking: props.config.cursorStyles[mode].blinking })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readablePending(sequence: string) {
|
|
94
|
+
if (!sequence) return undefined
|
|
95
|
+
return sequence.replaceAll("><", " ")
|
|
96
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { KeyEvent } from "@opentui/core"
|
|
2
|
+
|
|
3
|
+
const NAMED_KEYS: Record<string, string> = {
|
|
4
|
+
escape: "<Esc>",
|
|
5
|
+
esc: "<Esc>",
|
|
6
|
+
return: "<CR>",
|
|
7
|
+
enter: "<CR>",
|
|
8
|
+
tab: "<Tab>",
|
|
9
|
+
backspace: "<BS>",
|
|
10
|
+
delete: "<Del>",
|
|
11
|
+
space: "<Space>",
|
|
12
|
+
up: "<Up>",
|
|
13
|
+
down: "<Down>",
|
|
14
|
+
left: "<Left>",
|
|
15
|
+
right: "<Right>",
|
|
16
|
+
home: "<Home>",
|
|
17
|
+
end: "<End>",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function keyNotation(event: KeyEvent) {
|
|
21
|
+
const name = event.name?.toLowerCase()
|
|
22
|
+
if (!name) return undefined
|
|
23
|
+
|
|
24
|
+
if (event.ctrl) return `<C-${ctrlKey(name)}>`
|
|
25
|
+
if (event.meta) return `<M-${name}>`
|
|
26
|
+
if (name.length === 1) return event.shift ? name.toUpperCase() : name
|
|
27
|
+
return NAMED_KEYS[name] ?? `<${name}>`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ctrlKey(name: string) {
|
|
31
|
+
if (name === "escape" || name === "esc") return "["
|
|
32
|
+
if (name.length === 1) return name
|
|
33
|
+
return name
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import type { VimConfig } from "./config"
|
|
4
|
+
|
|
5
|
+
export type VimLog = (event: string, data?: Record<string, unknown>) => void
|
|
6
|
+
|
|
7
|
+
export function createVimLog(config: VimConfig): VimLog {
|
|
8
|
+
if (!config.debug) return () => {}
|
|
9
|
+
|
|
10
|
+
const path = config.debugPath ?? defaultPath()
|
|
11
|
+
void append(path, `\n--- opencode-vim debug ${new Date().toISOString()} ---\n`)
|
|
12
|
+
|
|
13
|
+
return (event, data) => {
|
|
14
|
+
const suffix = data ? ` ${safeJson(data)}` : ""
|
|
15
|
+
void append(path, `${new Date().toISOString()} ${event}${suffix}\n`)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function defaultPath() {
|
|
20
|
+
const home = process.env.HOME
|
|
21
|
+
return home ? join(home, ".cache/opencode/opencode-vim.log") : "/tmp/opencode-vim.log"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function append(path: string, text: string) {
|
|
25
|
+
try {
|
|
26
|
+
await mkdir(dirname(path), { recursive: true })
|
|
27
|
+
await appendFile(path, text)
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeJson(data: Record<string, unknown>) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.stringify(data)
|
|
34
|
+
} catch {
|
|
35
|
+
return "{}"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { CursorPosition } from "@vimee/core"
|
|
2
|
+
import type { EditBufferLike } from "./actions"
|
|
3
|
+
|
|
4
|
+
export type PromptMap = {
|
|
5
|
+
hostText: string
|
|
6
|
+
vimText: string
|
|
7
|
+
hostToVim: number[]
|
|
8
|
+
vimToHost: Array<number | undefined>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createPromptMap(hostText: string, input?: EditBufferLike): PromptMap {
|
|
12
|
+
return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [])
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
|
|
16
|
+
const prefix = commonPrefix(map.vimText, vimText)
|
|
17
|
+
const suffix = commonSuffix(map.vimText, vimText, prefix)
|
|
18
|
+
const synthetic = new Set<number>()
|
|
19
|
+
let hostText = ""
|
|
20
|
+
|
|
21
|
+
for (let vimOffset = 0; vimOffset < vimText.length; vimOffset++) {
|
|
22
|
+
const oldOffset = previousOffset(map, vimOffset, vimText.length, prefix, suffix)
|
|
23
|
+
if (oldOffset !== undefined && map.vimToHost[oldOffset] === undefined) {
|
|
24
|
+
synthetic.add(vimOffset)
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
hostText += vimText[vimOffset]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return buildPromptMapFromSynthetic(hostText, vimText, synthetic)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hostPosition(map: PromptMap, hostOffset: number): CursorPosition {
|
|
35
|
+
return positionFromOffset(map.vimText, map.hostToVim[clamp(hostOffset, 0, map.hostText.length)] ?? 0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function hostOffset(map: PromptMap, position: CursorPosition, bias: "previous" | "next" = "next") {
|
|
39
|
+
return hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildPromptMap(hostText: string, wraps: number[]): PromptMap {
|
|
43
|
+
const hostToVim: number[] = []
|
|
44
|
+
const vimToHost: Array<number | undefined> = []
|
|
45
|
+
const wrapOffsets = new Set(wraps.filter((offset) => offset > 0 && offset < hostText.length && hostText[offset - 1] !== "\n"))
|
|
46
|
+
let vimText = ""
|
|
47
|
+
let vimOffset = 0
|
|
48
|
+
|
|
49
|
+
for (let hostOffset = 0; hostOffset < hostText.length; hostOffset++) {
|
|
50
|
+
if (wrapOffsets.has(hostOffset)) {
|
|
51
|
+
vimText += "\n"
|
|
52
|
+
vimToHost[vimOffset] = undefined
|
|
53
|
+
vimOffset++
|
|
54
|
+
}
|
|
55
|
+
hostToVim[hostOffset] = vimOffset
|
|
56
|
+
vimText += hostText[hostOffset]
|
|
57
|
+
vimToHost[vimOffset] = hostOffset
|
|
58
|
+
vimOffset++
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
hostToVim[hostText.length] = vimOffset
|
|
62
|
+
return { hostText, vimText, hostToVim, vimToHost }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number>): PromptMap {
|
|
66
|
+
const hostToVim: number[] = []
|
|
67
|
+
const vimToHost: Array<number | undefined> = []
|
|
68
|
+
let hostOffset = 0
|
|
69
|
+
|
|
70
|
+
for (let vimOffset = 0; vimOffset < vimText.length; vimOffset++) {
|
|
71
|
+
if (synthetic.has(vimOffset)) {
|
|
72
|
+
vimToHost[vimOffset] = undefined
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
hostToVim[hostOffset] = vimOffset
|
|
77
|
+
vimToHost[vimOffset] = hostOffset
|
|
78
|
+
hostOffset++
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
hostToVim[hostText.length] = vimText.length
|
|
82
|
+
return { hostText, vimText, hostToVim, vimToHost }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, prefix: number, suffix: number) {
|
|
86
|
+
if (vimOffset < prefix) return vimOffset
|
|
87
|
+
if (vimOffset >= vimLength - suffix) return map.vimText.length - (vimLength - vimOffset)
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function visualWrapOffsets(input: EditBufferLike, text: string) {
|
|
92
|
+
const original = input.cursorOffset
|
|
93
|
+
const wraps: number[] = []
|
|
94
|
+
let previousRow: number | undefined
|
|
95
|
+
|
|
96
|
+
for (let offset = 0; offset <= text.length; offset++) {
|
|
97
|
+
input.cursorOffset = offset
|
|
98
|
+
const row = input.visualCursor?.visualRow
|
|
99
|
+
if (row === undefined) {
|
|
100
|
+
wraps.length = 0
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
if (previousRow !== undefined && row > previousRow && text[offset - 1] !== "\n") wraps.push(offset)
|
|
104
|
+
previousRow = row
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
input.cursorOffset = original
|
|
108
|
+
return wraps
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hostOffsetFromVimOffset(map: PromptMap, vimOffset: number, bias: "previous" | "next") {
|
|
112
|
+
const offset = clamp(vimOffset, 0, map.vimText.length)
|
|
113
|
+
if (offset === map.vimText.length) return map.hostText.length
|
|
114
|
+
|
|
115
|
+
const host = map.vimToHost[offset]
|
|
116
|
+
if (host !== undefined) return host
|
|
117
|
+
|
|
118
|
+
if (bias === "previous") {
|
|
119
|
+
for (let previous = offset - 1; previous >= 0; previous--) {
|
|
120
|
+
const previousHost = map.vimToHost[previous]
|
|
121
|
+
if (previousHost !== undefined) return previousHost
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (let next = offset + 1; next < map.vimToHost.length; next++) {
|
|
126
|
+
const nextHost = map.vimToHost[next]
|
|
127
|
+
if (nextHost !== undefined) return nextHost
|
|
128
|
+
}
|
|
129
|
+
return map.hostText.length
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function commonPrefix(left: string, right: string) {
|
|
133
|
+
let index = 0
|
|
134
|
+
while (index < left.length && index < right.length && left[index] === right[index]) index++
|
|
135
|
+
return index
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function commonSuffix(left: string, right: string, prefix: number) {
|
|
139
|
+
let length = 0
|
|
140
|
+
while (length + prefix < left.length && length + prefix < right.length && left[left.length - length - 1] === right[right.length - length - 1]) length++
|
|
141
|
+
return length
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function positionFromOffset(text: string, offset: number): CursorPosition {
|
|
145
|
+
const lines = text.slice(0, offset).split("\n")
|
|
146
|
+
return { line: lines.length - 1, col: lines[lines.length - 1]?.length ?? 0 }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function offsetFromPosition(text: string, position: CursorPosition) {
|
|
150
|
+
const lines = text.split("\n")
|
|
151
|
+
const line = clamp(position.line, 0, Math.max(0, lines.length - 1))
|
|
152
|
+
let offset = 0
|
|
153
|
+
for (let index = 0; index < line; index++) offset += lines[index].length + 1
|
|
154
|
+
return offset + clamp(position.col, 0, lines[line]?.length ?? 0)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function clamp(value: number, min: number, max: number) {
|
|
158
|
+
return Math.max(min, Math.min(max, value))
|
|
159
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import type { VimLog } from "./log"
|
|
3
|
+
|
|
4
|
+
export type VimMode = "normal" | "insert"
|
|
5
|
+
|
|
6
|
+
export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) {
|
|
7
|
+
const [mode, setMode] = createSignal<VimMode>(defaultMode)
|
|
8
|
+
const [pending, setPending] = createSignal("")
|
|
9
|
+
|
|
10
|
+
log("state.init", { mode: defaultMode })
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
mode,
|
|
14
|
+
setMode(next: VimMode) {
|
|
15
|
+
if (mode() !== next) log("state.mode", { from: mode(), to: next })
|
|
16
|
+
setMode(next)
|
|
17
|
+
},
|
|
18
|
+
pending,
|
|
19
|
+
setPending(next: string) {
|
|
20
|
+
if (pending() !== next) log("state.pending", { from: pending(), to: next })
|
|
21
|
+
setPending(next)
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { createSignal, onCleanup } from "solid-js"
|
|
3
|
+
import type { Accessor } from "solid-js"
|
|
4
|
+
import type { PromptContext } from "../../prompt/types"
|
|
5
|
+
import type { VimLog } from "./log"
|
|
6
|
+
import type { VimMode } from "./state"
|
|
7
|
+
|
|
8
|
+
const STATUS_SYNC_MS = 50
|
|
9
|
+
|
|
10
|
+
type VimStatusProps = {
|
|
11
|
+
mode: Accessor<VimMode>
|
|
12
|
+
pending: Accessor<string | undefined>
|
|
13
|
+
theme: PromptContext["api"]["theme"]["current"]
|
|
14
|
+
pendingDisplayDelay?: number
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
log?: VimLog
|
|
17
|
+
requestRender?: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function VimStatus(props: VimStatusProps) {
|
|
21
|
+
const [mode, setMode] = createSignal(props.mode())
|
|
22
|
+
const [pending, setPending] = createSignal<string | undefined>()
|
|
23
|
+
let pendingTimer: ReturnType<typeof setTimeout> | undefined
|
|
24
|
+
let scheduledPending: string | undefined
|
|
25
|
+
|
|
26
|
+
const sync = () => {
|
|
27
|
+
const nextMode = props.mode()
|
|
28
|
+
const nextPending = props.pending()
|
|
29
|
+
|
|
30
|
+
if (mode() !== nextMode) {
|
|
31
|
+
props.log?.("status.sync", { fromMode: mode(), toMode: nextMode, fromPending: pending(), toPending: pending() })
|
|
32
|
+
setMode(nextMode)
|
|
33
|
+
props.requestRender?.()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
syncPending(nextPending)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const timer = setInterval(sync, STATUS_SYNC_MS)
|
|
40
|
+
onCleanup(() => {
|
|
41
|
+
clearInterval(timer)
|
|
42
|
+
if (pendingTimer) clearTimeout(pendingTimer)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="row">
|
|
47
|
+
{pending() ? <text fg={props.theme.info}>{pending()} </text> : undefined}
|
|
48
|
+
<text fg={props.disabled ? props.theme.textMuted : mode() === "normal" ? props.theme.warning : props.theme.success}>{mode() === "normal" ? "NORMAL" : "INSERT"}</text>
|
|
49
|
+
</box>
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
function syncPending(nextPending: string | undefined) {
|
|
53
|
+
if (!nextPending) {
|
|
54
|
+
if (pendingTimer) clearTimeout(pendingTimer)
|
|
55
|
+
pendingTimer = undefined
|
|
56
|
+
scheduledPending = undefined
|
|
57
|
+
setDisplayedPending(undefined)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (pending() === nextPending) return
|
|
62
|
+
if (scheduledPending === nextPending) return
|
|
63
|
+
|
|
64
|
+
if (pendingTimer) clearTimeout(pendingTimer)
|
|
65
|
+
scheduledPending = nextPending
|
|
66
|
+
const delay = props.pendingDisplayDelay ?? 120
|
|
67
|
+
if (delay <= 0) {
|
|
68
|
+
scheduledPending = undefined
|
|
69
|
+
setDisplayedPending(nextPending)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pendingTimer = setTimeout(() => {
|
|
74
|
+
pendingTimer = undefined
|
|
75
|
+
scheduledPending = undefined
|
|
76
|
+
if (props.pending() === nextPending) setDisplayedPending(nextPending)
|
|
77
|
+
}, delay)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function setDisplayedPending(nextPending: string | undefined) {
|
|
81
|
+
if (pending() === nextPending) return
|
|
82
|
+
props.log?.("status.sync", { fromMode: mode(), toMode: mode(), fromPending: pending(), toPending: nextPending })
|
|
83
|
+
setPending(nextPending)
|
|
84
|
+
props.requestRender?.()
|
|
85
|
+
}
|
|
86
|
+
}
|