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 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",
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": ["opencode", "opencode-plugin", "tui", "vim", "navigation"],
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": ["tui.ts", "src/"],
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 promptFocused = () => {
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 blocked = () => {
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 false
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
- if (data.session.form.list("global", location)?.length) return true
36
- if (info?.parentID) return false
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
- return [...ids].some((sessionID) =>
39
- data.session.permission.list(sessionID)?.length || data.session.form.list(sessionID)?.length,
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: setNav,
86
+ changed: (active) => {
87
+ setNav(active)
88
+ syncCursor(active)
89
+ },
56
90
  })
57
- const cleanups = [() => controller.leave()]
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({
@@ -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 || context.blocked?.()
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() || context.blocked?.()) return
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
- if (mode !== "base" && mode !== MODE) return
35
- if (!context.promptFocused()) {
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
  }