opencode-next-prompt 0.0.7 → 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 +27 -11
- 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 +348 -128
- package/dist/predict.d.ts +21 -6
- package/dist/predict.js +93 -67
- 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,45 +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
|
-
|
|
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
|
+
]
|
|
33
42
|
}
|
|
34
43
|
```
|
|
35
44
|
|
|
36
45
|
| Key | Default | Description |
|
|
37
46
|
|---|---|---|
|
|
38
|
-
| `acceptKey` | `
|
|
39
|
-
| `timeoutMs` | `20000` | Per-prediction timeout; the
|
|
47
|
+
| `acceptKey` | `right` | Key to accept the suggestion (only effective while the input is empty) |
|
|
48
|
+
| `timeoutMs` | `20000` | Per-prediction timeout; the timed-out call is aborted and retried once |
|
|
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 |
|
|
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 |
|
|
51
|
+
| `includeToolContext` | `false` | Include summarized tool calls and outputs in the prediction context (more context, more tokens) |
|
|
52
|
+
|
|
53
|
+
The accept command is registered as `opencode-next-prompt.accept`, so it can be rebound in `cli.json`'s `keybinds`.
|
|
40
54
|
|
|
41
55
|
## How it works
|
|
42
56
|
|
|
43
|
-
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.
|
|
58
|
+
|
|
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.
|
|
44
60
|
|
|
45
61
|
## License
|
|
46
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,145 +1,365 @@
|
|
|
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,
|
|
10
|
+
includeToolContext: false,
|
|
8
11
|
};
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const RETRY_DELAY_MS = 800;
|
|
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
|
+
}
|
|
15
63
|
return undefined;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 });
|
|
35
103
|
}
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const turns = conversationTurns(sessionID);
|
|
107
|
+
if (turns.length === 0) {
|
|
108
|
+
debug("skip", "no conversation turns");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
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;
|
|
137
|
+
store.discard();
|
|
138
|
+
scheduleRetry(userMessageID);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
if (inFlightUserMessageID === userMessageID)
|
|
142
|
+
inFlightUserMessageID = undefined;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const scheduleRetry = (userMessageID) => {
|
|
146
|
+
if (retriedUserMessageID === userMessageID)
|
|
147
|
+
return;
|
|
148
|
+
retriedUserMessageID = userMessageID;
|
|
149
|
+
setTimeout(() => void maybePredict(), RETRY_DELAY_MS);
|
|
150
|
+
};
|
|
151
|
+
const invalidate = () => {
|
|
76
152
|
store.discard();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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");
|
|
95
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
|
+
}));
|
|
96
206
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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) {
|
|
283
|
+
const slash = spec?.indexOf("/") ?? -1;
|
|
284
|
+
if (spec && slash > 0 && slash < spec.length - 1) {
|
|
285
|
+
const providerID = spec.slice(0, slash);
|
|
286
|
+
const modelID = spec.slice(slash + 1);
|
|
287
|
+
return { providerID, id: modelID, model: findModel(context, providerID, modelID) };
|
|
288
|
+
}
|
|
289
|
+
const providerID = sessionModel?.providerID;
|
|
290
|
+
const modelID = spec ?? sessionModel?.id;
|
|
291
|
+
if (!providerID || !modelID)
|
|
292
|
+
return undefined;
|
|
293
|
+
return { providerID, id: modelID, model: findModel(context, providerID, modelID) };
|
|
294
|
+
}
|
|
295
|
+
function findModel(context, providerID, modelID) {
|
|
296
|
+
return context.data.location.model.list()?.find((item) => item.providerID === providerID && item.modelID === modelID);
|
|
297
|
+
}
|
|
298
|
+
function checkModelConfig(spec, ref, context) {
|
|
299
|
+
if (!spec || !ref)
|
|
300
|
+
return undefined;
|
|
301
|
+
const providers = context.data.location.provider.list();
|
|
302
|
+
if (providers && !providers.some((item) => item.id === ref.providerID)) {
|
|
303
|
+
const available = providers.map((item) => item.id).join(", ");
|
|
304
|
+
return `model config error: provider "${ref.providerID}" not found${available ? ` (available providers: ${available})` : ""}`;
|
|
305
|
+
}
|
|
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 ? ", ..." : ""})` : ""}`;
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
const VARIANT_ORDER = ["none", "low", "min", "medium", "high", "xhigh", "max"];
|
|
319
|
+
function resolveVariant(configVariant, ref) {
|
|
320
|
+
if (configVariant === "default")
|
|
321
|
+
return undefined;
|
|
322
|
+
if (configVariant)
|
|
323
|
+
return configVariant;
|
|
324
|
+
const variants = ref?.model?.variants;
|
|
325
|
+
if (!variants || variants.length === 0)
|
|
326
|
+
return undefined;
|
|
327
|
+
const names = variants.map((item) => item.id);
|
|
328
|
+
const low = names.find((name) => name.toLowerCase() === "low");
|
|
329
|
+
if (low)
|
|
330
|
+
return low;
|
|
331
|
+
return names.toSorted((a, b) => variantRank(a) - variantRank(b))[0];
|
|
332
|
+
}
|
|
333
|
+
function variantRank(name) {
|
|
334
|
+
const index = VARIANT_ORDER.indexOf(name.toLowerCase());
|
|
335
|
+
return index === -1 ? VARIANT_ORDER.length : index;
|
|
336
|
+
}
|
|
337
|
+
function errorMessage(error) {
|
|
338
|
+
if (error instanceof Error)
|
|
339
|
+
return error.message;
|
|
340
|
+
if (typeof error === "string")
|
|
341
|
+
return error;
|
|
342
|
+
if (error && typeof error === "object") {
|
|
343
|
+
const candidate = error.message;
|
|
344
|
+
if (typeof candidate === "string")
|
|
345
|
+
return candidate;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
return JSON.stringify(error);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return String(error);
|
|
352
|
+
}
|
|
134
353
|
}
|
|
135
354
|
const TOOL_SUMMARY_CHARS = 150;
|
|
136
355
|
function summarizeToolPart(part) {
|
|
137
|
-
|
|
356
|
+
const tool = part;
|
|
357
|
+
if (tool.type !== "tool" || !tool.state || tool.state.status === "running" || tool.state.status === "streaming")
|
|
138
358
|
return "";
|
|
139
|
-
const input = JSON.stringify(
|
|
140
|
-
const output =
|
|
359
|
+
const input = JSON.stringify(tool.state.input ?? {});
|
|
360
|
+
const output = tool.state.metadata?.output ?? "";
|
|
141
361
|
const inputText = input.length > 80 ? input.slice(0, 79) + "…" : input;
|
|
142
362
|
const outputText = output.length > TOOL_SUMMARY_CHARS ? output.slice(0, TOOL_SUMMARY_CHARS - 1) + "…" : output;
|
|
143
|
-
return `[tool:${
|
|
363
|
+
return `[tool:${tool.name ?? "?"}] ${inputText} → ${outputText || tool.state.status || ""}`;
|
|
144
364
|
}
|
|
145
|
-
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";
|
|
@@ -6,12 +6,27 @@ export type ConversationTurn = {
|
|
|
6
6
|
};
|
|
7
7
|
export type PredictOptions = {
|
|
8
8
|
timeoutMs: number;
|
|
9
|
+
model?: string;
|
|
10
|
+
};
|
|
11
|
+
export type PredictResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
text: string | null;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
9
16
|
};
|
|
10
17
|
export type Predictor = {
|
|
11
|
-
predict(model:
|
|
12
|
-
providerID: string;
|
|
13
|
-
id: string;
|
|
14
|
-
} | undefined, turns: ConversationTurn[]): Promise<string | null>;
|
|
18
|
+
predict(model: ModelRef | undefined, turns: ConversationTurn[], variant?: string): Promise<PredictResult>;
|
|
15
19
|
dispose(): Promise<void>;
|
|
16
20
|
};
|
|
17
|
-
|
|
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
|
|
|
@@ -13,76 +13,109 @@ If nothing plausible remains to say, reply with exactly: NO_SUGGESTION
|
|
|
13
13
|
Conversation (oldest first):
|
|
14
14
|
`;
|
|
15
15
|
const MAX_PREDICTION_CHARS = 170;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
const MAX_TURNS = 8;
|
|
17
|
+
const MAX_TURN_CHARS = 800;
|
|
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) {
|
|
30
30
|
return {
|
|
31
|
-
async predict(model, turns) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
async predict(model, turns, variant) {
|
|
32
|
+
if (turns.length === 0)
|
|
33
|
+
return { ok: true, text: null };
|
|
34
|
+
const prompt = PROMPT + renderConversation(turns);
|
|
35
|
+
const ref = resolveModel(options.model, model);
|
|
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));
|
|
35
61
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return null;
|
|
39
|
-
let input = PROMPT + renderConversation(delta);
|
|
40
|
-
if (sessionID && accumulatedChars + input.length > MAX_ACCUMULATED_CHARS) {
|
|
41
|
-
await deleteSession();
|
|
42
|
-
delta = turns;
|
|
43
|
-
input = PROMPT + renderConversation(delta);
|
|
62
|
+
try {
|
|
63
|
+
return await attempt(undefined);
|
|
44
64
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
throw created.error;
|
|
49
|
-
sessionID = created.data.id;
|
|
65
|
+
catch (error) {
|
|
66
|
+
debug("generate failed", errorMessage(error));
|
|
67
|
+
return { ok: false };
|
|
50
68
|
}
|
|
51
|
-
const result = await withTimeout(client.session.prompt({ sessionID, directory, parts: [{ type: "text", text: input }] }), options.timeoutMs);
|
|
52
|
-
if (result.error)
|
|
53
|
-
throw result.error;
|
|
54
|
-
predictedTurns = turns.length;
|
|
55
|
-
predictedLastMessageID = turns.at(-1)?.id;
|
|
56
|
-
accumulatedChars += input.length;
|
|
57
|
-
return parsePrediction(result.data.parts);
|
|
58
69
|
},
|
|
59
70
|
async dispose() {
|
|
60
|
-
|
|
71
|
+
// Nothing to clean up: predictions do not own a session.
|
|
61
72
|
},
|
|
62
73
|
};
|
|
63
74
|
}
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|
|
93
|
+
function resolveModel(spec, sessionModel) {
|
|
94
|
+
if (!spec)
|
|
95
|
+
return sessionModel;
|
|
96
|
+
const slash = spec.indexOf("/");
|
|
97
|
+
if (slash > 0 && slash < spec.length - 1) {
|
|
98
|
+
return { providerID: spec.slice(0, slash), id: spec.slice(slash + 1) };
|
|
99
|
+
}
|
|
100
|
+
return sessionModel ? { providerID: sessionModel.providerID, id: spec } : undefined;
|
|
101
|
+
}
|
|
66
102
|
function renderConversation(turns) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
})
|
|
77
|
-
.map((turn) => `${turn.role}: ${turn.text}`)
|
|
78
|
-
.join("\n");
|
|
103
|
+
if (turns.length === 0)
|
|
104
|
+
return "";
|
|
105
|
+
const recent = turns.slice(-MAX_TURNS);
|
|
106
|
+
const lines = recent.map((turn) => `${turn.role}: ${truncate(turn.text, MAX_TURN_CHARS)}`);
|
|
107
|
+
const first = turns[0];
|
|
108
|
+
if (turns.length > MAX_TURNS && first?.role === "user" && first.id !== recent[0]?.id) {
|
|
109
|
+
lines.unshift(`user: [original goal] ${truncate(first.text, MAX_TURN_CHARS)}`);
|
|
110
|
+
}
|
|
111
|
+
return lines.join("\n");
|
|
79
112
|
}
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.replace(
|
|
113
|
+
function truncate(text, max) {
|
|
114
|
+
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
115
|
+
}
|
|
116
|
+
function parsePrediction(raw) {
|
|
117
|
+
const line = raw
|
|
118
|
+
.replace(/<\/?think>[\s\S]*?<\/think>\s*/g, "")
|
|
86
119
|
.split("\n")
|
|
87
120
|
.map((item) => item.trim())
|
|
88
121
|
.find((item) => item.length > 0);
|
|
@@ -96,10 +129,3 @@ function parsePrediction(parts) {
|
|
|
96
129
|
return null;
|
|
97
130
|
return text.length > MAX_PREDICTION_CHARS ? text.slice(0, MAX_PREDICTION_CHARS - 3) + "..." : text;
|
|
98
131
|
}
|
|
99
|
-
function withTimeout(promise, ms) {
|
|
100
|
-
let timer;
|
|
101
|
-
const timeout = new Promise((_, reject) => {
|
|
102
|
-
timer = setTimeout(() => reject(new Error(`prediction timed out after ${ms}ms`)), ms);
|
|
103
|
-
});
|
|
104
|
-
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
105
|
-
}
|
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