opencode-ghost 0.1.0 → 0.2.0

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 ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-09-24
11
+
12
+ ### Added
13
+
14
+ - Slash-command autocomplete while typing: the remaining command name is
15
+ ghosted from configured commands, skills, and builtin TUI commands, with
16
+ known argument options shown as `[a | b]` hints and completed with `Tab`
17
+ (staged: command name, then arguments). Purely local — no model calls.
18
+ - Prompt-history ghost while typing: if the line being typed is a prefix of a
19
+ message already sent in the same session, the rest is ghosted and `Tab`
20
+ pulls the full line back.
21
+ - New option `argHints` (per-command argument option lists for the completion
22
+ hints).
23
+ ## [0.1.1] - 2026-09-24
24
+
25
+ ### Added
26
+
27
+ - Optional Left-arrow navigation back to the home screen from an empty prompt.
28
+ - Recovery of abandoned hidden suggestion sessions via a periodic sweep that
29
+ needs no configuration (an optional marker directory adds extra
30
+ crash-recovery tracking).
31
+
32
+ ### Fixed
33
+
34
+ - Prevent suggestions from repeating the user's previous message.
35
+ - Trigger suggestions from the current session-status idle event.
36
+ - Remove hidden suggestion sessions and their markers when generation fails:
37
+ deletion now passes the session directory, and a periodic sweep retries
38
+ abandoned sessions (including corrupt or zero-byte markers) instead of
39
+ leaving them behind for the life of the TUI process.
40
+ - Delete hidden suggestion sessions reliably: every hidden-session call is
41
+ scoped to the visible session's project directory, hidden sessions carry a
42
+ metadata tag that survives the server's automatic title generation, a
43
+ configuration-free sweep reaps leftovers by listing sessions, and shutdown
44
+ deletes in-flight generations. Marker-tracked sessions are removed even
45
+ after being retitled.
46
+ ## [0.1.0] - 2026-09-21
47
+
48
+ ### Added
49
+
50
+ - Next-prompt suggestions for the opencode TUI: after each turn a model reads
51
+ the recent conversation and writes one short message in the user's voice.
52
+ - The suggestion renders under the prompt and is accepted with `Tab` or `→`,
53
+ dropping it into the input to edit or send.
54
+ - `/suggest` toggles suggestions on and off; the state is persisted in the
55
+ plugin KV.
56
+ - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
57
+ `recentMessages` and `system`.
58
+
59
+ [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.0...HEAD
60
+ [0.2.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...v0.2.0
61
+ [0.1.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.0...v0.1.1
62
+ [0.1.0]: https://github.com/ozandogrultan/opencode-ghost/releases/tag/v0.1.0
package/README.md CHANGED
@@ -17,6 +17,14 @@ 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 autocomplete while typing.** When the input starts with `/`,
21
+ the remaining command name is ghosted from configured commands, skills, and
22
+ the builtin TUI commands; known argument options render as `[a | b]` hints.
23
+ Tab completes in stages (command name, then each argument). Purely local — no
24
+ model calls.
25
+ - **History ghost while typing.** If the line you are typing is a prefix of a
26
+ message you already sent in the same session, the rest of it is ghosted as
27
+ well, Tab pulls the full line back. Also purely local.
20
28
  - **`/suggest` toggles it.** State is stored in the plugin KV.
21
29
 
22
30
  ## Requirements
@@ -87,6 +95,9 @@ Pass an options object as the second element of the plugin tuple:
87
95
  | `idleDelayMs` | `number` | `500` | Debounce after a turn finishes before generating. |
88
96
  | `recentMessages` | `number` | `10` | How many recent messages feed the suggestion prompt. |
89
97
  | `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
98
+ | `backOnEmptyLeft` | `boolean` | `false` | Return to the home screen with Left when the prompt is empty. |
99
+ | `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"]`. |
90
101
 
91
102
  ## Commands
92
103
 
@@ -100,7 +111,11 @@ When enabling, a suggestion is generated immediately for the current session.
100
111
  session's recent messages.
101
112
  - Asks a model (your `small_model` by default) for one short next user message,
102
113
  in a throwaway hidden session with tools disabled; the session is deleted
103
- immediately afterwards.
114
+ immediately afterwards. Every hidden-session call is scoped to the current
115
+ session's project directory, hidden sessions carry a metadata tag that
116
+ survives the server's automatic title generation, and a periodic sweep (plus
117
+ shutdown cleanup) reaps any leftovers — including sessions orphaned by a
118
+ crash — with no configuration required.
104
119
  - Renders the result through the `session_prompt` host slot into the prompt's
105
120
  `hint` row, and accepts it into the input via the prompt ref.
106
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
@@ -18,12 +18,19 @@
18
18
  },
19
19
  "files": [
20
20
  "src",
21
+ "CHANGELOG.md",
21
22
  "LICENSE",
22
23
  "README.md"
23
24
  ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
24
28
  "scripts": {
25
29
  "typecheck": "tsc --noEmit",
26
- "test": "bun test"
30
+ "test": "bun test && bash tests/changelog.sh",
31
+ "test:unit": "bun test",
32
+ "test:changelog": "bash tests/changelog.sh",
33
+ "prepublishOnly": "bun run test && bun run typecheck"
27
34
  },
28
35
  "engines": {
29
36
  "opencode": ">=1.18.0"
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Builtin TUI slash commands. The plugin API does not expose the TUI's own
3
+ * command palette, so the completable subset we observed in the shipped binary
4
+ * is curated here. Names are fixed; descriptions are ours and only used for the
5
+ * completion display.
6
+ */
7
+ export type BuiltinCommand = {
8
+ name: string
9
+ description: string
10
+ }
11
+
12
+ export const BUILTIN_COMMANDS: BuiltinCommand[] = [
13
+ { name: "new", description: "New session" },
14
+ { name: "agent", description: "Cycle agent" },
15
+ { name: "model", description: "Choose model" },
16
+ { name: "mcp", description: "Toggle MCP servers" },
17
+ { name: "open", description: "Command palette" },
18
+ { name: "share", description: "Share session" },
19
+ { name: "unshare", description: "Unshare session" },
20
+ { name: "compact", description: "Compact session" },
21
+ { name: "export", description: "Export session" },
22
+ { name: "fork", description: "Fork session" },
23
+ { name: "undo", description: "Undo messages" },
24
+ { name: "redo", description: "Redo messages" },
25
+ { name: "terminal", description: "Toggle terminal" },
26
+ { name: "workspace", description: "Workspace controls" },
27
+ { name: "suggest", description: "Toggle prompt suggestions" },
28
+ ]
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pure slash-command completion: given the current prompt input, the pool of
3
+ * known command names (configured commands, skills, and builtins) and optional
4
+ * per-command argument hints, decide what ghost text to render and what Tab
5
+ * should insert.
6
+ */
7
+ export type CommandPool = {
8
+ readonly name: string
9
+ readonly description?: string
10
+ }
11
+
12
+ export type Completion = {
13
+ /** Dimmed text rendered after the input, e.g. `warm` for `/keep`+`warm`. */
14
+ ghost?: string
15
+ /** What Tab places into the input when accepting. */
16
+ insert?: string
17
+ /** Argument options shown as `[a | b]`, filtered by the word in progress. */
18
+ args?: string[]
19
+ /** Non-insertable hint text shown when nothing can be completed. */
20
+ hint?: string
21
+ }
22
+
23
+ /** Parse leading `/name args...` from the input; `args` is "" until a space exists. */
24
+ function parseSlash(input: string): { name: string; args: string; nameComplete: boolean } | undefined {
25
+ if (!input.startsWith("/")) return undefined
26
+ const line = input.split("\n")[0]
27
+ const space = line.indexOf(" ")
28
+ if (space === -1) return { name: line.slice(1), args: "", nameComplete: false }
29
+ return { name: line.slice(1, space), args: line.slice(space + 1), nameComplete: true }
30
+ }
31
+
32
+ /** Options that still match the word being typed, excluding an exact hit. */
33
+ function matchOptions(typed: string, options: readonly string[]): string[] | undefined {
34
+ const lower = typed.toLowerCase()
35
+ const matches = options.filter(
36
+ (option) => option.toLowerCase() !== lower && option.toLowerCase().startsWith(lower),
37
+ )
38
+ return matches.length > 0 ? matches : undefined
39
+ }
40
+
41
+ export function completeCommand(
42
+ input: string,
43
+ pool: readonly CommandPool[],
44
+ argHints: Readonly<Record<string, readonly string[]>> = {},
45
+ ): Completion | undefined {
46
+ const parsed = parseSlash(input)
47
+ if (!parsed) return undefined
48
+ const lower = parsed.name.toLowerCase()
49
+ const exact = pool.find((command) => command.name.toLowerCase() === lower)
50
+
51
+ // Still typing the command name (no space yet).
52
+ if (!parsed.nameComplete) {
53
+ if (exact) {
54
+ const hints = argHints[lower]
55
+ return {
56
+ args: hints ? [...hints] : undefined,
57
+ hint: hints ? undefined : exact.description,
58
+ insert: `/${exact.name} `,
59
+ }
60
+ }
61
+ const matches = pool.filter((command) => command.name.toLowerCase().startsWith(lower))
62
+ if (matches.length === 0) return undefined
63
+ const next = matches[0]
64
+ return { ghost: next.name.slice(parsed.name.length), insert: `/${next.name} ` }
65
+ }
66
+
67
+ // `/name args...`: complete the in-progress argument word against hints.
68
+ 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
80
+ }
81
+ if (exact && parsed.args === "") return { hint: exact.description }
82
+ return undefined
83
+ }
package/src/options.ts CHANGED
@@ -2,29 +2,47 @@ export type PromptSuggestOptions = {
2
2
  enabled?: boolean
3
3
  model?: string
4
4
  acceptKeys?: string[]
5
+ backOnEmptyLeft?: boolean
6
+ internalSessionMarkerDir?: string
5
7
  maxChars?: number
6
8
  idleDelayMs?: number
7
9
  recentMessages?: number
8
10
  system?: string
11
+ argHints?: Record<string, string[]>
9
12
  }
10
13
 
11
14
  export type ResolvedOptions = {
12
15
  enabled: boolean
13
16
  model: string | undefined
14
17
  acceptKeys: string[]
18
+ backOnEmptyLeft: boolean
19
+ internalSessionMarkerDir: string | undefined
15
20
  maxChars: number
16
21
  idleDelayMs: number
17
22
  recentMessages: number
18
23
  system: string
24
+ argHints: Record<string, readonly string[]>
19
25
  }
20
26
 
21
27
  export const DEFAULT_SYSTEM = [
22
28
  "You write the next message that the USER of a coding agent would send.",
23
- "It must sound like the user, not the assistant: short and direct.",
24
- "Never restate or summarize the assistant's reply.",
25
- 'If the assistant proposed a next step, a brief affirmation such as "Yes." or "Go ahead." is best.',
26
- "If the assistant asked a question, answer it briefly.",
27
- "Output one line, plain text only: no markdown, no quotes, no backticks, no preamble.",
29
+ "Write what the user says AFTER the assistant's latest reply.",
30
+ "",
31
+ "Rules:",
32
+ "- Move the conversation forward. Never repeat, quote, or paraphrase the user's own previous message.",
33
+ "- Sound like the user: short and direct, one line, plain text only.",
34
+ '- If the assistant proposed a next step, a brief affirmation such as "Yes." or "Go ahead." is best.',
35
+ "- If the assistant asked a question, answer it briefly.",
36
+ "- No markdown, no quotes, no backticks, no preamble.",
37
+ "",
38
+ "Examples:",
39
+ "Assistant: I ran the tests - 13 passed, 0 failed.",
40
+ "Next user message: great, thanks",
41
+ "Assistant: That changes the hook contract. Want me to proceed?",
42
+ "Next user message: yes, go ahead",
43
+ "Assistant: Which model should the small tasks use?",
44
+ "Next user message: the fast free one",
45
+ "",
28
46
  "If no reply makes sense, output exactly: NONE",
29
47
  ].join("\n")
30
48
 
@@ -48,6 +66,11 @@ export function resolveOptions(options: PromptSuggestOptions | undefined): Resol
48
66
  Array.isArray(input.acceptKeys) && input.acceptKeys.length > 0
49
67
  ? input.acceptKeys
50
68
  : [...DEFAULTS.acceptKeys],
69
+ backOnEmptyLeft: input.backOnEmptyLeft ?? false,
70
+ internalSessionMarkerDir:
71
+ typeof input.internalSessionMarkerDir === "string" && input.internalSessionMarkerDir.trim()
72
+ ? input.internalSessionMarkerDir.trim()
73
+ : undefined,
51
74
  maxChars: typeof maxChars === "number" && maxChars > 0 ? maxChars : DEFAULTS.maxChars,
52
75
  idleDelayMs:
53
76
  typeof idleDelayMs === "number" && idleDelayMs >= 0 ? idleDelayMs : DEFAULTS.idleDelayMs,
@@ -56,5 +79,13 @@ export function resolveOptions(options: PromptSuggestOptions | undefined): Resol
56
79
  ? recentMessages
57
80
  : DEFAULTS.recentMessages,
58
81
  system: typeof input.system === "string" && input.system.trim() ? input.system : DEFAULT_SYSTEM,
82
+ argHints:
83
+ input.argHints && typeof input.argHints === "object" && !Array.isArray(input.argHints)
84
+ ? Object.fromEntries(
85
+ Object.entries(input.argHints)
86
+ .filter(([, value]) => Array.isArray(value) && value.length > 0)
87
+ .map(([key, value]) => [key.toLowerCase(), [...value]]),
88
+ )
89
+ : {},
59
90
  }
60
91
  }
package/src/text.ts CHANGED
@@ -32,3 +32,19 @@ export function normalize(raw: string, maxChars: number): string | undefined {
32
32
  if (cleaned.length <= maxChars) return cleaned
33
33
  return cleaned.slice(0, maxChars - 1).trimEnd() + "…"
34
34
  }
35
+
36
+ export function isEcho(suggestion: string, previous: string): boolean {
37
+ const strip = (value: string) =>
38
+ value
39
+ .replace(/\s+/g, " ")
40
+ .trim()
41
+ .toLowerCase()
42
+ .replace(/[.!?,;:]+$/, "")
43
+ const next = strip(suggestion)
44
+ const last = strip(previous)
45
+ if (!next || !last) return false
46
+ if (next === last) return true
47
+ if (next.length >= 12 && last.includes(next)) return true
48
+ if (last.length >= 12 && next.includes(last)) return true
49
+ return false
50
+ }
package/src/tui.tsx CHANGED
@@ -1,10 +1,20 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPlugin, TuiPromptRef } from "@opencode-ai/plugin/tui"
3
3
  import { createSignal } from "solid-js"
4
+ import * as fs from "node:fs"
5
+ import * as path from "node:path"
4
6
  import { resolveOptions, type PromptSuggestOptions } from "./options"
5
- import { clip, normalize, parseModel } from "./text"
7
+ import { clip, isEcho, normalize, parseModel } from "./text"
8
+ import { BUILTIN_COMMANDS } from "./builtins"
9
+ import { completeCommand, type CommandPool } from "./completion"
6
10
 
7
11
  const HIDDEN_TITLE = "ghost-hidden"
12
+ // Survives the server's automatic title generation, which renames the hidden
13
+ // session after the first prompt and would otherwise make it invisible to a
14
+ // title-based sweep.
15
+ const GHOST_METADATA = { ghost: "opencode-ghost" } as const
16
+ const ORPHAN_SWEEP_INTERVAL_MS = 30_000
17
+ const ORPHAN_MIN_AGE_MS = 15_000
8
18
  const MAX_MESSAGE_CHARS = 800
9
19
  const MAX_TRANSCRIPT_CHARS = 6000
10
20
  const ENABLED_KEY = "ghost.enabled"
@@ -30,9 +40,230 @@ const HIDDEN_TOOLS = {
30
40
  lsp: false,
31
41
  }
32
42
 
43
+ function markPending(dir: string, pending: string) {
44
+ try {
45
+ fs.mkdirSync(dir, { recursive: true })
46
+ fs.writeFileSync(pending, "")
47
+ } catch {
48
+ // best effort
49
+ }
50
+ }
51
+
52
+ function markSession(dir: string, pending: string, id: string, directory: string | undefined) {
53
+ try {
54
+ fs.writeFileSync(
55
+ path.join(dir, `id-${id}`),
56
+ JSON.stringify(directory ? { directory, pid: process.pid } : { pid: process.pid }),
57
+ )
58
+ fs.rmSync(pending, { force: true })
59
+ } catch {
60
+ // best effort
61
+ }
62
+ }
63
+
64
+ function unmarkSession(dir: string, pending: string, id: string | undefined, deleted: boolean) {
65
+ try {
66
+ fs.rmSync(pending, { force: true })
67
+ } catch {
68
+ // best effort
69
+ }
70
+ if (!id || !deleted) return
71
+ try {
72
+ fs.rmSync(path.join(dir, `id-${id}`), { force: true })
73
+ } catch {
74
+ // best effort
75
+ }
76
+ }
77
+
78
+ function isGhostSession(session: { title?: unknown; metadata?: unknown }): boolean {
79
+ if ((session as { title?: unknown }).title === HIDDEN_TITLE) return true
80
+ const metadata = (session as { metadata?: Record<string, unknown> }).metadata
81
+ return (
82
+ typeof metadata === "object" &&
83
+ metadata !== null &&
84
+ (metadata as Record<string, unknown>).ghost === GHOST_METADATA.ghost
85
+ )
86
+ }
87
+
88
+ function processIsRunning(pid: number): boolean {
89
+ try {
90
+ process.kill(pid, 0)
91
+ return true
92
+ } catch (error) {
93
+ return (error as NodeJS.ErrnoException).code !== "ESRCH"
94
+ }
95
+ }
96
+
97
+ function isNotFound(error: unknown): boolean {
98
+ const candidate = error as { status?: number; response?: { status?: number }; message?: string }
99
+ const status = candidate?.status ?? candidate?.response?.status
100
+ if (status === 404) return true
101
+ return typeof candidate?.message === "string" && /not found/i.test(candidate.message)
102
+ }
103
+
33
104
  const tui: TuiPlugin = async (api, rawOptions) => {
34
105
  const opts = resolveOptions(rawOptions as PromptSuggestOptions | undefined)
106
+ // In-flight hidden sessions (id -> directory). A Map so dispose and the
107
+ // sweep can scope get/delete calls even while a generation is running.
108
+ const activeHidden = new Map<string, string | undefined>()
109
+
110
+ // Directory of the visible session the suggestion belongs to, falling back
111
+ // to the TUI working directory. Every hidden-session call is scoped with it:
112
+ // the server resolves sessions per directory, and an unscoped call is the
113
+ // historical reason failed generations left sessions behind.
114
+ const resolveParentDirectory = (visibleSessionID: string): string | undefined => {
115
+ try {
116
+ const local = api.state.session.get(visibleSessionID) as { directory?: unknown } | undefined
117
+ if (typeof local?.directory === "string" && local.directory) return local.directory
118
+ } catch {
119
+ // fall through to the cwd
120
+ }
121
+ try {
122
+ const cwd = (api.state as { path?: { directory?: unknown } }).path?.directory
123
+ if (typeof cwd === "string" && cwd) return cwd
124
+ } catch {
125
+ // unavailable in some test harnesses; callers fall back to the
126
+ // server-reported directory instead.
127
+ }
128
+ return undefined
129
+ }
130
+
131
+ const markerPaths = () => {
132
+ const dir = opts.internalSessionMarkerDir
133
+ if (!dir) return [] as { entry: string; marker: string; ageMs: number }[]
134
+ let names: string[]
135
+ try {
136
+ names = fs.readdirSync(dir).filter((entry) => /^id-ses_[\w-]+$/.test(entry))
137
+ } catch {
138
+ return [] as { entry: string; marker: string; ageMs: number }[]
139
+ }
140
+ const now = Date.now()
141
+ return names.map((entry) => {
142
+ const marker = path.join(dir, entry)
143
+ let ageMs = Number.POSITIVE_INFINITY
144
+ try {
145
+ ageMs = now - fs.statSync(marker).mtimeMs
146
+ } catch {
147
+ // Unreadable mtime: treat as old so it gets swept.
148
+ }
149
+ return { entry, marker, ageMs }
150
+ })
151
+ }
152
+
153
+ // Deletes a ghost session and reports whether it is confirmed gone. A
154
+ // `directory` is passed whenever known: the server scopes deletion by it, and
155
+ // omitting it is why failed generations left sessions behind. A non-true
156
+ // delete response is re-checked by existence rather than assumed to have
157
+ // failed, so a session already removed (or removed concurrently) still counts
158
+ // as gone and its marker is not kept forever.
159
+ const removeHiddenSession = async (sessionID: string, directory?: string): Promise<boolean> => {
160
+ const target = directory ? { sessionID, directory } : { sessionID }
161
+ try {
162
+ const result = await api.client.session.delete(target)
163
+ if (result.data === true) return true
164
+ } catch (error) {
165
+ // Only a definitive "not found" means it is gone; anything else (server
166
+ // down, permissions) must keep the marker so a later sweep retries.
167
+ return isNotFound(error)
168
+ }
169
+ try {
170
+ await api.client.session.get(target)
171
+ return false
172
+ } catch (error) {
173
+ return isNotFound(error)
174
+ }
175
+ }
176
+
177
+ // Reaps abandoned ghost sessions in this process and from crashed ones. Two
178
+ // phases:
179
+ //
180
+ // - Marker files (when `internalSessionMarkerDir` is configured) are
181
+ // authoritative proof this plugin created the session, so they are deleted
182
+ // regardless of title: the server auto-titles the hidden session after the
183
+ // first prompt, and the old title check let every retitled orphan live
184
+ // forever. It deliberately does not skip markers by owner pid anymore: a
185
+ // failed delete in a still-running TUI left its own markers behind forever,
186
+ // because the old startup-only recovery treated a live pid as
187
+ // "not orphaned". Fresh markers (younger than ORPHAN_MIN_AGE_MS) whose
188
+ // owner is still running are left alone so an in-flight generation in
189
+ // another process is not swept mid-write.
190
+ // - A list-based sweep needs no configuration and catches everything the
191
+ // markers miss (default setup, older versions, deleted marker dirs). Only
192
+ // sessions still identifiable as ghost (title or metadata tag) and quiet
193
+ // past the grace window are deleted, and never in-flight generations of
194
+ // this process.
195
+ const sweepOrphans = async () => {
196
+ for (const { entry, marker, ageMs } of markerPaths()) {
197
+ const sessionID = entry.slice(3)
198
+ if (activeHidden.has(sessionID)) continue
199
+ let directory: string | undefined
200
+ let pid: number | undefined
201
+ try {
202
+ const parsed = JSON.parse(fs.readFileSync(marker, "utf8")) as {
203
+ directory?: string
204
+ pid?: number
205
+ }
206
+ if (typeof parsed?.directory === "string" && parsed.directory) directory = parsed.directory
207
+ if (Number.isInteger(parsed?.pid) && (parsed.pid as number) > 0) pid = parsed.pid as number
208
+ } catch {
209
+ // Corrupt/empty marker: no directory or pid; still attempt cleanup.
210
+ }
211
+ // A fresh marker whose owner is still running may be an in-flight
212
+ // generation in another TUI. A dead owner, or one quiet past the grace
213
+ // window, is an orphan.
214
+ if (pid !== undefined && ageMs < ORPHAN_MIN_AGE_MS && processIsRunning(pid)) continue
215
+
216
+ if (await removeHiddenSession(sessionID, directory)) {
217
+ try {
218
+ fs.rmSync(marker, { force: true })
219
+ } catch {
220
+ // best effort
221
+ }
222
+ }
223
+ }
224
+
225
+ try {
226
+ const listed = await api.client.session.list()
227
+ const sessions = (listed as { data?: unknown }).data
228
+ if (!Array.isArray(sessions)) return
229
+ const now = Date.now()
230
+ for (const session of sessions) {
231
+ const candidate = session as {
232
+ id?: unknown
233
+ title?: unknown
234
+ metadata?: unknown
235
+ directory?: unknown
236
+ time?: { created?: unknown; updated?: unknown }
237
+ }
238
+ if (typeof candidate.id !== "string" || !candidate.id) continue
239
+ if (activeHidden.has(candidate.id)) continue
240
+ if (!isGhostSession(candidate)) continue
241
+ const updated =
242
+ typeof candidate.time?.updated === "number"
243
+ ? candidate.time.updated
244
+ : typeof candidate.time?.created === "number"
245
+ ? candidate.time.created
246
+ : undefined
247
+ if (updated !== undefined && now - updated < ORPHAN_MIN_AGE_MS) continue
248
+ const directory =
249
+ typeof candidate.directory === "string" && candidate.directory
250
+ ? candidate.directory
251
+ : undefined
252
+ await removeHiddenSession(candidate.id, directory)
253
+ }
254
+ } catch {
255
+ // Listing is best effort (server down, older SDK): markers and the
256
+ // generation finally-block still cover the common cases.
257
+ }
258
+ }
259
+ void sweepOrphans()
260
+ const sweepTimer = setInterval(() => void sweepOrphans(), ORPHAN_SWEEP_INTERVAL_MS)
261
+ sweepTimer.unref?.()
262
+
35
263
  const [ghost, setGhost] = createSignal<{ sessionID: string; text: string } | undefined>()
264
+ const [completion, setCompletion] = createSignal<
265
+ { sessionID: string; ghost?: string; insert?: string; args?: string[]; hint?: string } | undefined
266
+ >()
36
267
  let promptRef: TuiPromptRef | undefined
37
268
  let timer: ReturnType<typeof setTimeout> | undefined
38
269
  let generating = false
@@ -47,6 +278,96 @@ const tui: TuiPlugin = async (api, rawOptions) => {
47
278
 
48
279
  const currentInput = () => promptRef?.current.input ?? ""
49
280
 
281
+ // Completable commands: configured commands/skills from the server layered
282
+ // under the builtin TUI slash commands. One config read, refreshed lazily; no
283
+ // model calls are involved anywhere in completion.
284
+ let commandPool: CommandPool[] = BUILTIN_COMMANDS
285
+ let poolFetching = false
286
+ let poolFetchedAt = 0
287
+ const refreshPool = async () => {
288
+ if (poolFetching) return
289
+ poolFetching = true
290
+ try {
291
+ const result = await api.client.command.list()
292
+ const rows = (result as { data?: unknown }).data
293
+ if (Array.isArray(rows)) {
294
+ const configured: CommandPool[] = rows
295
+ .map((row) => row as { name?: unknown; description?: unknown })
296
+ .filter((row) => typeof row.name === "string" && row.name.length > 0)
297
+ .map((row) => ({
298
+ name: row.name as string,
299
+ description: typeof row.description === "string" ? row.description : undefined,
300
+ }))
301
+ const seen = new Set(configured.map((command) => `${command.name.toLowerCase()}`))
302
+ const merged = [
303
+ ...configured,
304
+ ...BUILTIN_COMMANDS.filter((command) => !seen.has(command.name.toLowerCase())),
305
+ ]
306
+ commandPool = merged.length > 0 ? merged : BUILTIN_COMMANDS
307
+ poolFetchedAt = Date.now()
308
+ }
309
+ } catch {
310
+ // Keep the current pool; the poll loop retries on the next TTL window.
311
+ } finally {
312
+ poolFetching = false
313
+ }
314
+ }
315
+ void refreshPool()
316
+
317
+ // History ghost while typing: if the input is a prefix of a message the user
318
+ // already sent in this session, offer the rest of it. No model involved.
319
+ const historyCandidate = (sessionID: string, input: string) => {
320
+ const wants = input.replace(/\s+/g, " ").trimEnd()
321
+ if (wants.length < 2 || /\n/.test(input)) return undefined
322
+ const collapsed = wants.toLowerCase()
323
+ const messages = api.state.session.messages(sessionID).filter((m) => m.role === "user")
324
+ for (let i = messages.length - 1; i >= 0; i--) {
325
+ const text = api.state
326
+ .part(messages[i].id)
327
+ .filter((part) => part.type === "text")
328
+ .map((part) => (part.type === "text" ? part.text : ""))
329
+ .join(" ")
330
+ .replace(/\s+/g, " ")
331
+ .trim()
332
+ if (text.length <= collapsed.length) continue
333
+ if (!text.toLowerCase().startsWith(collapsed)) continue
334
+ return { ghost: text.slice(collapsed.length), insert: text }
335
+ }
336
+ return undefined
337
+ }
338
+
339
+ const POLL_INTERVAL_MS = 100
340
+ const POOL_TTL_MS = 60_000
341
+ let completionKey = ""
342
+ const pollTimer = setInterval(() => {
343
+ const sessionID = currentSessionID()
344
+ if (!sessionID) {
345
+ if (completion()) setCompletion(undefined)
346
+ return
347
+ }
348
+ const input = promptRef?.current.input ?? ""
349
+ if (poolFetchedAt && Date.now() - poolFetchedAt > POOL_TTL_MS) void refreshPool()
350
+ const key = `${sessionID}\u0000${input}`
351
+ const found = input.startsWith("/")
352
+ ? completeCommand(input, commandPool, opts.argHints)
353
+ : historyCandidate(sessionID, input)
354
+ if (completionKey === `${key}\u0000${found?.insert ?? ""}`) return
355
+ completionKey = `${key}\u0000${found?.insert ?? ""}`
356
+ setCompletion(
357
+ found
358
+ ? {
359
+ sessionID,
360
+ ghost: found.ghost,
361
+ insert: found.insert,
362
+ args: "args" in found ? found.args : undefined,
363
+ hint: "hint" in found ? found.hint : undefined,
364
+ }
365
+ : undefined,
366
+ )
367
+ setAcceptVisible(Boolean(found?.insert) || Boolean(ghost()))
368
+ }, POLL_INTERVAL_MS)
369
+ pollTimer.unref?.()
370
+
50
371
  const isEnabled = () => {
51
372
  try {
52
373
  return api.kv.get<boolean>(ENABLED_KEY, opts.enabled) !== false
@@ -96,17 +417,49 @@ const tui: TuiPlugin = async (api, rawOptions) => {
96
417
  return `${lines.join("\n").slice(-MAX_TRANSCRIPT_CHARS)}\n\nTask: write the user's next message.`
97
418
  }
98
419
 
420
+ const lastUserText = (sessionID: string) => {
421
+ const message = api.state.session.messages(sessionID).filter((m) => m.role === "user").at(-1)
422
+ if (!message) return ""
423
+ return api.state
424
+ .part(message.id)
425
+ .filter((part) => part.type === "text")
426
+ .map((part) => (part.type === "text" ? part.text : ""))
427
+ .join(" ")
428
+ }
429
+
99
430
  const generate = async (sessionID: string): Promise<string | undefined> => {
100
431
  const transcript = buildTranscript(sessionID)
101
432
  if (!transcript) return undefined
102
433
 
103
- const created = await api.client.session.create({ title: HIDDEN_TITLE })
104
- const tempID = created.data?.id
105
- if (!tempID) return undefined
434
+ const markerDir = opts.internalSessionMarkerDir
435
+ const pending = markerDir ? path.join(markerDir, "pending") : undefined
436
+ if (markerDir && pending) markPending(markerDir, pending)
106
437
 
438
+ let tempID: string | undefined
439
+ let createdDirectory: string | undefined
440
+ // Scope every hidden-session call to the visible session's project: the
441
+ // server resolves sessions per directory, and unscoped calls left orphans
442
+ // behind whenever the default differed.
443
+ const parentDirectory = resolveParentDirectory(sessionID)
107
444
  try {
445
+ const created = await api.client.session.create({
446
+ title: HIDDEN_TITLE,
447
+ metadata: { ...GHOST_METADATA },
448
+ ...(parentDirectory ? { directory: parentDirectory } : {}),
449
+ })
450
+ tempID = created.data?.id
451
+ if (!tempID) return undefined
452
+ createdDirectory =
453
+ typeof created.data?.directory === "string" && created.data.directory
454
+ ? created.data.directory
455
+ : parentDirectory
456
+ activeHidden.set(tempID, createdDirectory)
457
+ if (markerDir && pending)
458
+ markSession(markerDir, pending, tempID, createdDirectory ?? parentDirectory ?? "")
459
+
108
460
  const result = await api.client.session.prompt({
109
461
  sessionID: tempID,
462
+ ...(createdDirectory ? { directory: createdDirectory } : {}),
110
463
  system: opts.system,
111
464
  tools: { ...HIDDEN_TOOLS },
112
465
  model: resolveModel(),
@@ -117,9 +470,21 @@ const tui: TuiPlugin = async (api, rawOptions) => {
117
470
  .filter((part) => part.type === "text")
118
471
  .map((part) => (part.type === "text" ? part.text : ""))
119
472
  .join("\n")
120
- return normalize(raw, opts.maxChars)
473
+ const suggestion = normalize(raw, opts.maxChars)
474
+ if (!suggestion) return undefined
475
+ const previous = lastUserText(sessionID)
476
+ if (previous && isEcho(suggestion, previous)) return undefined
477
+ return suggestion
121
478
  } finally {
122
- await api.client.session.delete({ sessionID: tempID }).catch(() => undefined)
479
+ let deleted = false
480
+ if (tempID) {
481
+ activeHidden.delete(tempID)
482
+ // Pass the directory and retry-based confirmation so a failed
483
+ // generation still removes its hidden session. If the session truly
484
+ // cannot be removed yet, the marker is kept and sweepOrphans retries.
485
+ deleted = await removeHiddenSession(tempID, createdDirectory)
486
+ }
487
+ if (markerDir && pending) unmarkSession(markerDir, pending, tempID, deleted)
123
488
  }
124
489
  }
125
490
 
@@ -130,11 +495,11 @@ const tui: TuiPlugin = async (api, rawOptions) => {
130
495
  priority: 100,
131
496
  bindings: opts.acceptKeys.map((key) => ({
132
497
  key,
133
- desc: "Accept suggested prompt",
498
+ desc: "Accept suggested prompt or completion",
134
499
  preventDefault: true,
135
500
  cmd: () => {
136
- accept()
137
- return true
501
+ if (completionAccept()) return true
502
+ return accept()
138
503
  },
139
504
  })),
140
505
  })
@@ -145,12 +510,30 @@ const tui: TuiPlugin = async (api, rawOptions) => {
145
510
  acceptLayer = undefined
146
511
  }
147
512
 
148
- const showGhost = (value: { sessionID: string; text: string } | undefined) => {
149
- setGhost(value)
513
+ let acceptVisible = false
514
+ const setAcceptVisible = (value: boolean) => {
515
+ if (acceptVisible === value) return
516
+ acceptVisible = value
150
517
  if (value) enableAccept()
151
518
  else disableAccept()
152
519
  }
153
520
 
521
+ const showGhost = (value: { sessionID: string; text: string } | undefined) => {
522
+ setGhost(value)
523
+ setAcceptVisible(Boolean(value) || Boolean(completion()?.insert))
524
+ }
525
+
526
+ const completionAccept = () => {
527
+ const current = completion()
528
+ if (!current || currentSessionID() !== current.sessionID) return false
529
+ if (!promptRef || !current.insert) return false
530
+ completionKey = ""
531
+ promptRef.set({ input: current.insert, parts: [] })
532
+ promptRef.focus()
533
+ setCompletion(undefined)
534
+ return true
535
+ }
536
+
154
537
  const run = async (sessionID: string) => {
155
538
  if (generating) return
156
539
  if (!isEnabled()) return
@@ -197,6 +580,35 @@ const tui: TuiPlugin = async (api, rawOptions) => {
197
580
  schedule(id)
198
581
  })
199
582
 
583
+ // session.idle is deprecated upstream; the live signal is session.status.
584
+ const offStatus = api.event.on("session.status", (event) => {
585
+ const props = event.properties
586
+ if (props?.status?.type !== "idle") return
587
+ if (!props.sessionID) return
588
+ schedule(props.sessionID)
589
+ })
590
+
591
+ const backLayer = api.keymap.registerLayer({
592
+ mode: "base",
593
+ priority: 40,
594
+ bindings: [
595
+ {
596
+ key: "left",
597
+ desc: "Return to the home screen when the prompt is empty",
598
+ preventDefault: false,
599
+ fallthrough: true,
600
+ cmd: () => {
601
+ if (!opts.backOnEmptyLeft) return false
602
+ if (api.route.current.name !== "session") return false
603
+ if (!promptRef) return false
604
+ if (currentInput().trim().length > 0) return false
605
+ api.route.navigate("home")
606
+ return true
607
+ },
608
+ },
609
+ ],
610
+ })
611
+
200
612
  api.slots.register({
201
613
  order: 60,
202
614
  slots: {
@@ -205,6 +617,13 @@ const tui: TuiPlugin = async (api, rawOptions) => {
205
617
  const current = ghost()
206
618
  return current && current.sessionID === props.session_id ? current.text : ""
207
619
  }
620
+ const completionText = () => {
621
+ const current = completion()
622
+ if (!current || current.sessionID !== props.session_id) return ""
623
+ if (current.args && current.args.length > 0) return `[${current.args.join(" | ")}]`
624
+ if (current.hint) return current.hint
625
+ return current.ghost ?? ""
626
+ }
208
627
  return (
209
628
  <api.ui.Prompt
210
629
  sessionID={props.session_id}
@@ -212,9 +631,11 @@ const tui: TuiPlugin = async (api, rawOptions) => {
212
631
  disabled={props.disabled}
213
632
  showPlaceholder={false}
214
633
  hint={
215
- suggestion() ? (
634
+ suggestion() || completionText() ? (
216
635
  <box marginLeft={1}>
217
- <text fg={api.theme.current.textMuted}>{suggestion()}</text>
636
+ <text fg={api.theme.current.textMuted}>
637
+ {completionText() || suggestion()}
638
+ </text>
218
639
  </box>
219
640
  ) : undefined
220
641
  }
@@ -263,8 +684,20 @@ const tui: TuiPlugin = async (api, rawOptions) => {
263
684
 
264
685
  api.lifecycle.onDispose(() => {
265
686
  if (timer) clearTimeout(timer)
687
+ clearInterval(sweepTimer)
688
+ clearInterval(pollTimer)
266
689
  offIdle()
690
+ offStatus()
691
+ backLayer()
267
692
  disableAccept()
693
+ // A generation in flight at shutdown would otherwise leak its hidden
694
+ // session: the finally-block still runs for the awaiting caller, but if
695
+ // the host drops pending work, fire one last scoped delete per session.
696
+ // The periodic sweep (markers + list) retries anything this misses.
697
+ for (const [sessionID, directory] of activeHidden) {
698
+ void removeHiddenSession(sessionID, directory).catch(() => {})
699
+ }
700
+ activeHidden.clear()
268
701
  })
269
702
  }
270
703