opencode-next-prompt 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-next-prompt contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # opencode-next-prompt
2
+
3
+ Predicts your next message after the assistant replies and shows it at the prompt — accept it with the right arrow (like Claude Code's "next message suggestion").
4
+
5
+ ## Install
6
+
7
+ Add to `~/.config/opencode/opencode.jsonc` (or the project-level `opencode.json`):
8
+
9
+ ```json
10
+ {
11
+ "plugin": ["opencode-next-prompt"]
12
+ }
13
+ ```
14
+
15
+ Restart opencode. The package and its dependencies are installed automatically.
16
+
17
+ ## Usage
18
+
19
+ - After each reply, a suggested next message appears at the input in gray
20
+ - Press **→** while the input is empty to accept it; with text in the input, → keeps its normal cursor behavior
21
+ - Typing hides the suggestion; clearing the input re-shows it
22
+
23
+ ## Config
24
+
25
+ ```json
26
+ {
27
+ "plugin": [["opencode-next-prompt", {
28
+ "acceptKey": "right",
29
+ "position": "left",
30
+ "timeoutMs": 20000
31
+ }]]
32
+ }
33
+ ```
34
+
35
+ | Key | Default | Description |
36
+ |---|---|---|
37
+ | `acceptKey` | `right` | Key to accept the suggestion (only effective while the input is empty) |
38
+ | `position` | `left` | Where the suggestion shows: `left` inside the input; `right` below-right of the input |
39
+ | `timeoutMs` | `20000` | Per-prediction timeout; the suggestion is dropped on timeout |
40
+
41
+ ## How it works
42
+
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.
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,6 @@
1
+ import type { TuiPlugin } from "@opencode-ai/plugin/tui";
2
+ declare const _default: {
3
+ id: string;
4
+ tui: TuiPlugin;
5
+ };
6
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,149 @@
1
+ import { InputRenderable } from "@opentui/core";
2
+ import { createSuggestionStore } from "./state.js";
3
+ import { createPredictor } from "./predict.js";
4
+ import { createInputEmpty, renderSuggestionPlaceholder, renderSuggestionSlot } from "./ui.js";
5
+ const DEFAULT_OPTIONS = {
6
+ acceptKey: "right",
7
+ position: "left",
8
+ timeoutMs: 20_000,
9
+ };
10
+ const opencodeNextPrompt = async (api, rawOptions) => {
11
+ const options = { ...DEFAULT_OPTIONS, ...(rawOptions ?? {}) };
12
+ const store = createSuggestionStore();
13
+ const currentSessionID = () => {
14
+ const route = api.route.current;
15
+ if (route.name !== "session")
16
+ return undefined;
17
+ const sessionID = route.params?.sessionID;
18
+ return typeof sessionID === "string" ? sessionID : undefined;
19
+ };
20
+ const promptInputIsEmpty = () => {
21
+ const editor = api.renderer.currentFocusedEditor;
22
+ if (!editor || editor instanceof InputRenderable)
23
+ return false;
24
+ return editor.editBuffer.getText() === "";
25
+ };
26
+ const conversationTurns = (sessionID) => {
27
+ const result = [];
28
+ for (const message of api.state.session.messages(sessionID)) {
29
+ if (message.role !== "user" && message.role !== "assistant")
30
+ continue;
31
+ const text = api.state
32
+ .part(message.id)
33
+ .map((part) => {
34
+ if (part.type === "text" && !part.synthetic && !part.ignored) {
35
+ return part.text.replace(/<think>[\s\S]*?<\/think>\s*/g, "");
36
+ }
37
+ return summarizeToolPart(part);
38
+ })
39
+ .join("\n")
40
+ .trim();
41
+ if (!text)
42
+ continue;
43
+ result.push({ id: message.id, role: message.role, text });
44
+ }
45
+ return result;
46
+ };
47
+ const lastUserMessageID = (sessionID) => {
48
+ for (const message of api.state.session.messages(sessionID).toReversed()) {
49
+ if (message.role === "user")
50
+ return message.id;
51
+ }
52
+ return undefined;
53
+ };
54
+ let predictedUserMessageID;
55
+ const predictor = createPredictor(api.client, api.state.path.directory, options);
56
+ const maybePredict = async () => {
57
+ const sessionID = currentSessionID();
58
+ if (!sessionID)
59
+ return;
60
+ const userMessageID = lastUserMessageID(sessionID);
61
+ if (userMessageID && userMessageID === predictedUserMessageID)
62
+ return;
63
+ const turns = conversationTurns(sessionID);
64
+ if (turns.length === 0)
65
+ return;
66
+ const generation = store.beginPredict(sessionID);
67
+ if (generation === null)
68
+ return;
69
+ predictedUserMessageID = userMessageID;
70
+ const session = api.state.session.get(sessionID);
71
+ try {
72
+ const text = await predictor.predict(session?.model, turns);
73
+ store.commit(generation, sessionID, text);
74
+ }
75
+ catch (error) {
76
+ log(api, "prediction failed", error);
77
+ store.discard();
78
+ }
79
+ };
80
+ const accept = async () => {
81
+ const current = store.read();
82
+ if (current.kind !== "ready")
83
+ return;
84
+ try {
85
+ await api.client.tui.appendPrompt({ text: current.text });
86
+ }
87
+ catch (error) {
88
+ log(api, "append prompt failed", error);
89
+ }
90
+ };
91
+ api.event.on("session.status", (event) => {
92
+ const { sessionID, status } = event.properties;
93
+ if (sessionID !== currentSessionID())
94
+ return;
95
+ if (status.type === "busy") {
96
+ store.discard();
97
+ }
98
+ else if (status.type === "idle") {
99
+ if (store.read().kind === "idle")
100
+ void maybePredict();
101
+ }
102
+ });
103
+ api.event.on("tui.session.select", (event) => {
104
+ store.discard();
105
+ void predictor.dispose();
106
+ });
107
+ api.event.on("session.compacted", () => {
108
+ void predictor.dispose();
109
+ });
110
+ api.lifecycle.onDispose(() => {
111
+ void predictor.dispose();
112
+ });
113
+ api.keymap.registerLayer({
114
+ priority: 1000,
115
+ enabled: () => store.read().kind === "ready",
116
+ commands: [{ name: "next-prompt.accept", run: () => (promptInputIsEmpty() ? void accept() : undefined) }],
117
+ bindings: [
118
+ { key: options.acceptKey, cmd: "next-prompt.accept", preventDefault: false, fallthrough: true },
119
+ ],
120
+ });
121
+ const inputEmpty = options.position === "right" ? createInputEmpty(api) : undefined;
122
+ api.slots.register({
123
+ id: "opencode-next-prompt",
124
+ slots: {
125
+ session_prompt_right: (ctx, props) => options.position === "right"
126
+ ? renderSuggestionSlot(ctx, props, store.read, options.acceptKey, inputEmpty)
127
+ : renderSuggestionPlaceholder(api, props, store.read, options.acceptKey),
128
+ },
129
+ });
130
+ };
131
+ function log(api, message, extra) {
132
+ void api.client.app.log({
133
+ service: "opencode-next-prompt",
134
+ level: "info",
135
+ message,
136
+ extra: { data: JSON.stringify(extra) },
137
+ });
138
+ }
139
+ const TOOL_SUMMARY_CHARS = 150;
140
+ function summarizeToolPart(part) {
141
+ if (part.type !== "tool" || !part.state || part.state.status === "running")
142
+ return "";
143
+ const input = JSON.stringify(part.state.input ?? {});
144
+ const output = part.state.metadata?.output ?? "";
145
+ const inputText = input.length > 80 ? input.slice(0, 79) + "…" : input;
146
+ const outputText = output.length > TOOL_SUMMARY_CHARS ? output.slice(0, TOOL_SUMMARY_CHARS - 1) + "…" : output;
147
+ return `[tool:${part.tool ?? "?"}] ${inputText} → ${outputText || part.state.status || ""}`;
148
+ }
149
+ export default { id: "opencode-next-prompt", tui: opencodeNextPrompt };
@@ -0,0 +1,17 @@
1
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2
+ export type ConversationTurn = {
3
+ id: string;
4
+ role: "user" | "assistant";
5
+ text: string;
6
+ };
7
+ export type PredictOptions = {
8
+ timeoutMs: number;
9
+ };
10
+ export type Predictor = {
11
+ predict(model: {
12
+ providerID: string;
13
+ id: string;
14
+ } | undefined, turns: ConversationTurn[]): Promise<string | null>;
15
+ dispose(): Promise<void>;
16
+ };
17
+ export declare function createPredictor(client: OpencodeClient, directory: string | undefined, options: PredictOptions): Predictor;
@@ -0,0 +1,117 @@
1
+ const TITLE = "next-prompt-suggestion";
2
+ const PROMPT = `You are generating a suggested next user message for an AI coding assistant conversation.
3
+ The conversation below is oldest first; the assistant just finished replying.
4
+
5
+ Goal:
6
+ - Suggest a useful next step that keeps momentum.
7
+
8
+ Rules:
9
+ - Output exactly one line, 170 characters max. Be concise.
10
+ - Write as the user speaking to the assistant (for example: "Can you...", "Help me...", "Let's...").
11
+ - Match the user's tone and language; keep it natural and human.
12
+ - Prefer a concrete action over a broad question.
13
+ - If the conversation is vague or small-talk, steer toward a practical starter request.
14
+ - If there is no meaningful or appropriate next step to suggest, output exactly: NO_SUGGESTION
15
+ - Avoid corporate or robotic phrasing.
16
+ - Avoid asking multiple discovery questions in one sentence.
17
+ - Do not include quotes, labels, markdown, or explanations.
18
+
19
+ Examples:
20
+ - Greeting context -> "Can you scan this repo and suggest the best first task to tackle?"
21
+ - Bug-fix context -> "Can you reproduce this bug and propose the smallest safe fix?"
22
+ - Feature context -> "Let's implement this incrementally; start with the MVP version first."
23
+ - Conversation is complete -> "NO_SUGGESTION"
24
+
25
+ Conversation:
26
+ `;
27
+ const MAX_PREDICTION_CHARS = 170;
28
+ export function createPredictor(client, directory, options) {
29
+ let sessionID;
30
+ let predictedTurns = 0;
31
+ let predictedLastMessageID;
32
+ let accumulatedChars = 0;
33
+ const deleteSession = async () => {
34
+ if (sessionID) {
35
+ await client.session.delete({ sessionID, directory }).catch(() => { });
36
+ sessionID = undefined;
37
+ predictedTurns = 0;
38
+ predictedLastMessageID = undefined;
39
+ accumulatedChars = 0;
40
+ }
41
+ };
42
+ return {
43
+ async predict(model, turns) {
44
+ const expectedID = turns[predictedTurns - 1]?.id;
45
+ if (predictedLastMessageID && expectedID && expectedID !== predictedLastMessageID) {
46
+ await deleteSession();
47
+ }
48
+ let delta = turns.slice(predictedTurns);
49
+ if (delta.length === 0)
50
+ return null;
51
+ let input = PROMPT + renderConversation(delta);
52
+ if (sessionID && accumulatedChars + input.length > MAX_ACCUMULATED_CHARS) {
53
+ await deleteSession();
54
+ delta = turns;
55
+ input = PROMPT + renderConversation(delta);
56
+ }
57
+ if (!sessionID) {
58
+ const created = await client.session.create({ directory, title: TITLE, model });
59
+ if (created.error)
60
+ throw created.error;
61
+ sessionID = created.data.id;
62
+ }
63
+ const result = await withTimeout(client.session.prompt({ sessionID, directory, parts: [{ type: "text", text: input }] }), options.timeoutMs);
64
+ if (result.error)
65
+ throw result.error;
66
+ predictedTurns = turns.length;
67
+ predictedLastMessageID = turns.at(-1)?.id;
68
+ accumulatedChars += input.length;
69
+ return parsePrediction(result.data.parts);
70
+ },
71
+ async dispose() {
72
+ await deleteSession();
73
+ },
74
+ };
75
+ }
76
+ const MAX_ASSISTANT_MESSAGES = 10;
77
+ const MAX_ACCUMULATED_CHARS = 80_000;
78
+ function renderConversation(turns) {
79
+ const assistantCount = turns.reduce((count, turn) => count + (turn.role === "assistant" ? 1 : 0), 0);
80
+ const skip = Math.max(0, assistantCount - MAX_ASSISTANT_MESSAGES);
81
+ let skipped = 0;
82
+ return turns
83
+ .filter((turn) => {
84
+ if (turn.role !== "assistant" || skipped >= skip)
85
+ return true;
86
+ skipped += 1;
87
+ return false;
88
+ })
89
+ .map((turn) => `${turn.role}: ${turn.text}`)
90
+ .join("\n");
91
+ }
92
+ function parsePrediction(parts) {
93
+ const line = parts
94
+ .filter((part) => part.type === "text")
95
+ .map((part) => part.text)
96
+ .join("")
97
+ .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
98
+ .split("\n")
99
+ .map((item) => item.trim())
100
+ .find((item) => item.length > 0);
101
+ if (!line)
102
+ return null;
103
+ const text = line.replace(/^["'`]+|["'`]+$/g, "");
104
+ if (!text)
105
+ return null;
106
+ const tag = text.toUpperCase().replace(/[\s-]+/g, "_").replace(/[^A-Z_]/g, "");
107
+ if (tag === "NO_SUGGESTION")
108
+ return null;
109
+ return text.length > MAX_PREDICTION_CHARS ? text.slice(0, MAX_PREDICTION_CHARS - 3) + "..." : text;
110
+ }
111
+ function withTimeout(promise, ms) {
112
+ let timer;
113
+ const timeout = new Promise((_, reject) => {
114
+ timer = setTimeout(() => reject(new Error(`prediction timed out after ${ms}ms`)), ms);
115
+ });
116
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
117
+ }
@@ -0,0 +1,19 @@
1
+ export type SuggestionState = {
2
+ kind: "idle";
3
+ } | {
4
+ kind: "predicting";
5
+ sessionID: string;
6
+ generation: number;
7
+ } | {
8
+ kind: "ready";
9
+ sessionID: string;
10
+ text: string;
11
+ generation: number;
12
+ };
13
+ export declare function createSuggestionStore(): {
14
+ read: import("solid-js").Accessor<SuggestionState>;
15
+ beginPredict(sessionID: string): number | null;
16
+ commit(generation: number, sessionID: string, text: string | null): void;
17
+ discard(): void;
18
+ };
19
+ export type SuggestionStore = ReturnType<typeof createSuggestionStore>;
package/dist/state.js ADDED
@@ -0,0 +1,25 @@
1
+ import { createSignal } from "solid-js";
2
+ export function createSuggestionStore() {
3
+ let generation = 0;
4
+ const [state, setState] = createSignal({ kind: "idle" });
5
+ return {
6
+ read: state,
7
+ beginPredict(sessionID) {
8
+ if (state().kind !== "idle")
9
+ return null;
10
+ generation += 1;
11
+ setState({ kind: "predicting", sessionID, generation });
12
+ return generation;
13
+ },
14
+ commit(generation, sessionID, text) {
15
+ const current = state();
16
+ if (current.kind !== "predicting" || current.generation !== generation)
17
+ return;
18
+ setState(text ? { kind: "ready", sessionID, text, generation } : { kind: "idle" });
19
+ },
20
+ discard() {
21
+ generation += 1;
22
+ setState({ kind: "idle" });
23
+ },
24
+ };
25
+ }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { JSX } from "@opentui/solid";
2
+ import type { TuiPluginApi, TuiSlotContext } 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;
7
+ };
8
+ export declare function renderSuggestionPlaceholder(api: TuiPluginApi, props: PromptSlotProps, state: Accessor<SuggestionState>, acceptKey: string): JSX.Element;
9
+ export declare function createInputEmpty(api: TuiPluginApi): Accessor<boolean>;
10
+ export declare function renderSuggestionSlot(ctx: TuiSlotContext, props: PromptSlotProps, state: Accessor<SuggestionState>, acceptKey: string, inputEmpty: Accessor<boolean>): JSX.Element;
package/dist/ui.js ADDED
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx } from "@opentui/solid/jsx-runtime";
2
+ import { createEffect, createSignal, onCleanup } from "solid-js";
3
+ import { InputRenderable, TextareaRenderable } from "@opentui/core";
4
+ const ARROW_KEYS = {
5
+ right: "→",
6
+ left: "←",
7
+ up: "↑",
8
+ down: "↓",
9
+ };
10
+ function displayKey(key) {
11
+ return ARROW_KEYS[key] ?? key;
12
+ }
13
+ export function renderSuggestionPlaceholder(api, props, state, acceptKey) {
14
+ const apply = () => {
15
+ const editor = api.renderer.currentFocusedEditor;
16
+ if (!editor || editor instanceof InputRenderable || !(editor instanceof TextareaRenderable))
17
+ return;
18
+ const current = state();
19
+ editor.placeholder =
20
+ current.kind === "ready" && current.sessionID === props.session_id
21
+ ? ` [${displayKey(acceptKey)}] ${current.text}`
22
+ : null;
23
+ };
24
+ createEffect(apply);
25
+ api.renderer.on("focused_editor", apply);
26
+ onCleanup(() => {
27
+ api.renderer.off("focused_editor", apply);
28
+ });
29
+ return null;
30
+ }
31
+ export function createInputEmpty(api) {
32
+ const [inputEmpty, setInputEmpty] = createSignal(true);
33
+ const apply = () => {
34
+ const editor = api.renderer.currentFocusedEditor;
35
+ setInputEmpty(editor === null || (!(editor instanceof InputRenderable) && editor.editBuffer.getText() === ""));
36
+ };
37
+ const offs = new Set();
38
+ const subscribe = (editor) => {
39
+ if (!editor)
40
+ return;
41
+ const handler = () => apply();
42
+ editor.editBuffer.on("content-changed", handler);
43
+ editor.editBuffer.on("cursor-changed", handler);
44
+ offs.add(() => {
45
+ editor.editBuffer.off("content-changed", handler);
46
+ editor.editBuffer.off("cursor-changed", handler);
47
+ });
48
+ };
49
+ api.renderer.on("focused_editor", (current) => {
50
+ offs.forEach((off) => off());
51
+ offs.clear();
52
+ subscribe(current);
53
+ apply();
54
+ });
55
+ subscribe(api.renderer.currentFocusedEditor);
56
+ apply();
57
+ return inputEmpty;
58
+ }
59
+ export function renderSuggestionSlot(ctx, props, state, acceptKey, inputEmpty) {
60
+ const current = state();
61
+ if (current.kind !== "ready" || current.sessionID !== props.session_id || !inputEmpty())
62
+ return null;
63
+ return _jsx("text", { fg: ctx.theme.current.textMuted, children: `[${displayKey(acceptKey)}] ${current.text}` });
64
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "opencode-next-prompt",
3
+ "version": "0.0.1",
4
+ "description": "Predicts your next message after the assistant replies in OpenCode — accept it with the right arrow",
5
+ "type": "module",
6
+ "exports": {
7
+ "./tui": {
8
+ "import": "./dist/index.js",
9
+ "types": "./dist/index.d.ts"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "deno task build",
19
+ "check": "deno task check"
20
+ },
21
+ "engines": {
22
+ "opencode": ">=1.18.0"
23
+ },
24
+ "dependencies": {
25
+ "@opentui/core": "0.4.5",
26
+ "@opentui/solid": "0.4.5",
27
+ "solid-js": "1.9.12"
28
+ },
29
+ "peerDependencies": {
30
+ "@opencode-ai/plugin": ">=1.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@opencode-ai/plugin": "^1.0.0",
34
+ "bun-types": "^1.2.0",
35
+ "typescript": "^5.5.0"
36
+ },
37
+ "keywords": ["opencode-plugin", "tui", "suggestion", "prediction"],
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/lenitain/opencode-next-prompt.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/lenitain/opencode-next-prompt/issues"
45
+ }
46
+ }