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 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/tui.json`:
9
+ Add to `~/.config/opencode/cli.json`:
8
10
 
9
11
  ```json
10
12
  {
11
- "plugin": ["opencode-next-prompt"]
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`; the `plugin` array in `opencode.jsonc` is for server plugins (e.g. opencode-polkit).
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 (**↓** by default) while the input is empty to fill it; with text in the input, the key keeps its normal cursor behavior
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
- "plugin": [["opencode-next-prompt", {
30
- "acceptKey": "down",
31
- "timeoutMs": 20000,
32
- "model": "my-provider/fast-model",
33
- "disableTools": true,
34
- "includeToolContext": false
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` | `down` | Key to accept the suggestion (only effective while the input is empty) |
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 and a log entry |
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 in a fresh background session built from the recent conversation (the last few turns plus the original goal). The suggestion is written into the input placeholder, so what you see is exactly what gets accepted. The main conversation is never modified.
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, and the background session is created and removed per prediction, so the model never sees its own previous predictions (which would bias the next one). Failed or timed-out predictions are retried once. A suggestion disappears when you type or when the session changes.
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
 
@@ -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
+ }
@@ -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 type { TuiPlugin } from "@opencode-ai/plugin/tui";
2
- declare const _default: {
3
- id: string;
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 { InputRenderable } from "@opentui/core";
1
+ import { Plugin } from "@opencode/plugin/tui";
2
2
  import { createSuggestionStore } from "./state.js";
3
3
  import { createPredictor } from "./predict.js";
4
- import { renderSuggestionPlaceholder } from "./ui.js";
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: "down",
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 opencodeNextPrompt = async (api, rawOptions) => {
13
- const options = { ...DEFAULT_OPTIONS, ...(rawOptions ?? {}) };
14
- const store = createSuggestionStore();
15
- const currentSessionID = () => {
16
- const route = api.route.current;
17
- if (route.name !== "session")
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
- const sessionID = route.params?.sessionID;
20
- return typeof sessionID === "string" ? sessionID : undefined;
21
- };
22
- const promptInputIsEmpty = () => {
23
- const editor = api.renderer.currentFocusedEditor;
24
- if (!editor || editor instanceof InputRenderable)
25
- return false;
26
- return editor.editBuffer.getText() === "";
27
- };
28
- const conversationTurns = (sessionID) => {
29
- const result = [];
30
- for (const message of api.state.session.messages(sessionID)) {
31
- if (message.role !== "user" && message.role !== "assistant")
32
- continue;
33
- const text = api.state
34
- .part(message.id)
35
- .map((part) => {
36
- if (part.type === "text" && !part.synthetic && !part.ignored) {
37
- return part.text.replace(/<think>[\s\S]*?<\/think>\s*/g, "");
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 options.includeToolContext ? summarizeToolPart(part) : "";
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
- return;
88
- }
89
- const turns = conversationTurns(sessionID);
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
- else {
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
- catch (error) {
111
- log(api, "prediction failed", errorMessage(error));
112
- if (inFlightUserMessageID !== userMessageID)
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
- scheduleRetry(userMessageID);
116
- }
117
- finally {
118
- if (inFlightUserMessageID === userMessageID)
119
- inFlightUserMessageID = undefined;
120
- }
121
- };
122
- const scheduleRetry = (userMessageID) => {
123
- if (retriedUserMessageID === userMessageID)
124
- return;
125
- retriedUserMessageID = userMessageID;
126
- setTimeout(() => void maybePredict(), RETRY_DELAY_MS);
127
- };
128
- const invalidate = () => {
129
- store.discard();
130
- inFlightUserMessageID = undefined;
131
- completedUserMessageID = undefined;
132
- retriedUserMessageID = undefined;
133
- void predictor.dispose();
134
- };
135
- const accept = async () => {
136
- const current = store.read();
137
- if (current.kind !== "ready")
138
- return;
139
- try {
140
- await api.client.tui.appendPrompt({ text: current.text });
141
- }
142
- catch (error) {
143
- log(api, "append prompt failed", errorMessage(error));
144
- }
145
- };
146
- api.event.on("session.status", (event) => {
147
- const { sessionID, status } = event.properties;
148
- if (sessionID !== currentSessionID())
149
- return;
150
- if (status.type === "busy") {
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
- else if (status.type === "idle") {
154
- if (store.read().kind === "idle")
155
- void maybePredict();
156
- }
157
- });
158
- api.event.on("tui.session.select", () => invalidate());
159
- api.event.on("session.compacted", () => invalidate());
160
- api.event.on("session.deleted", (event) => {
161
- if (event.properties.sessionID === currentSessionID())
162
- invalidate();
163
- });
164
- api.event.on("message.removed", (event) => {
165
- if (event.properties.sessionID === currentSessionID())
166
- invalidate();
167
- });
168
- api.event.on("message.part.removed", (event) => {
169
- if (event.properties.sessionID === currentSessionID())
170
- invalidate();
171
- });
172
- api.lifecycle.onDispose(() => {
173
- void predictor.dispose();
174
- });
175
- api.keymap.registerLayer({
176
- priority: 1000,
177
- enabled: () => store.read().kind === "ready",
178
- commands: [{ name: "next-prompt.accept", run: () => (promptInputIsEmpty() ? void accept() : undefined) }],
179
- bindings: [
180
- { key: options.acceptKey, cmd: "next-prompt.accept", preventDefault: false, fallthrough: true },
181
- ],
182
- });
183
- api.slots.register({
184
- id: "opencode-next-prompt",
185
- slots: {
186
- session_prompt_right: (ctx, props) => renderSuggestionPlaceholder(api, props, store.read, options.acceptKey),
187
- },
188
- });
189
- };
190
- function log(api, message, extra) {
191
- void api.client.app.log({
192
- service: "opencode-next-prompt",
193
- level: "info",
194
- message,
195
- extra: { data: extra },
196
- });
197
- }
198
- function resolveModelRef(spec, sessionModel, providers) {
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, providers) };
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, providers) };
293
+ return { providerID, id: modelID, model: findModel(context, providerID, modelID) };
210
294
  }
211
- function findModel(providerID, modelID, providers) {
212
- return providers.find((item) => item.id === providerID)?.models[modelID];
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, providers) {
298
+ function checkModelConfig(spec, ref, context) {
215
299
  if (!spec || !ref)
216
300
  return undefined;
217
- const provider = providers.find((item) => item.id === ref.providerID);
218
- if (!provider) {
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
- if (!ref.model) {
223
- const available = Object.keys(provider.models).slice(0, 10).join(", ");
224
- return `model config error: model "${ref.modelID}" not found on provider "${ref.providerID}"${available ? ` (available models: ${available}${Object.keys(provider.models).length > 10 ? ", ..." : ""})` : ""}`;
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
- if (part.type !== "tool" || !part.state || part.state.status === "running")
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(part.state.input ?? {});
271
- const output = part.state.metadata?.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:${part.tool ?? "?"}] ${inputText} → ${outputText || part.state.status || ""}`;
363
+ return `[tool:${tool.name ?? "?"}] ${inputText} → ${outputText || tool.state.status || ""}`;
275
364
  }
276
- export default { id: "opencode-next-prompt", tui: opencodeNextPrompt };
365
+ export default opencodeNextPrompt;
package/dist/predict.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OpencodeClient } from "@opencode-ai/sdk/v2";
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
- export declare function createPredictor(client: OpencodeClient, directory: string | undefined, options: PredictOptions): Predictor;
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
- const TITLE = "next-prompt-suggestion";
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
- const DENY_ALL_TOOLS = [
19
- { permission: "*", pattern: "*", action: "deny" },
20
- ];
21
- export function createPredictor(client, directory, options) {
22
- let sessionID;
23
- const deleteSession = async () => {
24
- if (sessionID) {
25
- const current = sessionID;
26
- sessionID = undefined;
27
- await client.session.abort({ sessionID: current, directory }).catch(() => { });
28
- await client.session.delete({ sessionID: current, directory }).catch(() => { });
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 input = PROMPT + renderConversation(turns);
34
+ const prompt = PROMPT + renderConversation(turns);
36
35
  const ref = resolveModel(options.model, model);
37
- const created = await client.session.create({
38
- directory,
39
- title: TITLE,
40
- model: ref ? { id: ref.id, providerID: ref.providerID, ...(variant ? { variant } : {}) } : undefined,
41
- permission: options.disableTools === false ? undefined : DENY_ALL_TOOLS,
42
- });
43
- if (created.error)
44
- throw created.error;
45
- sessionID = created.data.id;
46
- const currentSessionID = sessionID;
47
- let text;
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
- const result = await withTimeout(client.session.prompt({ sessionID, directory, parts: [{ type: "text", text: input }] }), options.timeoutMs, () => void client.session.abort({ sessionID: currentSessionID, directory }).catch(() => { }));
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
- await deleteSession();
56
- throw error;
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
- await deleteSession();
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(parts) {
90
- const line = parts
91
- .filter((part) => part.type === "text")
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: import("solid-js").Accessor<SuggestionState>;
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
- import { createSignal } from "solid-js";
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
- const [state, setState] = createSignal({ kind: "idle" });
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: state,
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().kind !== "idle")
30
+ if (state.kind !== "idle")
9
31
  return null;
10
32
  generation += 1;
11
- setState({ kind: "predicting", sessionID, generation });
33
+ set({ kind: "predicting", sessionID, generation });
12
34
  return generation;
13
35
  },
14
36
  commit(generation, sessionID, text) {
15
- const current = state();
16
- if (current.kind !== "predicting" || current.generation !== generation)
37
+ if (state.kind !== "predicting" || state.generation !== generation)
17
38
  return;
18
- setState(text ? { kind: "ready", sessionID, text, generation } : { kind: "idle" });
39
+ set(text ? { kind: "ready", sessionID, text, generation } : { kind: "idle" });
19
40
  },
20
41
  discard() {
21
42
  generation += 1;
22
- setState({ kind: "idle" });
43
+ set({ kind: "idle" });
23
44
  },
24
45
  };
25
46
  }
package/dist/ui.d.ts CHANGED
@@ -1,8 +1,23 @@
1
- import type { JSX } from "@opentui/solid";
2
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
3
- import type { Accessor } from "solid-js";
4
- import type { SuggestionState } from "./state.ts";
5
- export type PromptSlotProps = {
6
- session_id: string;
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 declare function renderSuggestionPlaceholder(api: TuiPluginApi, props: PromptSlotProps, state: Accessor<SuggestionState>, acceptKey: string): JSX.Element;
23
+ export type PlaceholderController = ReturnType<typeof createPlaceholderController>;
package/dist/ui.js CHANGED
@@ -1,5 +1,5 @@
1
- import { createEffect, onCleanup } from "solid-js";
2
- import { InputRenderable, TextareaRenderable } from "@opentui/core";
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
- export function renderSuggestionPlaceholder(api, props, state, acceptKey) {
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 = api.renderer.currentFocusedEditor;
15
- if (!editor || editor instanceof InputRenderable || !(editor instanceof TextareaRenderable))
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
- const current = state();
18
- editor.placeholder =
19
- current.kind === "ready" && current.sessionID === props.session_id
20
- ? ` [${displayKey(acceptKey)}] ${current.text}`
21
- : null;
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.8",
4
- "description": "Predicts your next message after the assistant replies in OpenCode \u2014 accept it with the down arrow",
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": "deno task build",
19
- "check": "deno task check"
19
+ "build": "bun tools/build.ts",
20
+ "check": "tsc --noEmit",
21
+ "test": "bun test"
20
22
  },
21
23
  "engines": {
22
- "opencode": ">=1.18.0"
24
+ "opencode": ">=2.0.0"
23
25
  },
24
26
  "dependencies": {
25
- "@opentui/core": "0.4.5",
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-ai/plugin": ">=1.0.0"
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-ai/plugin": "^1.0.0",
34
- "bun-types": "^1.2.0",
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
@@ -0,0 +1,4 @@
1
+ // Local-directory entrypoint for OpenCode (cli.json "plugins" with an absolute
2
+ // path resolves <dir>/tui directly, bypassing package.json "exports").
3
+ // Published installs use the "./tui" export in package.json instead.
4
+ export { default } from "./dist/index.js"