opencode-ghost 0.2.3 → 0.2.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/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.5] - 2026-09-25
11
+
12
+ ### Changed
13
+
14
+ - Slash-command argument hints are dimmed at the same opacity as every other
15
+ ghost; they keep the theme accent hue but no longer appear brighter than the
16
+ next-message and history ghosts.
17
+ ## [0.2.4] - 2026-09-25
18
+
19
+ ### Fixed
20
+
21
+ - Long next-message suggestions now wrap onto extra rows and grow the prompt
22
+ box instead of being clipped at its right edge, so the whole suggestion stays
23
+ readable.
24
+ - Slash commands and skills only suggest their accepted argument flags (from
25
+ `argHints`); command and skill descriptions are no longer echoed as if they
26
+ were arguments. Commands without hints show no ghost.
27
+
28
+ ### Changed
29
+
30
+ - Ghost styling: slash-command ghosts use the theme accent color, and every
31
+ other ghost is dimmed and clipped to the prompt box so it cannot be mistaken
32
+ for typed text.
10
33
  ## [0.2.3] - 2026-09-25
11
34
 
12
35
  ### Removed
@@ -82,7 +105,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
82
105
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
83
106
  `recentMessages` and `system`.
84
107
 
85
- [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.3...HEAD
108
+ [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.5...HEAD
109
+ [0.2.5]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.4...v0.2.5
110
+ [0.2.4]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.3...v0.2.4
86
111
  [0.2.3]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.2...v0.2.3
87
112
  [0.2.2]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.1...v0.2.2
88
113
  [0.2.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.0...v0.2.1
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 opencode-ghost contributors
3
+ Copyright (c) 2026 Ozan Dogrultan
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -17,14 +17,21 @@ dimmed ghost text you accept with <kbd>Tab</kbd> (or <kbd>→</kbd>).
17
17
  default) for one line that sounds like you.
18
18
  - **Ghost text, `Tab` to accept.** The suggestion renders under the prompt;
19
19
  `Tab` or `→` drops it into the input so you can edit and send.
20
- - **Slash-command argument ghosts while typing.** After `/name `, argument
21
- option hints (from `argHints` or learned from builtins) render inside the
22
- prompt input box, right after the caret, like Claude Code's inline ghosts.
23
- Tab completes the next argument. While the command name is still being typed,
24
- opencode's own slash menu stays in charge. Purely local — no model calls.
20
+ - **Slash-command argument ghosts while typing.** After `/name `, accepted
21
+ argument options (from `argHints`) render inside the prompt input box, right
22
+ after the caret, as `[a | b | c]` like Claude Code. Running out of options or
23
+ naming a command with no configured hints shows no ghost — command and skill
24
+ descriptions are never suggested as if they were arguments. Tab completes the
25
+ next argument. While the command name is still being typed, opencode's own
26
+ slash menu stays in charge. Purely local — no model calls.
25
27
  - **History ghost while typing.** If the line you are typing is a prefix of a
26
28
  message you already sent in the same session, the rest of it is ghosted in
27
29
  the box as well; Tab pulls the full line back. Also purely local.
30
+ - **Distinct ghost styling.** Slash-command ghosts use the theme accent color;
31
+ every ghost is dimmed so it cannot be mistaken for typed text. A
32
+ next-message suggestion that is wider than the prompt wraps onto extra rows
33
+ and grows the prompt box (up to five rows) so it stays fully readable; other
34
+ ghosts are clipped to the box and never spill past it.
28
35
  - **`/suggest` toggles it.** State is stored in the plugin KV.
29
36
 
30
37
  ## Requirements
@@ -97,7 +104,7 @@ Pass an options object as the second element of the plugin tuple:
97
104
  | `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
98
105
  | `backOnEmptyLeft` | `boolean` | `false` | Return to the home screen with Left when the prompt is empty. |
99
106
  | `internalSessionMarkerDir` | `string` | unset | Optional extra crash-recovery tracking. A configuration-free sweep already reaps leftover hidden sessions, so this is only an additional safety net. |
100
- | `argHints` | `Record<string, string[]>` | `{}` | Argument option lists per slash command, shown as `[a \| b \| c]` after the command name and completed with Tab, e.g. `"/keepwarm": ["6h", "always", "off", "status"]`. |
107
+ | `argHints` | `Record<string, string[]>` | `{}` | Argument option lists per slash command, shown as `[a \| b \| c]` after a complete `/name ` and completed with Tab, e.g. `"/keepwarm": ["6h", "always", "off", "status"]`. Commands without an entry suggest no arguments. |
101
108
 
102
109
  ## Commands
103
110
 
@@ -148,6 +155,12 @@ bun test
148
155
  No build step: like other opencode TUI plugins, the package ships TSX source and
149
156
  opencode's runtime transpiles it.
150
157
 
158
+ ## Contributing
159
+
160
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the check suite and commit
161
+ conventions, [AGENTS.md](AGENTS.md) for the design rules, and
162
+ [CHANGELOG.md](CHANGELOG.md) for what changed.
163
+
151
164
  ## License
152
165
 
153
166
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Claude Code-style next-prompt suggestions in the opencode TUI, shown as dimmed ghost text and accepted with Tab. An opencode plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,15 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/ozandogrultan/opencode-ghost/issues"
15
15
  },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "tui",
20
+ "ghost-text",
21
+ "autosuggest",
22
+ "prompt",
23
+ "suggestion"
24
+ ],
16
25
  "exports": {
17
26
  "./tui": "./src/tui.tsx"
18
27
  },
@@ -25,25 +34,19 @@
25
34
  "publishConfig": {
26
35
  "access": "public"
27
36
  },
37
+ "packageManager": "bun@1.4.2",
38
+ "engines": {
39
+ "opencode": ">=1.18.0"
40
+ },
28
41
  "scripts": {
29
- "typecheck": "tsc --noEmit",
42
+ "prepare": "husky",
30
43
  "test": "bun test && bash tests/changelog.sh",
31
44
  "test:unit": "bun test",
32
45
  "test:changelog": "bash tests/changelog.sh",
46
+ "typecheck": "tsc --noEmit",
47
+ "lint:sh": "for script in install.sh tests/*.sh scripts/*.sh; do bash -n \"$script\" || exit; done",
33
48
  "prepublishOnly": "bun run test && bun run typecheck"
34
49
  },
35
- "engines": {
36
- "opencode": ">=1.18.0"
37
- },
38
- "keywords": [
39
- "opencode",
40
- "opencode-plugin",
41
- "tui",
42
- "ghost-text",
43
- "autosuggest",
44
- "prompt",
45
- "suggestion"
46
- ],
47
50
  "dependencies": {
48
51
  "solid-js": "^1.9.12"
49
52
  },
@@ -52,11 +55,14 @@
52
55
  "solid-js": "*"
53
56
  },
54
57
  "devDependencies": {
58
+ "@commitlint/cli": "^21.2.2",
59
+ "@commitlint/config-conventional": "^21.2.2",
55
60
  "@opencode-ai/plugin": "1.18.25",
56
61
  "@opentui/core": "0.4.5",
57
62
  "@opentui/keymap": "0.4.5",
58
63
  "@opentui/solid": "0.4.5",
59
64
  "@types/node": "24.12.2",
65
+ "husky": "^9.1.7",
60
66
  "typescript": "5.8.2"
61
67
  }
62
68
  }
package/src/completion.ts CHANGED
@@ -16,8 +16,6 @@ export type Completion = {
16
16
  insert?: string
17
17
  /** Argument options shown as `[a | b]`, filtered by the word in progress. */
18
18
  args?: string[]
19
- /** Non-insertable hint text shown when nothing can be completed. */
20
- hint?: string
21
19
  }
22
20
 
23
21
  /** Parse leading `/name args...` from the input; `args` is "" until a space exists. */
@@ -54,7 +52,6 @@ export function completeCommand(
54
52
  const hints = argHints[lower]
55
53
  return {
56
54
  args: hints ? [...hints] : undefined,
57
- hint: hints ? undefined : exact.description,
58
55
  insert: `/${exact.name} `,
59
56
  }
60
57
  }
@@ -66,18 +63,13 @@ export function completeCommand(
66
63
 
67
64
  // `/name args...`: complete the in-progress argument word against hints.
68
65
  const hintList = argHints[lower]
69
- if (hintList) {
70
- const matches = matchOptions(parsed.args, hintList)
71
- if (matches) {
72
- const completed = parsed.args + matches[0].slice(parsed.args.length)
73
- return {
74
- args: matches,
75
- ghost: matches[0].slice(parsed.args.length),
76
- insert: `/${parsed.name} ${completed} `,
77
- }
78
- }
79
- return undefined
66
+ if (!hintList) return undefined
67
+ const matches = matchOptions(parsed.args, hintList)
68
+ if (!matches) return undefined
69
+ const completed = parsed.args + matches[0].slice(parsed.args.length)
70
+ return {
71
+ args: matches,
72
+ ghost: matches[0].slice(parsed.args.length),
73
+ insert: `/${parsed.name} ${completed} `,
80
74
  }
81
- if (exact && parsed.args === "") return { hint: exact.description }
82
- return undefined
83
75
  }
package/src/text.ts CHANGED
@@ -33,6 +33,34 @@ export function normalize(raw: string, maxChars: number): string | undefined {
33
33
  return cleaned.slice(0, maxChars - 1).trimEnd() + "…"
34
34
  }
35
35
 
36
+ /**
37
+ * Number of visual rows `text` occupies when word-wrapped to `width` columns.
38
+ * Mirrors the greedy word wrap the prompt renderer uses, so a ghost can reserve
39
+ * exactly as many rows as it needs. Long words break at the column boundary.
40
+ */
41
+ export function wrapCount(text: string, width: number): number {
42
+ if (width <= 0) return 1
43
+ let lines = 1
44
+ let col = 0
45
+ for (const word of text.split(/\s+/).filter(Boolean)) {
46
+ let length = word.length
47
+ if (col > 0) {
48
+ if (col + 1 + length <= width) {
49
+ col += 1 + length
50
+ continue
51
+ }
52
+ lines += 1
53
+ col = 0
54
+ }
55
+ while (length > width) {
56
+ lines += 1
57
+ length -= width
58
+ }
59
+ col = length
60
+ }
61
+ return lines
62
+ }
63
+
36
64
  export function isEcho(suggestion: string, previous: string): boolean {
37
65
  const strip = (value: string) =>
38
66
  value
package/src/tui.tsx CHANGED
@@ -4,7 +4,7 @@ import { createSignal } from "solid-js"
4
4
  import * as fs from "node:fs"
5
5
  import * as path from "node:path"
6
6
  import { resolveOptions, type PromptSuggestOptions } from "./options"
7
- import { clip, isEcho, normalize, parseModel } from "./text"
7
+ import { clip, isEcho, normalize, parseModel, wrapCount } from "./text"
8
8
  import { BUILTIN_COMMANDS } from "./builtins"
9
9
  import { completeCommand, type CommandPool } from "./completion"
10
10
 
@@ -17,6 +17,13 @@ const ORPHAN_SWEEP_INTERVAL_MS = 30_000
17
17
  const ORPHAN_MIN_AGE_MS = 15_000
18
18
  const MAX_MESSAGE_CHARS = 800
19
19
  const MAX_TRANSCRIPT_CHARS = 6000
20
+ // The prompt editor grows to fit a wrapped next-message suggestion only up to
21
+ // this many rows; longer text is clipped. The host caps its editor at >= 6 rows
22
+ // by default, so 5 stays inside that ceiling.
23
+ const MAX_GHOST_LINES = 5
24
+ // Every ghost is drawn at this opacity so it always reads as a suggestion, not
25
+ // typed text. Slash-command ghosts keep the accent hue but are dimmed too.
26
+ const GHOST_OPACITY = 0.6
20
27
  const ENABLED_KEY = "ghost.enabled"
21
28
  const MODEL_KEY = "ghost.model"
22
29
 
@@ -341,10 +348,21 @@ const tui: TuiPlugin = async (api, rawOptions) => {
341
348
  // walking the layout tree). Uses the normal render pipeline, so it is visible
342
349
  // wherever the prompt is. When the editor or container cannot be located
343
350
  // (host layout change) completion falls back to the hint row.
351
+ type GhostColor = typeof api.theme.current.textMuted
344
352
  const [inBox, setInBox] = createSignal<
345
- { sessionID: string; x: number; y: number; text: string } | undefined
353
+ {
354
+ sessionID: string
355
+ x: number
356
+ y: number
357
+ text: string
358
+ width: number
359
+ height: number
360
+ wrap: boolean
361
+ color: GhostColor
362
+ opacity: number
363
+ } | undefined
346
364
  >()
347
- let slotNode: { screenX?: number; screenY?: number } | undefined
365
+ let slotNode: { screenX?: number; screenY?: number; width?: number } | undefined
348
366
  // Locate the prompt's editor renderable inside the layout tree.
349
367
  const findTextarea = (): unknown => {
350
368
  const root = (api.renderer as unknown as { root?: unknown }).root as
@@ -360,6 +378,8 @@ const tui: TuiPlugin = async (api, rawOptions) => {
360
378
  visualCursor?: { visualRow?: number; visualCol?: number }
361
379
  screenX?: number
362
380
  screenY?: number
381
+ width?: number
382
+ minHeight?: number
363
383
  getChildren?: () => unknown[]
364
384
  }
365
385
  | undefined
@@ -371,10 +391,38 @@ const tui: TuiPlugin = async (api, rawOptions) => {
371
391
  return undefined
372
392
  }
373
393
 
374
- const placeInBoxGhost = (sessionID: string, text: string): boolean => {
394
+ // A wrapped next-message suggestion is shown by growing the host editor's
395
+ // min height to the number of rows it needs, so the prompt box grows instead
396
+ // of clipping the text. The host sets minHeight once at creation (not
397
+ // reactively), so the value we set survives until we reset it.
398
+ let heightManaged: { minHeight?: number } | undefined
399
+ const resetEditorHeight = () => {
400
+ if (!heightManaged) return
401
+ try {
402
+ heightManaged.minHeight = 1
403
+ } catch {
404
+ // The editor may have been recreated or destroyed; best effort.
405
+ }
406
+ heightManaged = undefined
407
+ }
408
+
409
+ const placeInBoxGhost = (
410
+ sessionID: string,
411
+ text: string,
412
+ color: GhostColor,
413
+ opacity: number,
414
+ wrap: boolean,
415
+ ): boolean => {
416
+ resetEditorHeight()
375
417
  setInBox(undefined)
376
418
  const textarea = findTextarea() as
377
- | { screenX?: unknown; screenY?: unknown; visualCursor?: { visualRow?: number; visualCol?: number } }
419
+ | {
420
+ screenX?: unknown
421
+ screenY?: unknown
422
+ width?: unknown
423
+ minHeight?: number
424
+ visualCursor?: { visualRow?: number; visualCol?: number }
425
+ }
378
426
  | undefined
379
427
  if (
380
428
  !textarea ||
@@ -384,6 +432,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
384
432
  return false
385
433
  }
386
434
  const cursor = textarea.visualCursor ?? {}
435
+ const visualCol = typeof cursor.visualCol === "number" ? cursor.visualCol : 0
387
436
  // Overlay coordinates are relative to the slot container (it anchors the
388
437
  // absolute overlay), so subtract its screen origin from the editor's.
389
438
  if (
@@ -393,9 +442,30 @@ const tui: TuiPlugin = async (api, rawOptions) => {
393
442
  ) {
394
443
  return false
395
444
  }
396
- const x = textarea.screenX - slotNode.screenX + (typeof cursor.visualCol === "number" ? cursor.visualCol : 0)
445
+ const x = textarea.screenX - slotNode.screenX + visualCol
397
446
  const y = textarea.screenY - slotNode.screenY + (typeof cursor.visualRow === "number" ? cursor.visualRow : 0)
398
- setInBox({ sessionID, x, y, text })
447
+ // Keep the ghost inside the prompt box: clip it to the editor's remaining
448
+ // width so it never spills past the box's right edge.
449
+ const editorWidth = typeof textarea.width === "number" ? textarea.width : undefined
450
+ const width =
451
+ editorWidth !== undefined
452
+ ? editorWidth - visualCol
453
+ : typeof slotNode.width === "number"
454
+ ? slotNode.width - x
455
+ : undefined
456
+ if (width === undefined || width <= 0) return false
457
+ let height = 1
458
+ if (wrap) {
459
+ height = Math.max(1, Math.min(MAX_GHOST_LINES, wrapCount(text, width)))
460
+ try {
461
+ textarea.minHeight = height
462
+ heightManaged = textarea
463
+ } catch {
464
+ // If the host editor cannot be resized, fall back to a one-row clip.
465
+ height = 1
466
+ }
467
+ }
468
+ setInBox({ sessionID, x, y, text, width, height, wrap: wrap && height > 1, color, opacity })
399
469
  return true
400
470
  }
401
471
 
@@ -409,37 +479,52 @@ const tui: TuiPlugin = async (api, rawOptions) => {
409
479
  setCompletion(undefined)
410
480
  setInBox(undefined)
411
481
  }
482
+ resetEditorHeight()
412
483
  return
413
484
  }
414
485
  const input = promptRef?.current.input ?? ""
415
486
  if (poolFetchedAt && Date.now() - poolFetchedAt > POOL_TTL_MS) void refreshPool()
487
+ const isSlash = input.startsWith("/")
488
+ const startsEmpty = !input.trim()
416
489
  // `/name` with no space yet is opencode's native slash menu; the plugin
417
490
  // stays out of its way. Our slash completion only covers `/name args...`.
418
- const found = !input.startsWith("/")
491
+ const found = !isSlash
419
492
  ? historyCandidate(sessionID, input)
420
493
  : input.includes(" ")
421
494
  ? completeCommand(input, commandPool, opts.argHints)
422
495
  : undefined
423
496
  let display = ""
424
497
  let insert = ""
498
+ let wrap = false
499
+ // Slash-command completions are tinted like commands; every ghost is dimmed
500
+ // so it cannot be mistaken for typed text.
501
+ const color = isSlash ? api.theme.current.accent : api.theme.current.textMuted
502
+ const opacity = GHOST_OPACITY
425
503
  if (found) {
426
504
  const args = "args" in found ? found.args : undefined
427
- const hint = "hint" in found ? found.hint : undefined
428
505
  insert = found.insert ?? ""
429
- display = args && args.length > 0 ? `[${args.join(" | ")}]` : (found.ghost ?? hint ?? "")
430
- } else if (!input.trim()) {
506
+ display = args && args.length > 0 ? `[${args.join(" | ")}]` : (found.ghost ?? "")
507
+ } else if (startsEmpty) {
431
508
  // Empty prompt: the model's next-message suggestion is ghosted in the
432
- // box as well (the accept layer's `accept()` handles Tab for it).
509
+ // box as well (the accept layer's `accept()` handles Tab for it). It is
510
+ // the only ghost allowed to wrap and grow the prompt box, since it can be
511
+ // longer than the visible width and there is no typed text to disturb.
433
512
  const next = ghost()
434
- if (next && next.sessionID === sessionID) display = next.text
513
+ if (next && next.sessionID === sessionID) {
514
+ display = next.text
515
+ wrap = true
516
+ }
435
517
  }
436
- // Include the display in the key: a fresh suggestion for an unchanged
437
- // (empty) input must still be re-placed.
438
- const key = `${sessionID}\u0000${input}\u0000${display}`
518
+ // Include the display and renderer width in the key: a fresh suggestion for
519
+ // an unchanged (empty) input must be re-placed, and a resize must re-wrap.
520
+ const key = `${sessionID}\u0000${input}\u0000${display}\u0000${api.renderer.width}`
439
521
  if (completionKey === key) return
440
522
  completionKey = key
441
- const drawn = display ? placeInBoxGhost(sessionID, display) : false
442
- if (!drawn) setInBox(undefined)
523
+ const drawn = display ? placeInBoxGhost(sessionID, display, color, opacity, wrap) : false
524
+ if (!drawn) {
525
+ setInBox(undefined)
526
+ resetEditorHeight()
527
+ }
443
528
  setCompletion(
444
529
  found
445
530
  ? {
@@ -656,6 +741,9 @@ const tui: TuiPlugin = async (api, rawOptions) => {
656
741
  if (currentInput().trim()) return false
657
742
  promptRef.set({ input: current.text, parts: [] })
658
743
  promptRef.focus()
744
+ completionKey = ""
745
+ setInBox(undefined)
746
+ resetEditorHeight()
659
747
  showGhost(undefined)
660
748
  return true
661
749
  }
@@ -706,7 +794,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
706
794
  return (
707
795
  <box
708
796
  position="relative"
709
- ref={(ref: { screenX?: number; screenY?: number }) => {
797
+ ref={(ref: { screenX?: number; screenY?: number; width?: number }) => {
710
798
  slotNode = ref
711
799
  }}
712
800
  >
@@ -727,8 +815,23 @@ const tui: TuiPlugin = async (api, rawOptions) => {
727
815
  }}
728
816
  />
729
817
  {overlay() ? (
730
- <box position="absolute" left={overlay()!.x} top={overlay()!.y} zIndex={100}>
731
- <text fg={api.theme.current.textMuted}>{overlay()!.text}</text>
818
+ <box
819
+ position="absolute"
820
+ left={overlay()!.x}
821
+ top={overlay()!.y}
822
+ width={overlay()!.width}
823
+ height={overlay()!.height}
824
+ overflow="hidden"
825
+ opacity={overlay()!.opacity}
826
+ zIndex={100}
827
+ >
828
+ <text
829
+ fg={overlay()!.color}
830
+ wrapMode={overlay()!.wrap ? "word" : "none"}
831
+ truncate={!overlay()!.wrap}
832
+ >
833
+ {overlay()!.text}
834
+ </text>
732
835
  </box>
733
836
  ) : undefined}
734
837
  </box>
@@ -770,6 +873,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
770
873
  clearInterval(sweepTimer)
771
874
  clearInterval(pollTimer)
772
875
  setInBox(undefined)
876
+ resetEditorHeight()
773
877
  offIdle()
774
878
  offStatus()
775
879
  backLayer()