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 +62 -0
- package/README.md +16 -1
- package/package.json +9 -2
- package/src/builtins.ts +28 -0
- package/src/completion.ts +83 -0
- package/src/options.ts +36 -5
- package/src/text.ts +16 -0
- package/src/tui.tsx +446 -13
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.
|
|
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"
|
package/src/builtins.ts
ADDED
|
@@ -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
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
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
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
return
|
|
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
|
-
|
|
149
|
-
|
|
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}>
|
|
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
|
|