opencode-ghost 1.0.4 → 1.0.6

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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.6] - 2026-10-08
9
+
10
+ ### Fixed
11
+
12
+ - **tui:** Restore retained suggestions when drafts are emptied
13
+
14
+ ## [1.0.5] - 2026-10-08
15
+
16
+ ### Fixed
17
+
18
+ - **tui:** Keep inline suggestions visible when the composer blurs
19
+
8
20
  ## [1.0.4] - 2026-10-08
9
21
 
10
22
  ### Fixed
@@ -209,6 +221,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
209
221
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
210
222
  `recentMessages` and `system`.
211
223
 
224
+ [1.0.6]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.5...v1.0.6
225
+ [1.0.5]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.4...v1.0.5
212
226
  [1.0.4]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.3...v1.0.4
213
227
  [1.0.3]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.2...v1.0.3
214
228
  [1.0.2]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.1...v1.0.2
package/README.md CHANGED
@@ -17,11 +17,14 @@ a dimmed inline placeholder in the empty composer with
17
17
  short transcript of the recent turns, and asks a model for one line that
18
18
  sounds like you.
19
19
  - **Inline empty-composer ghost.** The suggestion renders as the textarea's
20
- dimmed placeholder only while the normal composer is empty and focused in an
21
- idle session. `Tab` or `→` inserts it as editable text; Enter does not accept
20
+ dimmed placeholder while the normal composer is empty in an idle session,
21
+ including when unfocused. With the composer focused, `Tab` or `→` inserts it
22
+ as editable text; Enter does not accept
22
23
  or submit the ghost. Acceptance never replaces a draft or touches the clipboard.
23
- - **Clears as you type.** Text-editing keys and pasting dismiss the suggestion;
24
- navigation keys leave it intact. Left on an empty eligible composer returns
24
+ - **Hides as you type.** Typing and pasting abort pending generation and hide the
25
+ preview. Emptying the composer restores the same suggestion without another
26
+ API call; focus changes also reuse it. Navigation keys leave it intact.
27
+ Left on an empty eligible focused composer returns
25
28
  home, even when suggestions are off (`backOnEmptyLeft`).
26
29
  - **`/suggest` toggles it.** State is stored durably via the plugin's storage.
27
30
 
@@ -137,6 +140,9 @@ When enabling, a suggestion is generated immediately for the current session.
137
140
  - Accepting uses the composer editor's `insertText` directly, without submitting.
138
141
  - Typing, pasting, navigation, a new execution, disabling and unloading cancel
139
142
  pending generation. Duplicate completion events do not generate again.
143
+ - Retains only one suggestion, bounded by `maxChars`, for the visible session.
144
+ Acceptance, a new turn, session changes, disabling, model changes and unloading
145
+ discard it.
140
146
 
141
147
  ## Caveats
142
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Stateless inline next-prompt ghosts for the empty OpenCode V2 composer, accepted with Tab or Right.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/composer.ts CHANGED
@@ -33,12 +33,16 @@ export type EditorNode = {
33
33
  }
34
34
 
35
35
  export function isEmptyComposer(editor: TextareaRenderable | undefined, focused: unknown, mode: string, normal: boolean, selected: boolean): boolean {
36
+ return !!editor && editor === focused && editor.focused && isEligibleComposer(editor, mode, normal, selected)
37
+ }
38
+
39
+ export function isEligibleComposer(editor: TextareaRenderable | undefined, mode: string, normal: boolean, selected: boolean): boolean {
36
40
  if (!editor?.parent || editor.parent.isDestroyed) return false
37
41
  const siblings = editor.parent.getChildren().filter((node) =>
38
42
  !(node.id.startsWith("slot-layout-") && node.getChildren().length === 0),
39
43
  )
40
44
  if (siblings.length !== 2 || siblings[0] !== editor) return false
41
- return normal && mode === "base" && !selected && editor === focused && editor.focused && !editor.isDestroyed && editor.plainText === "" && !editor.hasSelection() && !editor.traits.capture?.includes("navigate") && editor.extmarks.getAll().length === 0
45
+ return normal && mode === "base" && !selected && !editor.isDestroyed && editor.plainText === "" && !editor.hasSelection() && !editor.traits.capture?.includes("navigate") && editor.extmarks.getAll().length === 0
42
46
  }
43
47
 
44
48
  export function findComposerEditor<T extends EditorNode>(marker: EditorNode | undefined, isEditor: (node: EditorNode) => node is T, isComposer: (editor: T) => boolean): T | undefined {
package/src/lifecycle.ts CHANGED
@@ -4,13 +4,13 @@ export function createLifecycle(delay: number, allowed: (sessionID: string) => b
4
4
  let disposed = false
5
5
  let warned = false
6
6
  const completed = new Set<string>()
7
- const cancel = () => {
7
+ const interrupt = () => {
8
8
  clearTimeout(timer)
9
9
  timer = undefined
10
10
  controller?.abort()
11
11
  controller = undefined
12
- clear()
13
12
  }
13
+ const cancel = () => { interrupt(); clear() }
14
14
  const run = async (sessionID: string) => {
15
15
  cancel()
16
16
  if (disposed || !allowed(sessionID)) return
@@ -23,6 +23,7 @@ export function createLifecycle(delay: number, allowed: (sessionID: string) => b
23
23
  }
24
24
  }
25
25
  return {
26
+ interrupt,
26
27
  cancel,
27
28
  run,
28
29
  succeeded(id: string, sessionID: string) {
package/src/tui.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Plugin } from "@opencode/plugin/tui"
3
3
  import { TextareaRenderable, InputRenderable, type BoxRenderable, type Renderable } from "@opentui/core"
4
4
  import { createEffect, createMemo, createRoot, createSignal, on, onCleanup } from "solid-js"
5
- import { composerAction, findComposerEditor, isEmptyComposer, keyName, observeComposerInput } from "./composer"
5
+ import { composerAction, findComposerEditor, isEligibleComposer, isEmptyComposer, keyName, observeComposerInput } from "./composer"
6
6
  import { createLifecycle } from "./lifecycle"
7
7
  import { acceptCommand, acceptShortcuts, ghostKeymapLayers } from "./keymap"
8
8
  import { parseSuggestCommand, removedOptionKeys, resolveOptions, suggestionModel } from "./options"
@@ -118,8 +118,8 @@ export default Plugin.define({
118
118
  const syncInline = () => {
119
119
  composerRevision()
120
120
  const current = suggestion()
121
- const editor = current ? getComposer(current.sessionID).editor : undefined
122
- inline.sync(editor, current?.text, ghostColor(), !!current && canGenerate(current.sessionID) && isComposerEmpty(current.sessionID))
121
+ const { editor, normal } = current ? getComposer(current.sessionID) : { editor: undefined, normal: false }
122
+ inline.sync(editor, current?.text, ghostColor(), !!current && canGenerate(current.sessionID) && isEligibleComposer(editor, context.keymap.mode.current(), normal, context.renderer.hasSelection))
123
123
  }
124
124
 
125
125
  const disposeRouteWatcher = createRoot((dispose) => {
@@ -154,7 +154,7 @@ export default Plugin.define({
154
154
  const key = keyName(event)
155
155
  const current = suggestion()
156
156
  return Boolean(current && inline.visible(getComposer(current.sessionID).editor, current.text) && acceptShortcuts(opts.acceptKeys, context.keymap.shortcuts).has(key) && canGenerate(current.sessionID) && isComposerEmpty(current.sessionID))
157
- }, cancelSuggestion)
157
+ }, () => { lifecycle.interrupt(); inline.clear() })
158
158
 
159
159
  const commandLayers = () => ghostKeymapLayers([
160
160
  ...opts.acceptKeys.map((key) => acceptCommand(key, () => {