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 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/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
- }]]
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` | `down` | Key to accept the suggestion (only effective while the input is empty) |
39
- | `timeoutMs` | `20000` | Per-prediction timeout; the suggestion is dropped on timeout |
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 in a background session that reuses the conversation prefix — each prediction only processes the new content, so it stays fast regardless of conversation length. 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.
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
 
@@ -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,145 +1,365 @@
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,
10
+ includeToolContext: false,
8
11
  };
9
- const opencodeNextPrompt = async (api, rawOptions) => {
10
- const options = { ...DEFAULT_OPTIONS, ...(rawOptions ?? {}) };
11
- const store = createSuggestionStore();
12
- const currentSessionID = () => {
13
- const route = api.route.current;
14
- if (route.name !== "session")
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
- const sessionID = route.params?.sessionID;
17
- return typeof sessionID === "string" ? sessionID : undefined;
18
- };
19
- const promptInputIsEmpty = () => {
20
- const editor = api.renderer.currentFocusedEditor;
21
- if (!editor || editor instanceof InputRenderable)
22
- return false;
23
- return editor.editBuffer.getText() === "";
24
- };
25
- const conversationTurns = (sessionID) => {
26
- const result = [];
27
- for (const message of api.state.session.messages(sessionID)) {
28
- if (message.role !== "user" && message.role !== "assistant")
29
- continue;
30
- const text = api.state
31
- .part(message.id)
32
- .map((part) => {
33
- if (part.type === "text" && !part.synthetic && !part.ignored) {
34
- 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 });
35
103
  }
36
- return summarizeToolPart(part);
37
- })
38
- .join("\n")
39
- .trim();
40
- if (!text)
41
- continue;
42
- result.push({ id: message.id, role: message.role, text });
43
- }
44
- return result;
45
- };
46
- const lastUserMessageID = (sessionID) => {
47
- for (const message of api.state.session.messages(sessionID).toReversed()) {
48
- if (message.role === "user")
49
- return message.id;
50
- }
51
- return undefined;
52
- };
53
- let predictedUserMessageID;
54
- const predictor = createPredictor(api.client, api.state.path.directory, options);
55
- const maybePredict = async () => {
56
- const sessionID = currentSessionID();
57
- if (!sessionID)
58
- return;
59
- const userMessageID = lastUserMessageID(sessionID);
60
- if (userMessageID && userMessageID === predictedUserMessageID)
61
- return;
62
- const turns = conversationTurns(sessionID);
63
- if (turns.length === 0)
64
- return;
65
- const generation = store.beginPredict(sessionID);
66
- if (generation === null)
67
- return;
68
- predictedUserMessageID = userMessageID;
69
- const session = api.state.session.get(sessionID);
70
- try {
71
- const text = await predictor.predict(session?.model, turns);
72
- store.commit(generation, sessionID, text);
73
- }
74
- catch (error) {
75
- log(api, "prediction failed", error);
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
- const accept = async () => {
80
- const current = store.read();
81
- if (current.kind !== "ready")
82
- return;
83
- try {
84
- await api.client.tui.appendPrompt({ text: current.text });
85
- }
86
- catch (error) {
87
- log(api, "append prompt failed", error);
88
- }
89
- };
90
- api.event.on("session.status", (event) => {
91
- const { sessionID, status } = event.properties;
92
- if (sessionID !== currentSessionID())
93
- return;
94
- 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");
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
- else if (status.type === "idle") {
98
- if (store.read().kind === "idle")
99
- void maybePredict();
100
- }
101
- });
102
- api.event.on("tui.session.select", (event) => {
103
- store.discard();
104
- void predictor.dispose();
105
- });
106
- api.event.on("session.compacted", () => {
107
- void predictor.dispose();
108
- });
109
- api.lifecycle.onDispose(() => {
110
- void predictor.dispose();
111
- });
112
- api.keymap.registerLayer({
113
- priority: 1000,
114
- enabled: () => store.read().kind === "ready",
115
- commands: [{ name: "next-prompt.accept", run: () => (promptInputIsEmpty() ? void accept() : undefined) }],
116
- bindings: [
117
- { key: options.acceptKey, cmd: "next-prompt.accept", preventDefault: false, fallthrough: true },
118
- ],
119
- });
120
- api.slots.register({
121
- id: "opencode-next-prompt",
122
- slots: {
123
- session_prompt_right: (ctx, props) => renderSuggestionPlaceholder(api, props, store.read, options.acceptKey),
124
- },
125
- });
126
- };
127
- function log(api, message, extra) {
128
- void api.client.app.log({
129
- service: "opencode-next-prompt",
130
- level: "info",
131
- message,
132
- extra: { data: JSON.stringify(extra) },
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
- 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")
138
358
  return "";
139
- const input = JSON.stringify(part.state.input ?? {});
140
- const output = part.state.metadata?.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:${part.tool ?? "?"}] ${inputText} → ${outputText || part.state.status || ""}`;
363
+ return `[tool:${tool.name ?? "?"}] ${inputText} → ${outputText || tool.state.status || ""}`;
144
364
  }
145
- 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";
@@ -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
- 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
 
@@ -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
- export function createPredictor(client, directory, options) {
17
- let sessionID;
18
- let predictedTurns = 0;
19
- let predictedLastMessageID;
20
- let accumulatedChars = 0;
21
- const deleteSession = async () => {
22
- if (sessionID) {
23
- await client.session.delete({ sessionID, directory }).catch(() => { });
24
- sessionID = undefined;
25
- predictedTurns = 0;
26
- predictedLastMessageID = undefined;
27
- accumulatedChars = 0;
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
- const expectedID = turns[predictedTurns - 1]?.id;
33
- if (predictedLastMessageID && expectedID && expectedID !== predictedLastMessageID) {
34
- await deleteSession();
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
- let delta = turns.slice(predictedTurns);
37
- if (delta.length === 0)
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
- if (!sessionID) {
46
- const created = await client.session.create({ directory, title: TITLE, model });
47
- if (created.error)
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
- await deleteSession();
71
+ // Nothing to clean up: predictions do not own a session.
61
72
  },
62
73
  };
63
74
  }
64
- const MAX_ASSISTANT_MESSAGES = 10;
65
- const MAX_ACCUMULATED_CHARS = 80_000;
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
- const assistantCount = turns.reduce((count, turn) => count + (turn.role === "assistant" ? 1 : 0), 0);
68
- const skip = Math.max(0, assistantCount - MAX_ASSISTANT_MESSAGES);
69
- let skipped = 0;
70
- return turns
71
- .filter((turn) => {
72
- if (turn.role !== "assistant" || skipped >= skip)
73
- return true;
74
- skipped += 1;
75
- return false;
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 parsePrediction(parts) {
81
- const line = parts
82
- .filter((part) => part.type === "text")
83
- .map((part) => part.text)
84
- .join("")
85
- .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
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: 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.7",
4
- "description": "Predicts your next message after the assistant replies in OpenCode \u2014 accept it with the right 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"