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 +21 -0
- package/README.md +47 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +149 -0
- package/dist/predict.d.ts +17 -0
- package/dist/predict.js +117 -0
- package/dist/state.d.ts +19 -0
- package/dist/state.js +25 -0
- package/dist/ui.d.ts +10 -0
- package/dist/ui.js +64 -0
- package/package.json +46 -0
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
|
package/dist/index.d.ts
ADDED
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;
|
package/dist/predict.js
ADDED
|
@@ -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
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -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
|
+
}
|