opencode-vim-navigation 0.1.3 → 0.1.5
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 +7 -0
- package/package.json +12 -3
- package/src/adapter.mjs +49 -10
- package/src/navigation.mjs +9 -6
package/README.md
CHANGED
|
@@ -7,6 +7,13 @@ scrollback—while keeping your prompt draft safe.
|
|
|
7
7
|
Press **Ctrl+Q** to switch between INPUT and NAV. A compact, right-aligned
|
|
8
8
|
mode label appears on a divider immediately above the prompt.
|
|
9
9
|
|
|
10
|
+
When the native plan-review form is the only pending form, Ctrl+Q also enters
|
|
11
|
+
NAV even though the prompt does not own focus. Navigation scrolls the session
|
|
12
|
+
transcript behind the form; NAV consumes Enter, Escape, and all other
|
|
13
|
+
non-navigation keys so they cannot approve, cancel, or edit the form. Press
|
|
14
|
+
Ctrl+Q again to return to the form. Permission prompts and unrelated forms
|
|
15
|
+
continue to own their keys and block NAV.
|
|
16
|
+
|
|
10
17
|
## Compatibility
|
|
11
18
|
|
|
12
19
|
Tested with **OpenCode 2.1.4**. This uses OpenCode V2's TUI plugin API, not the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-vim-navigation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Modal Vim-style transcript navigation for the OpenCode terminal UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Raaghav Arya",
|
|
@@ -17,11 +17,20 @@
|
|
|
17
17
|
"registry": "https://registry.npmjs.org/"
|
|
18
18
|
},
|
|
19
19
|
"type": "module",
|
|
20
|
-
"keywords": [
|
|
20
|
+
"keywords": [
|
|
21
|
+
"opencode",
|
|
22
|
+
"opencode-plugin",
|
|
23
|
+
"tui",
|
|
24
|
+
"vim",
|
|
25
|
+
"navigation"
|
|
26
|
+
],
|
|
21
27
|
"exports": {
|
|
22
28
|
"./tui": "./tui.ts"
|
|
23
29
|
},
|
|
24
|
-
"files": [
|
|
30
|
+
"files": [
|
|
31
|
+
"tui.ts",
|
|
32
|
+
"src/"
|
|
33
|
+
],
|
|
25
34
|
"peerDependencies": {
|
|
26
35
|
"@opentui/core": ">=0.6.8 <0.7.0",
|
|
27
36
|
"@opentui/solid": ">=0.6.8 <0.7.0",
|
package/src/adapter.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export function createSetup({ InputRenderable, TextareaRenderable, createElement
|
|
|
16
16
|
const route = ui.router.current()
|
|
17
17
|
return route.type === "session" ? route.sessionID : undefined
|
|
18
18
|
}
|
|
19
|
-
const
|
|
19
|
+
const promptEditor = () => {
|
|
20
20
|
const editor = renderer.currentFocusedEditor
|
|
21
21
|
const traits = editor?.traits
|
|
22
22
|
return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable)
|
|
@@ -24,20 +24,50 @@ export function createSetup({ InputRenderable, TextareaRenderable, createElement
|
|
|
24
24
|
&& !traits.status
|
|
25
25
|
&& !traits.capture?.includes("navigate") && !traits.capture?.includes("submit")
|
|
26
26
|
&& !traits.capture?.includes("autocomplete")
|
|
27
|
+
? editor
|
|
28
|
+
: undefined
|
|
27
29
|
}
|
|
28
|
-
const
|
|
30
|
+
const promptFocused = () => promptEditor() !== undefined
|
|
31
|
+
let hiddenCursor
|
|
32
|
+
const restoreCursor = () => {
|
|
33
|
+
if (!hiddenCursor) return
|
|
34
|
+
if (!hiddenCursor.editor.isDestroyed) hiddenCursor.editor.showCursor = hiddenCursor.showCursor
|
|
35
|
+
hiddenCursor = undefined
|
|
36
|
+
}
|
|
37
|
+
const syncCursor = (active = nav()) => {
|
|
38
|
+
const editor = active ? promptEditor() : undefined
|
|
39
|
+
if (hiddenCursor && hiddenCursor.editor !== editor) restoreCursor()
|
|
40
|
+
if (editor && !hiddenCursor && editor.showCursor === true && !editor.isDestroyed) {
|
|
41
|
+
hiddenCursor = { editor, showCursor: true }
|
|
42
|
+
editor.showCursor = false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const visibleForms = () => {
|
|
29
46
|
const id = session()
|
|
30
|
-
if (!id) return
|
|
47
|
+
if (!id) return []
|
|
31
48
|
const info = data.session.get(id)
|
|
32
49
|
const location = info?.location ?? context.location
|
|
33
50
|
// Match the host's visible request set: root sessions aggregate their
|
|
34
51
|
// descendants; child sessions show global/location forms only.
|
|
35
|
-
|
|
36
|
-
if (info?.parentID) return
|
|
52
|
+
const forms = [...(data.session.form.list("global", location) ?? [])]
|
|
53
|
+
if (info?.parentID) return forms
|
|
37
54
|
const ids = new Set([id, ...data.session.family(id)])
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
for (const sessionID of ids) forms.push(...(data.session.form.list(sessionID) ?? []))
|
|
56
|
+
return forms
|
|
57
|
+
}
|
|
58
|
+
const planReviewForm = () => {
|
|
59
|
+
const forms = visibleForms()
|
|
60
|
+
return forms.length > 0 && forms.every((form) => form.metadata?.plugin === "raaghav.plan-review")
|
|
61
|
+
}
|
|
62
|
+
const blocked = () => {
|
|
63
|
+
const id = session()
|
|
64
|
+
if (!id) return false
|
|
65
|
+
const info = data.session.get(id)
|
|
66
|
+
if (info?.parentID) return visibleForms().some((form) => form.metadata?.plugin !== "raaghav.plan-review")
|
|
67
|
+
const location = info?.location ?? context.location
|
|
68
|
+
if (data.session.permission.list(id)?.length) return true
|
|
69
|
+
if (data.session.family(id).some((sessionID) => data.session.permission.list(sessionID)?.length)) return true
|
|
70
|
+
return visibleForms().some((form) => form.metadata?.plugin !== "raaghav.plan-review")
|
|
41
71
|
}
|
|
42
72
|
const required = ["session.line.up", "session.line.down", "session.page.up", "session.page.down", "session.first", "session.last"]
|
|
43
73
|
const controller = navigation({
|
|
@@ -45,6 +75,7 @@ export function createSetup({ InputRenderable, TextareaRenderable, createElement
|
|
|
45
75
|
mode: keymap.mode,
|
|
46
76
|
promptFocused,
|
|
47
77
|
blocked,
|
|
78
|
+
planReviewForm,
|
|
48
79
|
ready: () => {
|
|
49
80
|
const commands = new Set(keymap.commands().map((command) => command.id))
|
|
50
81
|
if (required.every((id) => commands.has(id))) return true
|
|
@@ -52,9 +83,15 @@ export function createSetup({ InputRenderable, TextareaRenderable, createElement
|
|
|
52
83
|
return false
|
|
53
84
|
},
|
|
54
85
|
dispatch: (id) => keymap.dispatch(id),
|
|
55
|
-
changed:
|
|
86
|
+
changed: (active) => {
|
|
87
|
+
setNav(active)
|
|
88
|
+
syncCursor(active)
|
|
89
|
+
},
|
|
56
90
|
})
|
|
57
|
-
const cleanups = [() =>
|
|
91
|
+
const cleanups = [() => {
|
|
92
|
+
controller.leave()
|
|
93
|
+
syncCursor(false)
|
|
94
|
+
}]
|
|
58
95
|
let cleaned = false
|
|
59
96
|
const cleanup = () => {
|
|
60
97
|
if (cleaned) return
|
|
@@ -78,8 +115,10 @@ export function createSetup({ InputRenderable, TextareaRenderable, createElement
|
|
|
78
115
|
nav()
|
|
79
116
|
session()
|
|
80
117
|
blocked()
|
|
118
|
+
planReviewForm()
|
|
81
119
|
promptFocused()
|
|
82
120
|
controller.sync()
|
|
121
|
+
syncCursor()
|
|
83
122
|
})
|
|
84
123
|
})
|
|
85
124
|
cleanups.push(ui.slot({
|
package/src/navigation.mjs
CHANGED
|
@@ -6,6 +6,8 @@ export function navigation(context) {
|
|
|
6
6
|
let pendingG = false
|
|
7
7
|
|
|
8
8
|
const active = () => popMode !== undefined
|
|
9
|
+
const planReview = () => context.planReviewForm?.() === true
|
|
10
|
+
const navigationAllowed = () => !context.blocked?.() && (context.promptFocused() || planReview())
|
|
9
11
|
function leave(follow = false) {
|
|
10
12
|
if (!active()) return
|
|
11
13
|
const release = popMode
|
|
@@ -16,8 +18,8 @@ export function navigation(context) {
|
|
|
16
18
|
if (follow) context.dispatch("session.last")
|
|
17
19
|
}
|
|
18
20
|
function sync() {
|
|
19
|
-
if (active() && (context.session() !== sessionID ||
|
|
20
|
-
|| (context.mode.current() === MODE && !context.promptFocused()))) leave()
|
|
21
|
+
if (active() && (context.session() !== sessionID || !navigationAllowed()
|
|
22
|
+
|| (context.mode.current() === MODE && !context.promptFocused() && !planReview()))) leave()
|
|
21
23
|
}
|
|
22
24
|
function scroll(id) {
|
|
23
25
|
context.dispatch(id)
|
|
@@ -28,11 +30,12 @@ export function navigation(context) {
|
|
|
28
30
|
}
|
|
29
31
|
function key(event) {
|
|
30
32
|
sync()
|
|
31
|
-
if (!context.session() ||
|
|
33
|
+
if (!context.session() || !navigationAllowed()) return
|
|
32
34
|
// Dialogs, permission forms, autocomplete, and embedded terminals own their keys.
|
|
33
35
|
const mode = context.mode.current()
|
|
34
|
-
|
|
35
|
-
if (!
|
|
36
|
+
const isPlanReview = planReview()
|
|
37
|
+
if (mode !== "base" && mode !== MODE && !isPlanReview) return
|
|
38
|
+
if (!context.promptFocused() && !isPlanReview) {
|
|
36
39
|
if (active()) leave()
|
|
37
40
|
return
|
|
38
41
|
}
|
|
@@ -96,7 +99,7 @@ export function navigation(context) {
|
|
|
96
99
|
function paste(event) {
|
|
97
100
|
sync()
|
|
98
101
|
if (active() && context.mode.current() === MODE) {
|
|
99
|
-
if (!context.promptFocused()) leave()
|
|
102
|
+
if (!context.promptFocused() && !planReview()) leave()
|
|
100
103
|
else consume(event)
|
|
101
104
|
}
|
|
102
105
|
}
|