opencode-next-prompt 0.0.8 → 0.1.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/README.md +23 -16
- package/dist/debug.d.ts +10 -0
- package/dist/debug.js +36 -0
- package/dist/editor.d.ts +27 -0
- package/dist/editor.js +32 -0
- package/dist/index.d.ts +3 -6
- package/dist/index.js +287 -198
- package/dist/predict.d.ts +14 -7
- package/dist/predict.js +65 -51
- package/dist/state.d.ts +12 -1
- package/dist/state.js +30 -9
- package/dist/ui.d.ts +22 -7
- package/dist/ui.js +65 -16
- package/package.json +17 -11
- package/tui.js +4 -0
package/README.md
CHANGED
|
@@ -2,54 +2,61 @@
|
|
|
2
2
|
|
|
3
3
|
Predicts your next message after the assistant replies and shows it at the prompt — accept it with a keypress (like Claude Code's "next message suggestion").
|
|
4
4
|
|
|
5
|
+
Requires OpenCode **≥ 2.0** (V2 plugin API).
|
|
6
|
+
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
|
-
Add to `~/.config/opencode/
|
|
9
|
+
Add to `~/.config/opencode/cli.json`:
|
|
8
10
|
|
|
9
11
|
```json
|
|
10
12
|
{
|
|
11
|
-
"
|
|
13
|
+
"plugins": ["opencode-next-prompt"]
|
|
12
14
|
}
|
|
13
15
|
```
|
|
14
16
|
|
|
15
17
|
Restart opencode. The package and its dependencies are installed automatically.
|
|
16
18
|
|
|
17
|
-
> TUI plugins are declared in `tui.json
|
|
19
|
+
> TUI plugins are declared in `cli.json`'s `plugins` array (the successor of `tui.json`'s `plugin` array); the `plugin` array in `opencode.json` is for server plugins.
|
|
18
20
|
|
|
19
21
|
## Usage
|
|
20
22
|
|
|
21
23
|
- After each reply, a suggested next message appears at the input in gray
|
|
22
|
-
- Press the accept key (
|
|
24
|
+
- Press the accept key (**→** by default) while the input is empty to fill it; with text in the input, the key keeps its normal cursor behavior
|
|
23
25
|
- Typing hides the suggestion; clearing the input re-shows it
|
|
24
26
|
|
|
25
27
|
## Config
|
|
26
28
|
|
|
27
29
|
```json
|
|
28
30
|
{
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
"plugins": [
|
|
32
|
+
{
|
|
33
|
+
"package": "opencode-next-prompt",
|
|
34
|
+
"options": {
|
|
35
|
+
"acceptKey": "right",
|
|
36
|
+
"timeoutMs": 20000,
|
|
37
|
+
"model": "my-provider/fast-model",
|
|
38
|
+
"includeToolContext": false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
|
36
42
|
}
|
|
37
43
|
```
|
|
38
44
|
|
|
39
45
|
| Key | Default | Description |
|
|
40
46
|
|---|---|---|
|
|
41
|
-
| `acceptKey` | `
|
|
47
|
+
| `acceptKey` | `right` | Key to accept the suggestion (only effective while the input is empty) |
|
|
42
48
|
| `timeoutMs` | `20000` | Per-prediction timeout; the timed-out call is aborted and retried once |
|
|
43
|
-
| `model` | unset | Fast model for predictions as `provider/model`; a bare model id uses the session's provider. Defaults to the current session's model. Unknown providers/models are rejected with an error toast
|
|
49
|
+
| `model` | unset | Fast model for predictions as `provider/model`; a bare model id uses the session's provider. Defaults to the current session's model. Unknown providers/models are rejected with an error toast |
|
|
44
50
|
| `variant` | auto | Model variant (reasoning effort) used for predictions. By default the lowest variant the model supports is picked automatically (e.g. `low`); models without variants are left untouched. Set it explicitly (e.g. `"high"`) to override, or `"default"` to disable |
|
|
45
|
-
| `disableTools` | `true` | Denies all tools in the background predictor session (predictions never run code or search) |
|
|
46
51
|
| `includeToolContext` | `false` | Include summarized tool calls and outputs in the prediction context (more context, more tokens) |
|
|
47
52
|
|
|
53
|
+
The accept command is registered as `opencode-next-prompt.accept`, so it can be rebound in `cli.json`'s `keybinds`.
|
|
54
|
+
|
|
48
55
|
## How it works
|
|
49
56
|
|
|
50
|
-
After each reply, the plugin predicts your next input
|
|
57
|
+
After each reply, the plugin predicts your next input with a single tool-free text-generation call built from the recent conversation (the last few turns plus the original goal). No background session is created, no tools are ever available to the prediction, and the main conversation is never modified. The suggestion is written into the input placeholder, so what you see is exactly what gets accepted.
|
|
51
58
|
|
|
52
|
-
Each suggestion costs one extra model call
|
|
59
|
+
Each suggestion costs one extra model call. Failed or timed-out predictions are retried once. A suggestion disappears when you type, when you revert or compact the conversation, or when the session changes.
|
|
53
60
|
|
|
54
61
|
## License
|
|
55
62
|
|
package/dist/debug.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Where debug output lands while running inside the real TUI. */
|
|
2
|
+
export declare const DEBUG_LOG_FILE: string;
|
|
3
|
+
/**
|
|
4
|
+
* Debug logging is off by default. Enable with `NEXT_PROMPT_DEBUG=1` (or the
|
|
5
|
+
* globalThis flag used by tests).
|
|
6
|
+
*
|
|
7
|
+
* Output goes to a file: inside the TUI, console.error is captured by the
|
|
8
|
+
* host and never reaches stderr, so a file is the only reliable channel.
|
|
9
|
+
*/
|
|
10
|
+
export declare function debug(message: string, extra?: unknown): void;
|
package/dist/debug.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** Where debug output lands while running inside the real TUI. */
|
|
5
|
+
export const DEBUG_LOG_FILE = join(tmpdir(), "opencode-next-prompt.log");
|
|
6
|
+
function stringify(extra) {
|
|
7
|
+
if (extra === undefined)
|
|
8
|
+
return "";
|
|
9
|
+
if (typeof extra === "string")
|
|
10
|
+
return extra;
|
|
11
|
+
try {
|
|
12
|
+
return JSON.stringify(extra);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return String(extra);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Debug logging is off by default. Enable with `NEXT_PROMPT_DEBUG=1` (or the
|
|
20
|
+
* globalThis flag used by tests).
|
|
21
|
+
*
|
|
22
|
+
* Output goes to a file: inside the TUI, console.error is captured by the
|
|
23
|
+
* host and never reaches stderr, so a file is the only reliable channel.
|
|
24
|
+
*/
|
|
25
|
+
export function debug(message, extra) {
|
|
26
|
+
const flag = globalThis.NEXT_PROMPT_DEBUG || process.env.NEXT_PROMPT_DEBUG;
|
|
27
|
+
if (!flag)
|
|
28
|
+
return;
|
|
29
|
+
const detail = stringify(extra);
|
|
30
|
+
try {
|
|
31
|
+
appendFileSync(DEBUG_LOG_FILE, `${new Date().toISOString()} ${message}${detail ? ` ${detail}` : ""}\n`);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Never let diagnostics break the plugin.
|
|
35
|
+
}
|
|
36
|
+
}
|
package/dist/editor.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural (duck-typed) access to the TUI's focused prompt editor.
|
|
3
|
+
*
|
|
4
|
+
* `instanceof` cannot be used across the plugin/host boundary: the opencode
|
|
5
|
+
* binary bundles its own copy of @opentui/core, while the plugin resolves its
|
|
6
|
+
* own copy from node_modules, so class identity never matches. Instead we
|
|
7
|
+
* recognise editors by the members every edit-buffer renderable exposes.
|
|
8
|
+
*
|
|
9
|
+
* To tell the prompt's multi-line TextareaRenderable apart from the
|
|
10
|
+
* single-line InputRenderable (dialogs, forms — which must never receive the
|
|
11
|
+
* suggestion), we check for the `maxLength` accessor that only
|
|
12
|
+
* InputRenderable defines.
|
|
13
|
+
*/
|
|
14
|
+
export type TextareaLike = {
|
|
15
|
+
readonly plainText: string;
|
|
16
|
+
placeholder: string | null;
|
|
17
|
+
readonly isDestroyed: boolean;
|
|
18
|
+
insertText(text: string): void;
|
|
19
|
+
gotoBufferEnd(options?: {
|
|
20
|
+
select?: boolean;
|
|
21
|
+
}): void;
|
|
22
|
+
getLayoutNode(): {
|
|
23
|
+
markDirty(): void;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
/** Returns `editor` when it is a live, writable multi-line textarea; otherwise null. */
|
|
27
|
+
export declare function asPromptTextarea(editor: unknown): TextareaLike | null;
|
package/dist/editor.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural (duck-typed) access to the TUI's focused prompt editor.
|
|
3
|
+
*
|
|
4
|
+
* `instanceof` cannot be used across the plugin/host boundary: the opencode
|
|
5
|
+
* binary bundles its own copy of @opentui/core, while the plugin resolves its
|
|
6
|
+
* own copy from node_modules, so class identity never matches. Instead we
|
|
7
|
+
* recognise editors by the members every edit-buffer renderable exposes.
|
|
8
|
+
*
|
|
9
|
+
* To tell the prompt's multi-line TextareaRenderable apart from the
|
|
10
|
+
* single-line InputRenderable (dialogs, forms — which must never receive the
|
|
11
|
+
* suggestion), we check for the `maxLength` accessor that only
|
|
12
|
+
* InputRenderable defines.
|
|
13
|
+
*/
|
|
14
|
+
/** Returns `editor` when it is a live, writable multi-line textarea; otherwise null. */
|
|
15
|
+
export function asPromptTextarea(editor) {
|
|
16
|
+
if (!editor || typeof editor !== "object")
|
|
17
|
+
return null;
|
|
18
|
+
const candidate = editor;
|
|
19
|
+
if (candidate.isDestroyed)
|
|
20
|
+
return null;
|
|
21
|
+
if (typeof candidate.plainText !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
if (typeof candidate.insertText !== "function")
|
|
24
|
+
return null;
|
|
25
|
+
if (typeof candidate.gotoBufferEnd !== "function")
|
|
26
|
+
return null;
|
|
27
|
+
if (typeof candidate.getLayoutNode !== "function")
|
|
28
|
+
return null;
|
|
29
|
+
if ("maxLength" in candidate)
|
|
30
|
+
return null; // single-line InputRenderable
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
declare const
|
|
3
|
-
|
|
4
|
-
tui: TuiPlugin;
|
|
5
|
-
};
|
|
6
|
-
export default _default;
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
declare const opencodeNextPrompt: Plugin.Definition;
|
|
3
|
+
export default opencodeNextPrompt;
|
package/dist/index.js
CHANGED
|
@@ -1,227 +1,317 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
2
2
|
import { createSuggestionStore } from "./state.js";
|
|
3
3
|
import { createPredictor } from "./predict.js";
|
|
4
|
-
import {
|
|
4
|
+
import { createPlaceholderController } from "./ui.js";
|
|
5
|
+
import { asPromptTextarea } from "./editor.js";
|
|
6
|
+
import { debug } from "./debug.js";
|
|
5
7
|
const DEFAULT_OPTIONS = {
|
|
6
|
-
acceptKey: "
|
|
8
|
+
acceptKey: "right",
|
|
7
9
|
timeoutMs: 20_000,
|
|
8
|
-
disableTools: true,
|
|
9
10
|
includeToolContext: false,
|
|
10
11
|
};
|
|
11
12
|
const RETRY_DELAY_MS = 800;
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
13
|
+
const THINK_TAG = /<\/?think>[\s\S]*?<\/think>\s*/g;
|
|
14
|
+
const opencodeNextPrompt = Plugin.define({
|
|
15
|
+
id: "opencode-next-prompt",
|
|
16
|
+
setup(context) {
|
|
17
|
+
const options = { ...DEFAULT_OPTIONS, ...context.options };
|
|
18
|
+
const store = createSuggestionStore();
|
|
19
|
+
// The controller gates on the session the prompt is editing; the router
|
|
20
|
+
// always reports the focused session (per-tab footers cannot diverge
|
|
21
|
+
// from it), and predictions commit against the same source.
|
|
22
|
+
const placeholder = createPlaceholderController(context, store, () => currentSessionID(), options.acceptKey);
|
|
23
|
+
let disposed = false;
|
|
24
|
+
const currentSessionID = () => {
|
|
25
|
+
const route = context.ui.router.current();
|
|
26
|
+
if (route.type !== "session")
|
|
27
|
+
return undefined;
|
|
28
|
+
return route.sessionID;
|
|
29
|
+
};
|
|
30
|
+
const promptInputIsEmpty = () => {
|
|
31
|
+
const editor = asPromptTextarea(context.renderer.currentFocusedEditor);
|
|
32
|
+
return editor !== null && editor.plainText === "";
|
|
33
|
+
};
|
|
34
|
+
const conversationTurns = (sessionID) => {
|
|
35
|
+
const result = [];
|
|
36
|
+
for (const message of context.data.session.message.list(sessionID)) {
|
|
37
|
+
if (message.type === "user") {
|
|
38
|
+
const text = message.text.replace(THINK_TAG, "").trim();
|
|
39
|
+
if (text)
|
|
40
|
+
result.push({ id: message.id, role: "user", text });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (message.type !== "assistant")
|
|
44
|
+
continue;
|
|
45
|
+
const text = message.content
|
|
46
|
+
.map((part) => {
|
|
47
|
+
if (part.type === "text")
|
|
48
|
+
return part.text.replace(THINK_TAG, "");
|
|
49
|
+
return options.includeToolContext ? summarizeToolPart(part) : "";
|
|
50
|
+
})
|
|
51
|
+
.join("\n")
|
|
52
|
+
.trim();
|
|
53
|
+
if (text)
|
|
54
|
+
result.push({ id: message.id, role: "assistant", text });
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
const lastUserMessageID = (sessionID) => {
|
|
59
|
+
for (const message of context.data.session.message.list(sessionID).toReversed()) {
|
|
60
|
+
if (message.type === "user")
|
|
61
|
+
return message.id;
|
|
62
|
+
}
|
|
18
63
|
return undefined;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
64
|
+
};
|
|
65
|
+
let inFlightUserMessageID;
|
|
66
|
+
let completedUserMessageID;
|
|
67
|
+
let retriedUserMessageID;
|
|
68
|
+
let reportedModelIssue;
|
|
69
|
+
const predictor = createPredictor(context.client, {
|
|
70
|
+
timeoutMs: options.timeoutMs,
|
|
71
|
+
model: options.model,
|
|
72
|
+
});
|
|
73
|
+
const maybePredict = async () => {
|
|
74
|
+
if (disposed)
|
|
75
|
+
return;
|
|
76
|
+
const sessionID = currentSessionID();
|
|
77
|
+
if (!sessionID) {
|
|
78
|
+
debug("skip", "no session route");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const status = context.data.session.status(sessionID);
|
|
82
|
+
if (status !== "idle") {
|
|
83
|
+
debug("skip", { status });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const userMessageID = lastUserMessageID(sessionID);
|
|
87
|
+
if (!userMessageID) {
|
|
88
|
+
debug("skip", "no user message");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (userMessageID === completedUserMessageID || userMessageID === inFlightUserMessageID) {
|
|
92
|
+
debug("skip", "message already handled");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const session = context.data.session.get(sessionID);
|
|
96
|
+
const modelRef = resolveModelRef(options.model, session?.model, context);
|
|
97
|
+
const modelIssue = checkModelConfig(options.model, modelRef, context);
|
|
98
|
+
if (modelIssue) {
|
|
99
|
+
if (reportedModelIssue !== modelIssue) {
|
|
100
|
+
reportedModelIssue = modelIssue;
|
|
101
|
+
debug("prediction skipped", modelIssue);
|
|
102
|
+
context.ui.toast.show({ variant: "error", title: "next-prompt", message: modelIssue, duration: 8000 });
|
|
38
103
|
}
|
|
39
|
-
return
|
|
40
|
-
})
|
|
41
|
-
.join("\n")
|
|
42
|
-
.trim();
|
|
43
|
-
if (!text)
|
|
44
|
-
continue;
|
|
45
|
-
result.push({ id: message.id, role: message.role, text });
|
|
46
|
-
}
|
|
47
|
-
return result;
|
|
48
|
-
};
|
|
49
|
-
const lastUserMessageID = (sessionID) => {
|
|
50
|
-
for (const message of api.state.session.messages(sessionID).toReversed()) {
|
|
51
|
-
if (message.role === "user")
|
|
52
|
-
return message.id;
|
|
53
|
-
}
|
|
54
|
-
return undefined;
|
|
55
|
-
};
|
|
56
|
-
let inFlightUserMessageID;
|
|
57
|
-
let completedUserMessageID;
|
|
58
|
-
let retriedUserMessageID;
|
|
59
|
-
let reportedModelIssue;
|
|
60
|
-
const predictor = createPredictor(api.client, api.state.path.directory, {
|
|
61
|
-
timeoutMs: options.timeoutMs,
|
|
62
|
-
model: options.model,
|
|
63
|
-
disableTools: options.disableTools,
|
|
64
|
-
});
|
|
65
|
-
const maybePredict = async () => {
|
|
66
|
-
if (api.lifecycle.signal.aborted)
|
|
67
|
-
return;
|
|
68
|
-
const sessionID = currentSessionID();
|
|
69
|
-
if (!sessionID)
|
|
70
|
-
return;
|
|
71
|
-
if (api.state.session.status(sessionID)?.type !== "idle")
|
|
72
|
-
return;
|
|
73
|
-
const userMessageID = lastUserMessageID(sessionID);
|
|
74
|
-
if (!userMessageID)
|
|
75
|
-
return;
|
|
76
|
-
if (userMessageID === completedUserMessageID || userMessageID === inFlightUserMessageID)
|
|
77
|
-
return;
|
|
78
|
-
const session = api.state.session.get(sessionID);
|
|
79
|
-
const modelRef = resolveModelRef(options.model, session?.model, api.state.provider);
|
|
80
|
-
const modelIssue = checkModelConfig(options.model, modelRef, api.state.provider);
|
|
81
|
-
if (modelIssue) {
|
|
82
|
-
if (reportedModelIssue !== modelIssue) {
|
|
83
|
-
reportedModelIssue = modelIssue;
|
|
84
|
-
log(api, "prediction skipped", modelIssue);
|
|
85
|
-
api.ui.toast({ variant: "error", title: "next-prompt", message: modelIssue, duration: 8000 });
|
|
104
|
+
return;
|
|
86
105
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (turns.length === 0)
|
|
91
|
-
return;
|
|
92
|
-
const generation = store.beginPredict(sessionID);
|
|
93
|
-
if (generation === null)
|
|
94
|
-
return;
|
|
95
|
-
inFlightUserMessageID = userMessageID;
|
|
96
|
-
const variant = resolveVariant(options.variant, modelRef);
|
|
97
|
-
try {
|
|
98
|
-
const result = await predictor.predict(session?.model, turns, variant);
|
|
99
|
-
if (inFlightUserMessageID !== userMessageID)
|
|
106
|
+
const turns = conversationTurns(sessionID);
|
|
107
|
+
if (turns.length === 0) {
|
|
108
|
+
debug("skip", "no conversation turns");
|
|
100
109
|
return;
|
|
101
|
-
if (result.ok) {
|
|
102
|
-
completedUserMessageID = userMessageID;
|
|
103
|
-
store.commit(generation, sessionID, result.text);
|
|
104
110
|
}
|
|
105
|
-
|
|
111
|
+
const generation = store.beginPredict(sessionID);
|
|
112
|
+
if (generation === null) {
|
|
113
|
+
debug("skip", "store busy");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
inFlightUserMessageID = userMessageID;
|
|
117
|
+
const variant = resolveVariant(options.variant, modelRef);
|
|
118
|
+
try {
|
|
119
|
+
debug("predicting", { model: session?.model, variant });
|
|
120
|
+
const result = await predictor.predict(session?.model, turns, variant);
|
|
121
|
+
if (inFlightUserMessageID !== userMessageID)
|
|
122
|
+
return;
|
|
123
|
+
debug("predict result", { ok: result.ok, length: result.ok ? (result.text?.length ?? 0) : -1 });
|
|
124
|
+
if (result.ok) {
|
|
125
|
+
completedUserMessageID = userMessageID;
|
|
126
|
+
store.commit(generation, sessionID, result.text);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
store.discard();
|
|
130
|
+
scheduleRetry(userMessageID);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
debug("prediction failed", errorMessage(error));
|
|
135
|
+
if (inFlightUserMessageID !== userMessageID)
|
|
136
|
+
return;
|
|
106
137
|
store.discard();
|
|
107
138
|
scheduleRetry(userMessageID);
|
|
108
139
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
140
|
+
finally {
|
|
141
|
+
if (inFlightUserMessageID === userMessageID)
|
|
142
|
+
inFlightUserMessageID = undefined;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const scheduleRetry = (userMessageID) => {
|
|
146
|
+
if (retriedUserMessageID === userMessageID)
|
|
113
147
|
return;
|
|
148
|
+
retriedUserMessageID = userMessageID;
|
|
149
|
+
setTimeout(() => void maybePredict(), RETRY_DELAY_MS);
|
|
150
|
+
};
|
|
151
|
+
const invalidate = () => {
|
|
114
152
|
store.discard();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
inFlightUserMessageID = undefined;
|
|
154
|
+
completedUserMessageID = undefined;
|
|
155
|
+
retriedUserMessageID = undefined;
|
|
156
|
+
void predictor.dispose();
|
|
157
|
+
};
|
|
158
|
+
const accept = () => {
|
|
159
|
+
const current = store.read();
|
|
160
|
+
if (current.kind !== "ready")
|
|
161
|
+
return;
|
|
162
|
+
const editor = asPromptTextarea(context.renderer.currentFocusedEditor);
|
|
163
|
+
if (!editor)
|
|
164
|
+
return;
|
|
165
|
+
// Same insertion path the host uses for the `tui.prompt.append` event.
|
|
166
|
+
editor.insertText(current.text);
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
if (editor.isDestroyed)
|
|
169
|
+
return;
|
|
170
|
+
editor.getLayoutNode().markDirty();
|
|
171
|
+
editor.gotoBufferEnd();
|
|
172
|
+
context.renderer.requestRender();
|
|
173
|
+
}, 0);
|
|
174
|
+
};
|
|
175
|
+
const unsubscribers = [];
|
|
176
|
+
unsubscribers.push(context.data.on("session.status", (event) => {
|
|
177
|
+
if (event.data.sessionID !== currentSessionID())
|
|
178
|
+
return;
|
|
179
|
+
const type = event.data.status.type;
|
|
180
|
+
debug("session.status event", { type });
|
|
181
|
+
if (type === "busy") {
|
|
182
|
+
store.discard();
|
|
183
|
+
}
|
|
184
|
+
else if (type === "idle") {
|
|
185
|
+
if (store.read().kind === "idle")
|
|
186
|
+
void maybePredict();
|
|
187
|
+
}
|
|
188
|
+
}));
|
|
189
|
+
// V2 delivery guarantee: feature-plugins subscribe to execution events,
|
|
190
|
+
// while session.status appears not to reach clients at all. Mirrors the
|
|
191
|
+
// V1 busy/idle pair: started ≈ busy (user submitted), ended ≈ idle.
|
|
192
|
+
unsubscribers.push(context.data.on("session.execution.started", (event) => {
|
|
193
|
+
if (event.data.sessionID !== currentSessionID())
|
|
194
|
+
return;
|
|
195
|
+
debug("execution started");
|
|
151
196
|
store.discard();
|
|
197
|
+
}));
|
|
198
|
+
for (const type of ["session.execution.succeeded", "session.execution.interrupted"]) {
|
|
199
|
+
unsubscribers.push(context.data.on(type, (event) => {
|
|
200
|
+
if (event.data.sessionID !== currentSessionID())
|
|
201
|
+
return;
|
|
202
|
+
debug(type);
|
|
203
|
+
if (store.read().kind === "idle")
|
|
204
|
+
void maybePredict();
|
|
205
|
+
}));
|
|
152
206
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
207
|
+
// Diagnostic: what actually streams. Logs status/execution events plus a
|
|
208
|
+
// capped sample of distinct event types.
|
|
209
|
+
const seenEventTypes = new Set();
|
|
210
|
+
unsubscribers.push(context.data.listen(({ details }) => {
|
|
211
|
+
const type = details.type;
|
|
212
|
+
if (/^session\.(status|execution|idle|error)/.test(type)) {
|
|
213
|
+
debug("event", type);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (seenEventTypes.size < 40 && !seenEventTypes.has(type)) {
|
|
217
|
+
seenEventTypes.add(type);
|
|
218
|
+
debug("event(first-seen)", type);
|
|
219
|
+
}
|
|
220
|
+
}));
|
|
221
|
+
unsubscribers.push(context.data.on("tui.session.select", () => invalidate()));
|
|
222
|
+
// V1's message.removed / message.part.removed: history changed under us.
|
|
223
|
+
unsubscribers.push(context.data.on("session.revert.committed", (event) => {
|
|
224
|
+
if (event.data.sessionID === currentSessionID())
|
|
225
|
+
invalidate();
|
|
226
|
+
}));
|
|
227
|
+
unsubscribers.push(context.data.on("session.deleted", (event) => {
|
|
228
|
+
if (event.data.sessionID === currentSessionID())
|
|
229
|
+
invalidate();
|
|
230
|
+
}));
|
|
231
|
+
// The accept command itself; registered from the app slot render below
|
|
232
|
+
// (keymap layers need a live solid owner — see the slot comment).
|
|
233
|
+
const acceptLayer = () => ({
|
|
234
|
+
priority: 1000,
|
|
235
|
+
commands: [
|
|
236
|
+
{
|
|
237
|
+
id: "opencode-next-prompt.accept",
|
|
238
|
+
title: "Accept next-prompt suggestion",
|
|
239
|
+
group: "next-prompt",
|
|
240
|
+
enabled: () => store.read().kind === "ready",
|
|
241
|
+
bind: options.acceptKey,
|
|
242
|
+
run: () => {
|
|
243
|
+
debug("accept key", { kind: store.read().kind, empty: promptInputIsEmpty() });
|
|
244
|
+
if (promptInputIsEmpty())
|
|
245
|
+
accept();
|
|
246
|
+
// Keep the default key behavior (cursor movement), like V1's fallthrough.
|
|
247
|
+
return false;
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
placeholder.start();
|
|
253
|
+
// `context.keymap.layer` resolves through useContext, so it needs an
|
|
254
|
+
// active solid owner under the host's Keymap.Provider. Plugin setup runs
|
|
255
|
+
// outside the component tree (an async reconciliation, owner already
|
|
256
|
+
// dropped), where it would throw "Keymap.Provider is missing" — a slot
|
|
257
|
+
// render runs as a component inside the tree instead. The `app` slot is
|
|
258
|
+
// the mount the built-in /btw plugin uses for its layers: always mounted,
|
|
259
|
+
// exactly one instance, alive for the whole TUI session — so per-tab
|
|
260
|
+
// prompt footers can never duplicate the command registration, and
|
|
261
|
+
// plugin deactivation (disposeSlot → unmount) releases it via the
|
|
262
|
+
// component's own cleanup.
|
|
263
|
+
const disposeSlot = context.ui.slot({
|
|
264
|
+
append: "app",
|
|
265
|
+
render: () => {
|
|
266
|
+
context.keymap.layer(acceptLayer);
|
|
267
|
+
debug("keymap layer registered");
|
|
268
|
+
return null;
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
return () => {
|
|
272
|
+
disposed = true;
|
|
273
|
+
for (const off of unsubscribers)
|
|
274
|
+
off();
|
|
275
|
+
placeholder.dispose();
|
|
276
|
+
disposeSlot();
|
|
277
|
+
store.discard();
|
|
278
|
+
void predictor.dispose();
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
function resolveModelRef(spec, sessionModel, context) {
|
|
199
283
|
const slash = spec?.indexOf("/") ?? -1;
|
|
200
284
|
if (spec && slash > 0 && slash < spec.length - 1) {
|
|
201
285
|
const providerID = spec.slice(0, slash);
|
|
202
286
|
const modelID = spec.slice(slash + 1);
|
|
203
|
-
return { providerID, modelID, model: findModel(providerID, modelID
|
|
287
|
+
return { providerID, id: modelID, model: findModel(context, providerID, modelID) };
|
|
204
288
|
}
|
|
205
289
|
const providerID = sessionModel?.providerID;
|
|
206
290
|
const modelID = spec ?? sessionModel?.id;
|
|
207
291
|
if (!providerID || !modelID)
|
|
208
292
|
return undefined;
|
|
209
|
-
return { providerID, modelID, model: findModel(providerID, modelID
|
|
293
|
+
return { providerID, id: modelID, model: findModel(context, providerID, modelID) };
|
|
210
294
|
}
|
|
211
|
-
function findModel(providerID, modelID
|
|
212
|
-
return
|
|
295
|
+
function findModel(context, providerID, modelID) {
|
|
296
|
+
return context.data.location.model.list()?.find((item) => item.providerID === providerID && item.modelID === modelID);
|
|
213
297
|
}
|
|
214
|
-
function checkModelConfig(spec, ref,
|
|
298
|
+
function checkModelConfig(spec, ref, context) {
|
|
215
299
|
if (!spec || !ref)
|
|
216
300
|
return undefined;
|
|
217
|
-
const
|
|
218
|
-
if (!
|
|
301
|
+
const providers = context.data.location.provider.list();
|
|
302
|
+
if (providers && !providers.some((item) => item.id === ref.providerID)) {
|
|
219
303
|
const available = providers.map((item) => item.id).join(", ");
|
|
220
304
|
return `model config error: provider "${ref.providerID}" not found${available ? ` (available providers: ${available})` : ""}`;
|
|
221
305
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
306
|
+
const models = context.data.location.model.list();
|
|
307
|
+
if (models && !models.some((item) => item.providerID === ref.providerID && item.modelID === ref.id)) {
|
|
308
|
+
const available = models
|
|
309
|
+
.filter((item) => item.providerID === ref.providerID)
|
|
310
|
+
.map((item) => item.modelID)
|
|
311
|
+
.slice(0, 10)
|
|
312
|
+
.join(", ");
|
|
313
|
+
const count = models.filter((item) => item.providerID === ref.providerID).length;
|
|
314
|
+
return `model config error: model "${ref.id}" not found on provider "${ref.providerID}"${available ? ` (available models: ${available}${count > 10 ? ", ..." : ""})` : ""}`;
|
|
225
315
|
}
|
|
226
316
|
return undefined;
|
|
227
317
|
}
|
|
@@ -232,11 +322,9 @@ function resolveVariant(configVariant, ref) {
|
|
|
232
322
|
if (configVariant)
|
|
233
323
|
return configVariant;
|
|
234
324
|
const variants = ref?.model?.variants;
|
|
235
|
-
if (!variants)
|
|
236
|
-
return undefined;
|
|
237
|
-
const names = Object.keys(variants);
|
|
238
|
-
if (names.length === 0)
|
|
325
|
+
if (!variants || variants.length === 0)
|
|
239
326
|
return undefined;
|
|
327
|
+
const names = variants.map((item) => item.id);
|
|
240
328
|
const low = names.find((name) => name.toLowerCase() === "low");
|
|
241
329
|
if (low)
|
|
242
330
|
return low;
|
|
@@ -265,12 +353,13 @@ function errorMessage(error) {
|
|
|
265
353
|
}
|
|
266
354
|
const TOOL_SUMMARY_CHARS = 150;
|
|
267
355
|
function summarizeToolPart(part) {
|
|
268
|
-
|
|
356
|
+
const tool = part;
|
|
357
|
+
if (tool.type !== "tool" || !tool.state || tool.state.status === "running" || tool.state.status === "streaming")
|
|
269
358
|
return "";
|
|
270
|
-
const input = JSON.stringify(
|
|
271
|
-
const output =
|
|
359
|
+
const input = JSON.stringify(tool.state.input ?? {});
|
|
360
|
+
const output = tool.state.metadata?.output ?? "";
|
|
272
361
|
const inputText = input.length > 80 ? input.slice(0, 79) + "…" : input;
|
|
273
362
|
const outputText = output.length > TOOL_SUMMARY_CHARS ? output.slice(0, TOOL_SUMMARY_CHARS - 1) + "…" : output;
|
|
274
|
-
return `[tool:${
|
|
363
|
+
return `[tool:${tool.name ?? "?"}] ${inputText} → ${outputText || tool.state.status || ""}`;
|
|
275
364
|
}
|
|
276
|
-
export default
|
|
365
|
+
export default opencodeNextPrompt;
|
package/dist/predict.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ModelRef, OpenCodeClient } from "@opencode/client";
|
|
2
2
|
export type ConversationTurn = {
|
|
3
3
|
id: string;
|
|
4
4
|
role: "user" | "assistant";
|
|
@@ -7,7 +7,6 @@ export type ConversationTurn = {
|
|
|
7
7
|
export type PredictOptions = {
|
|
8
8
|
timeoutMs: number;
|
|
9
9
|
model?: string;
|
|
10
|
-
disableTools?: boolean;
|
|
11
10
|
};
|
|
12
11
|
export type PredictResult = {
|
|
13
12
|
ok: true;
|
|
@@ -16,10 +15,18 @@ export type PredictResult = {
|
|
|
16
15
|
ok: false;
|
|
17
16
|
};
|
|
18
17
|
export type Predictor = {
|
|
19
|
-
predict(model:
|
|
20
|
-
providerID: string;
|
|
21
|
-
id: string;
|
|
22
|
-
} | undefined, turns: ConversationTurn[], variant?: string): Promise<PredictResult>;
|
|
18
|
+
predict(model: ModelRef | undefined, turns: ConversationTurn[], variant?: string): Promise<PredictResult>;
|
|
23
19
|
dispose(): Promise<void>;
|
|
24
20
|
};
|
|
25
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Prediction runs through the tool-free `generate.text` endpoint: no session
|
|
23
|
+
* is created, so there is nothing to abort or clean up beyond the request
|
|
24
|
+
* itself, which is cancelled by an AbortSignal on timeout.
|
|
25
|
+
*
|
|
26
|
+
* Model fallback: some gates — notably OpenCode's free tier — reject
|
|
27
|
+
* explicitly selected models on this endpoint (503) while letting the server
|
|
28
|
+
* default through, so a failed call with a specified model is retried once
|
|
29
|
+
* without a model instead of failing the whole prediction. Both attempts
|
|
30
|
+
* share one timeout budget.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createPredictor(client: OpenCodeClient, options: PredictOptions): Predictor;
|
package/dist/predict.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { debug } from "./debug.js";
|
|
2
2
|
const PROMPT = `You are the user in a conversation with an AI coding assistant. The
|
|
3
3
|
assistant just finished replying to your latest message.
|
|
4
4
|
|
|
@@ -15,54 +15,81 @@ Conversation (oldest first):
|
|
|
15
15
|
const MAX_PREDICTION_CHARS = 170;
|
|
16
16
|
const MAX_TURNS = 8;
|
|
17
17
|
const MAX_TURN_CHARS = 800;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
};
|
|
18
|
+
/**
|
|
19
|
+
* Prediction runs through the tool-free `generate.text` endpoint: no session
|
|
20
|
+
* is created, so there is nothing to abort or clean up beyond the request
|
|
21
|
+
* itself, which is cancelled by an AbortSignal on timeout.
|
|
22
|
+
*
|
|
23
|
+
* Model fallback: some gates — notably OpenCode's free tier — reject
|
|
24
|
+
* explicitly selected models on this endpoint (503) while letting the server
|
|
25
|
+
* default through, so a failed call with a specified model is retried once
|
|
26
|
+
* without a model instead of failing the whole prediction. Both attempts
|
|
27
|
+
* share one timeout budget.
|
|
28
|
+
*/
|
|
29
|
+
export function createPredictor(client, options) {
|
|
31
30
|
return {
|
|
32
31
|
async predict(model, turns, variant) {
|
|
33
32
|
if (turns.length === 0)
|
|
34
33
|
return { ok: true, text: null };
|
|
35
|
-
const
|
|
34
|
+
const prompt = PROMPT + renderConversation(turns);
|
|
36
35
|
const ref = resolveModel(options.model, model);
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
36
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
37
|
+
const attempt = async (spec) => {
|
|
38
|
+
const remaining = deadline - Date.now();
|
|
39
|
+
if (remaining <= 0)
|
|
40
|
+
return { ok: false };
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), remaining);
|
|
43
|
+
try {
|
|
44
|
+
const result = await client.generate.text({ prompt, model: spec }, { signal: controller.signal });
|
|
45
|
+
return { ok: true, text: parsePrediction(result.text) };
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const spec = ref ? { providerID: ref.providerID, id: ref.id, ...(variant ? { variant } : {}) } : undefined;
|
|
52
|
+
try {
|
|
53
|
+
return await attempt(spec);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!spec) {
|
|
57
|
+
debug("generate failed", errorMessage(error));
|
|
58
|
+
return { ok: false };
|
|
59
|
+
}
|
|
60
|
+
debug("generate with explicit model failed, retrying with server default", errorMessage(error));
|
|
61
|
+
}
|
|
48
62
|
try {
|
|
49
|
-
|
|
50
|
-
if (result.error)
|
|
51
|
-
throw result.error;
|
|
52
|
-
text = parsePrediction(result.data.parts);
|
|
63
|
+
return await attempt(undefined);
|
|
53
64
|
}
|
|
54
65
|
catch (error) {
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
debug("generate failed", errorMessage(error));
|
|
67
|
+
return { ok: false };
|
|
57
68
|
}
|
|
58
|
-
await deleteSession();
|
|
59
|
-
return { ok: true, text };
|
|
60
69
|
},
|
|
61
70
|
async dispose() {
|
|
62
|
-
|
|
71
|
+
// Nothing to clean up: predictions do not own a session.
|
|
63
72
|
},
|
|
64
73
|
};
|
|
65
74
|
}
|
|
75
|
+
function errorMessage(error) {
|
|
76
|
+
if (error instanceof Error)
|
|
77
|
+
return error.message;
|
|
78
|
+
if (typeof error === "string")
|
|
79
|
+
return error;
|
|
80
|
+
if (error && typeof error === "object") {
|
|
81
|
+
const candidate = error.message;
|
|
82
|
+
if (typeof candidate === "string")
|
|
83
|
+
return candidate;
|
|
84
|
+
try {
|
|
85
|
+
return JSON.stringify(error);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return String(error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return String(error);
|
|
92
|
+
}
|
|
66
93
|
function resolveModel(spec, sessionModel) {
|
|
67
94
|
if (!spec)
|
|
68
95
|
return sessionModel;
|
|
@@ -86,12 +113,9 @@ function renderConversation(turns) {
|
|
|
86
113
|
function truncate(text, max) {
|
|
87
114
|
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
88
115
|
}
|
|
89
|
-
function parsePrediction(
|
|
90
|
-
const line =
|
|
91
|
-
.
|
|
92
|
-
.map((part) => part.text)
|
|
93
|
-
.join("")
|
|
94
|
-
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
|
116
|
+
function parsePrediction(raw) {
|
|
117
|
+
const line = raw
|
|
118
|
+
.replace(/<\/?think>[\s\S]*?<\/think>\s*/g, "")
|
|
95
119
|
.split("\n")
|
|
96
120
|
.map((item) => item.trim())
|
|
97
121
|
.find((item) => item.length > 0);
|
|
@@ -105,13 +129,3 @@ function parsePrediction(parts) {
|
|
|
105
129
|
return null;
|
|
106
130
|
return text.length > MAX_PREDICTION_CHARS ? text.slice(0, MAX_PREDICTION_CHARS - 3) + "..." : text;
|
|
107
131
|
}
|
|
108
|
-
function withTimeout(promise, ms, onTimeout) {
|
|
109
|
-
let timer;
|
|
110
|
-
const timeout = new Promise((_, reject) => {
|
|
111
|
-
timer = setTimeout(() => {
|
|
112
|
-
onTimeout();
|
|
113
|
-
reject(new Error(`prediction timed out after ${ms}ms`));
|
|
114
|
-
}, ms);
|
|
115
|
-
});
|
|
116
|
-
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
117
|
-
}
|
package/dist/state.d.ts
CHANGED
|
@@ -10,8 +10,19 @@ export type SuggestionState = {
|
|
|
10
10
|
text: string;
|
|
11
11
|
generation: number;
|
|
12
12
|
};
|
|
13
|
+
export type SuggestionListener = (state: SuggestionState) => void;
|
|
14
|
+
/**
|
|
15
|
+
* A tiny explicit pub/sub store.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately not a solid signal: a plugin loaded at runtime resolves solid's
|
|
18
|
+
* server build under bun's node conditions (createEffect would be a no-op)
|
|
19
|
+
* and the plugin's copy of solid is never the TUI's copy, so reactivity must
|
|
20
|
+
* not depend on it.
|
|
21
|
+
*/
|
|
13
22
|
export declare function createSuggestionStore(): {
|
|
14
|
-
read:
|
|
23
|
+
read(): SuggestionState;
|
|
24
|
+
/** Subscribes to state transitions; returns the unsubscribe function. */
|
|
25
|
+
subscribe(listener: SuggestionListener): () => void;
|
|
15
26
|
beginPredict(sessionID: string): number | null;
|
|
16
27
|
commit(generation: number, sessionID: string, text: string | null): void;
|
|
17
28
|
discard(): void;
|
package/dist/state.js
CHANGED
|
@@ -1,25 +1,46 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A tiny explicit pub/sub store.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a solid signal: a plugin loaded at runtime resolves solid's
|
|
5
|
+
* server build under bun's node conditions (createEffect would be a no-op)
|
|
6
|
+
* and the plugin's copy of solid is never the TUI's copy, so reactivity must
|
|
7
|
+
* not depend on it.
|
|
8
|
+
*/
|
|
2
9
|
export function createSuggestionStore() {
|
|
3
10
|
let generation = 0;
|
|
4
|
-
|
|
11
|
+
let state = { kind: "idle" };
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
const set = (next) => {
|
|
14
|
+
state = next;
|
|
15
|
+
for (const listener of [...listeners])
|
|
16
|
+
listener(state);
|
|
17
|
+
};
|
|
5
18
|
return {
|
|
6
|
-
read
|
|
19
|
+
read() {
|
|
20
|
+
return state;
|
|
21
|
+
},
|
|
22
|
+
/** Subscribes to state transitions; returns the unsubscribe function. */
|
|
23
|
+
subscribe(listener) {
|
|
24
|
+
listeners.add(listener);
|
|
25
|
+
return () => {
|
|
26
|
+
listeners.delete(listener);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
7
29
|
beginPredict(sessionID) {
|
|
8
|
-
if (state
|
|
30
|
+
if (state.kind !== "idle")
|
|
9
31
|
return null;
|
|
10
32
|
generation += 1;
|
|
11
|
-
|
|
33
|
+
set({ kind: "predicting", sessionID, generation });
|
|
12
34
|
return generation;
|
|
13
35
|
},
|
|
14
36
|
commit(generation, sessionID, text) {
|
|
15
|
-
|
|
16
|
-
if (current.kind !== "predicting" || current.generation !== generation)
|
|
37
|
+
if (state.kind !== "predicting" || state.generation !== generation)
|
|
17
38
|
return;
|
|
18
|
-
|
|
39
|
+
set(text ? { kind: "ready", sessionID, text, generation } : { kind: "idle" });
|
|
19
40
|
},
|
|
20
41
|
discard() {
|
|
21
42
|
generation += 1;
|
|
22
|
-
|
|
43
|
+
set({ kind: "idle" });
|
|
23
44
|
},
|
|
24
45
|
};
|
|
25
46
|
}
|
package/dist/ui.d.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import type { Context } from "@opencode/plugin/tui/context";
|
|
2
|
+
import type { SuggestionStore } from "./state.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Renders the suggestion into the prompt textarea's placeholder. There is no
|
|
5
|
+
* slot inside the input in V2, so the controller writes the placeholder
|
|
6
|
+
* directly: it re-applies on store changes, on the renderer's `focused_editor`
|
|
7
|
+
* event, and — because the TUI rewrites the placeholder from its own reactive
|
|
8
|
+
* memo (session switch, resize, shell change) — on every frame while a
|
|
9
|
+
* suggestion is live. `sessionID()` answers which session the prompt edits.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately free of solid-js: a plugin loaded at runtime resolves solid's
|
|
12
|
+
* server build (createEffect would be a no-op) and the plugin's copy of solid
|
|
13
|
+
* is not the TUI's copy anyway, so side effects here must not depend on it.
|
|
14
|
+
*
|
|
15
|
+
* The placeholder is only cleared when it is ours (it starts with the accept
|
|
16
|
+
* key marker), so OpenCode's own placeholder list is left untouched.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createPlaceholderController(context: Context, store: SuggestionStore, sessionID: () => string | undefined, acceptKey: string): {
|
|
19
|
+
apply: () => void;
|
|
20
|
+
start(): void;
|
|
21
|
+
dispose(): void;
|
|
7
22
|
};
|
|
8
|
-
export
|
|
23
|
+
export type PlaceholderController = ReturnType<typeof createPlaceholderController>;
|
package/dist/ui.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { asPromptTextarea } from "./editor.js";
|
|
2
|
+
import { debug } from "./debug.js";
|
|
3
3
|
const ARROW_KEYS = {
|
|
4
4
|
right: "→",
|
|
5
5
|
left: "←",
|
|
@@ -9,21 +9,70 @@ const ARROW_KEYS = {
|
|
|
9
9
|
function displayKey(key) {
|
|
10
10
|
return ARROW_KEYS[key] ?? key;
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Renders the suggestion into the prompt textarea's placeholder. There is no
|
|
14
|
+
* slot inside the input in V2, so the controller writes the placeholder
|
|
15
|
+
* directly: it re-applies on store changes, on the renderer's `focused_editor`
|
|
16
|
+
* event, and — because the TUI rewrites the placeholder from its own reactive
|
|
17
|
+
* memo (session switch, resize, shell change) — on every frame while a
|
|
18
|
+
* suggestion is live. `sessionID()` answers which session the prompt edits.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately free of solid-js: a plugin loaded at runtime resolves solid's
|
|
21
|
+
* server build (createEffect would be a no-op) and the plugin's copy of solid
|
|
22
|
+
* is not the TUI's copy anyway, so side effects here must not depend on it.
|
|
23
|
+
*
|
|
24
|
+
* The placeholder is only cleared when it is ours (it starts with the accept
|
|
25
|
+
* key marker), so OpenCode's own placeholder list is left untouched.
|
|
26
|
+
*/
|
|
27
|
+
export function createPlaceholderController(context, store, sessionID, acceptKey) {
|
|
28
|
+
const marker = ` [${displayKey(acceptKey)}] `;
|
|
13
29
|
const apply = () => {
|
|
14
|
-
const editor =
|
|
15
|
-
|
|
30
|
+
const editor = asPromptTextarea(context.renderer.currentFocusedEditor);
|
|
31
|
+
const current = store.read();
|
|
32
|
+
debug("apply", {
|
|
33
|
+
kind: current.kind,
|
|
34
|
+
want: current.kind === "ready" ? current.sessionID : undefined,
|
|
35
|
+
have: sessionID(),
|
|
36
|
+
editor: editor !== null,
|
|
37
|
+
});
|
|
38
|
+
if (!editor)
|
|
16
39
|
return;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
if (current.kind === "ready" && current.sessionID === sessionID()) {
|
|
41
|
+
const desired = marker + current.text;
|
|
42
|
+
// Write only on real changes: the setter is idempotent, which is what
|
|
43
|
+
// lets the frame reconciler below settle after restoring our marker.
|
|
44
|
+
if (editor.placeholder !== desired)
|
|
45
|
+
editor.placeholder = desired;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (typeof editor.placeholder === "string" && editor.placeholder.startsWith(marker)) {
|
|
49
|
+
editor.placeholder = null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
// TUI overwrites trigger a render, so reconciling on the next frame
|
|
53
|
+
// restores our marker within one paint; when idle there is nothing to
|
|
54
|
+
// defend, and frames skip the work entirely.
|
|
55
|
+
const onFrame = () => {
|
|
56
|
+
if (store.read().kind === "ready")
|
|
57
|
+
apply();
|
|
58
|
+
};
|
|
59
|
+
let unsubscribe = null;
|
|
60
|
+
return {
|
|
61
|
+
apply,
|
|
62
|
+
start() {
|
|
63
|
+
unsubscribe = store.subscribe(apply);
|
|
64
|
+
context.renderer.on("focused_editor", apply);
|
|
65
|
+
context.renderer.on("frame", onFrame);
|
|
66
|
+
},
|
|
67
|
+
dispose() {
|
|
68
|
+
unsubscribe?.();
|
|
69
|
+
unsubscribe = null;
|
|
70
|
+
context.renderer.off("focused_editor", apply);
|
|
71
|
+
context.renderer.off("frame", onFrame);
|
|
72
|
+
const editor = asPromptTextarea(context.renderer.currentFocusedEditor);
|
|
73
|
+
if (editor && typeof editor.placeholder === "string" && editor.placeholder.startsWith(marker)) {
|
|
74
|
+
editor.placeholder = null;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
22
77
|
};
|
|
23
|
-
createEffect(apply);
|
|
24
|
-
api.renderer.on("focused_editor", apply);
|
|
25
|
-
onCleanup(() => {
|
|
26
|
-
api.renderer.off("focused_editor", apply);
|
|
27
|
-
});
|
|
28
|
-
return null;
|
|
29
78
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-next-prompt",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Predicts your next message after the assistant replies in OpenCode
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Predicts your next message after the assistant replies in OpenCode — accept it with the right arrow",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./tui": {
|
|
@@ -11,27 +11,33 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"dist",
|
|
14
|
+
"tui.js",
|
|
14
15
|
"README.md",
|
|
15
16
|
"LICENSE"
|
|
16
17
|
],
|
|
17
18
|
"scripts": {
|
|
18
|
-
"build": "
|
|
19
|
-
"check": "
|
|
19
|
+
"build": "bun tools/build.ts",
|
|
20
|
+
"check": "tsc --noEmit",
|
|
21
|
+
"test": "bun test"
|
|
20
22
|
},
|
|
21
23
|
"engines": {
|
|
22
|
-
"opencode": ">=
|
|
24
|
+
"opencode": ">=2.0.0"
|
|
23
25
|
},
|
|
24
26
|
"dependencies": {
|
|
25
|
-
"@
|
|
26
|
-
"@opentui/solid": "0.4.5",
|
|
27
|
-
"solid-js": "1.9.12"
|
|
27
|
+
"@opencode/plugin": "^2.0.12"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@opencode
|
|
30
|
+
"@opencode/client": ">=2.0.12",
|
|
31
|
+
"@opentui/core": ">=0.5.11",
|
|
32
|
+
"@opentui/solid": ">=0.5.11",
|
|
33
|
+
"solid-js": ">=1.9.15"
|
|
31
34
|
},
|
|
32
35
|
"devDependencies": {
|
|
33
|
-
"@opencode
|
|
34
|
-
"
|
|
36
|
+
"@opencode/client": "^2.0.12",
|
|
37
|
+
"@opentui/core": "0.5.11",
|
|
38
|
+
"@opentui/solid": "0.5.11",
|
|
39
|
+
"@types/node": "^22.0.0",
|
|
40
|
+
"solid-js": "^1.9.15",
|
|
35
41
|
"typescript": "^5.5.0"
|
|
36
42
|
},
|
|
37
43
|
"keywords": [
|
package/tui.js
ADDED