opencode-miniterm 1.0.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/.prettierrc +16 -0
- package/AGENTS.md +243 -0
- package/README.md +5 -0
- package/bun.lock +108 -0
- package/package.json +34 -0
- package/src/ansi.ts +22 -0
- package/src/commands/agents.ts +178 -0
- package/src/commands/debug.ts +74 -0
- package/src/commands/details.ts +17 -0
- package/src/commands/diff.ts +155 -0
- package/src/commands/exit.ts +17 -0
- package/src/commands/init.ts +35 -0
- package/src/commands/kill.ts +33 -0
- package/src/commands/log.ts +24 -0
- package/src/commands/models.ts +218 -0
- package/src/commands/new.ts +42 -0
- package/src/commands/page.ts +78 -0
- package/src/commands/quit.ts +17 -0
- package/src/commands/run.ts +34 -0
- package/src/commands/sessions.ts +257 -0
- package/src/commands/undo.ts +65 -0
- package/src/config.ts +56 -0
- package/src/index.ts +990 -0
- package/src/render.ts +320 -0
- package/src/types.ts +11 -0
- package/test/render.test.ts +390 -0
- package/test/test.ts +115 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import type { State } from "../index";
|
|
3
|
+
import type { Command } from "../types";
|
|
4
|
+
|
|
5
|
+
let command: Command = {
|
|
6
|
+
name: "/quit",
|
|
7
|
+
description: "Exit the application",
|
|
8
|
+
run,
|
|
9
|
+
running: false,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default command;
|
|
13
|
+
|
|
14
|
+
async function run(_client: OpencodeClient, _state: State): Promise<void> {
|
|
15
|
+
console.log(`\x1b[90mGoodbye!\x1b[0m`);
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { OpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import type { State } from "../index";
|
|
4
|
+
import type { Command } from "../types";
|
|
5
|
+
|
|
6
|
+
let command: Command = {
|
|
7
|
+
name: "/run",
|
|
8
|
+
description: "Run a shell command (e.g. `/run git status`)",
|
|
9
|
+
run,
|
|
10
|
+
running: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default command;
|
|
14
|
+
|
|
15
|
+
async function run(_client: OpencodeClient, _state: State, input?: string): Promise<void> {
|
|
16
|
+
if (!input) return;
|
|
17
|
+
|
|
18
|
+
const child = spawn(input, [], { shell: true });
|
|
19
|
+
child.stdout?.on("data", (data) => {
|
|
20
|
+
process.stdout.write(data.toString());
|
|
21
|
+
});
|
|
22
|
+
child.stderr?.on("data", (data) => {
|
|
23
|
+
process.stderr.write(data.toString());
|
|
24
|
+
});
|
|
25
|
+
await new Promise<void>((resolve) => {
|
|
26
|
+
child.on("close", (code) => {
|
|
27
|
+
if (code !== 0) {
|
|
28
|
+
console.log(`\x1b[90mCommand exited with code ${code}\x1b[0m`);
|
|
29
|
+
}
|
|
30
|
+
console.log();
|
|
31
|
+
resolve();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { OpencodeClient, Session } from "@opencode-ai/sdk";
|
|
2
|
+
import readline, { type Key } from "node:readline";
|
|
3
|
+
import { config, saveConfig } from "../config";
|
|
4
|
+
import type { State } from "../index";
|
|
5
|
+
import { updateSessionTitle } from "../index";
|
|
6
|
+
import { writePrompt } from "../render";
|
|
7
|
+
import type { Command } from "../types";
|
|
8
|
+
|
|
9
|
+
let command: Command = {
|
|
10
|
+
name: "/sessions",
|
|
11
|
+
description: "List and select sessions",
|
|
12
|
+
run,
|
|
13
|
+
handleKey,
|
|
14
|
+
running: false,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default command;
|
|
18
|
+
|
|
19
|
+
interface SessionInfo {
|
|
20
|
+
id: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let sessionList: SessionInfo[] = [];
|
|
27
|
+
let selectedSessionIndex = 0;
|
|
28
|
+
let sessionListLineCount = 0;
|
|
29
|
+
let sessionListOffset = 0;
|
|
30
|
+
let sessionSearchString = "";
|
|
31
|
+
let sessionFilteredIndices: number[] = [];
|
|
32
|
+
|
|
33
|
+
async function run(client: OpencodeClient, state: State): Promise<void> {
|
|
34
|
+
const result = await client.session.list();
|
|
35
|
+
|
|
36
|
+
if (result.error) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Failed to fetch sessions (${result.response.status}): ${JSON.stringify(result.error)}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sessions = (result.data as Session[]) || [];
|
|
43
|
+
|
|
44
|
+
if (sessions.length === 0) {
|
|
45
|
+
console.log("No sessions found. Creating a new session...");
|
|
46
|
+
state.sessionID = await createSession(client);
|
|
47
|
+
config.sessionID = state.sessionID;
|
|
48
|
+
saveConfig();
|
|
49
|
+
console.log(`Created new session: ${state.sessionID}...\n`);
|
|
50
|
+
await updateSessionTitle();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
sessionList = sessions.map((session) => ({
|
|
55
|
+
id: session.id,
|
|
56
|
+
title: session.title,
|
|
57
|
+
createdAt: session.time?.created || Date.now(),
|
|
58
|
+
updatedAt: session.time?.updated || Date.now(),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
sessionList.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
62
|
+
|
|
63
|
+
sessionSearchString = "";
|
|
64
|
+
updateSessionFilter();
|
|
65
|
+
|
|
66
|
+
sessionListOffset = Math.floor(selectedSessionIndex / 10) * 10;
|
|
67
|
+
if (sessionListOffset < 0) sessionListOffset = 0;
|
|
68
|
+
|
|
69
|
+
command.running = true;
|
|
70
|
+
|
|
71
|
+
renderSessionList();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function handleKey(_client: OpencodeClient, key: Key, str?: string) {
|
|
75
|
+
switch (key.name) {
|
|
76
|
+
case "up": {
|
|
77
|
+
if (selectedSessionIndex === 0) {
|
|
78
|
+
selectedSessionIndex = sessionFilteredIndices.length - 1;
|
|
79
|
+
} else {
|
|
80
|
+
selectedSessionIndex--;
|
|
81
|
+
}
|
|
82
|
+
const currentIndex = sessionFilteredIndices[selectedSessionIndex];
|
|
83
|
+
if (currentIndex !== undefined && currentIndex < sessionListOffset && sessionListOffset > 0) {
|
|
84
|
+
sessionListOffset -= 10;
|
|
85
|
+
if (sessionListOffset < 0) sessionListOffset = 0;
|
|
86
|
+
}
|
|
87
|
+
renderSessionList();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
case "down": {
|
|
91
|
+
if (selectedSessionIndex === sessionFilteredIndices.length - 1) {
|
|
92
|
+
selectedSessionIndex = 0;
|
|
93
|
+
} else {
|
|
94
|
+
selectedSessionIndex++;
|
|
95
|
+
}
|
|
96
|
+
const currentIndex = sessionFilteredIndices[selectedSessionIndex];
|
|
97
|
+
if (
|
|
98
|
+
currentIndex !== undefined &&
|
|
99
|
+
currentIndex >= sessionListOffset + 10 &&
|
|
100
|
+
sessionListOffset + 10 < sessionList.length
|
|
101
|
+
) {
|
|
102
|
+
sessionListOffset += 10;
|
|
103
|
+
}
|
|
104
|
+
renderSessionList();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
case "escape": {
|
|
108
|
+
clearSessionList();
|
|
109
|
+
process.stdout.write("\x1b[?25h");
|
|
110
|
+
command.running = false;
|
|
111
|
+
sessionList = [];
|
|
112
|
+
selectedSessionIndex = 0;
|
|
113
|
+
sessionListOffset = 0;
|
|
114
|
+
sessionListLineCount = 0;
|
|
115
|
+
sessionSearchString = "";
|
|
116
|
+
sessionFilteredIndices = [];
|
|
117
|
+
readline.cursorTo(process.stdout, 0);
|
|
118
|
+
readline.clearScreenDown(process.stdout);
|
|
119
|
+
writePrompt();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
case "return": {
|
|
123
|
+
sessionListLineCount++;
|
|
124
|
+
clearSessionList();
|
|
125
|
+
process.stdout.write("\x1b[?25h");
|
|
126
|
+
const selectedIndex = sessionFilteredIndices[selectedSessionIndex];
|
|
127
|
+
const selected = selectedIndex !== undefined ? sessionList[selectedIndex] : undefined;
|
|
128
|
+
command.running = false;
|
|
129
|
+
sessionList = [];
|
|
130
|
+
selectedSessionIndex = 0;
|
|
131
|
+
sessionListOffset = 0;
|
|
132
|
+
sessionListLineCount = 0;
|
|
133
|
+
sessionSearchString = "";
|
|
134
|
+
sessionFilteredIndices = [];
|
|
135
|
+
readline.cursorTo(process.stdout, 0);
|
|
136
|
+
readline.clearScreenDown(process.stdout);
|
|
137
|
+
if (selected) {
|
|
138
|
+
config.sessionID = selected.id;
|
|
139
|
+
saveConfig();
|
|
140
|
+
console.log(`Switched to session: ${selected.id.substring(0, 8)}...`);
|
|
141
|
+
if (selected.title) {
|
|
142
|
+
console.log(` Title: ${selected.title}`);
|
|
143
|
+
}
|
|
144
|
+
console.log();
|
|
145
|
+
await updateSessionTitle();
|
|
146
|
+
}
|
|
147
|
+
writePrompt();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
case "backspace": {
|
|
151
|
+
sessionSearchString = sessionSearchString.slice(0, -1);
|
|
152
|
+
updateSessionFilter();
|
|
153
|
+
selectedSessionIndex = 0;
|
|
154
|
+
renderSessionList();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (str && str.length === 1) {
|
|
160
|
+
sessionSearchString += str;
|
|
161
|
+
updateSessionFilter();
|
|
162
|
+
selectedSessionIndex = 0;
|
|
163
|
+
renderSessionList();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function createSession(client: OpencodeClient): Promise<string> {
|
|
169
|
+
const result = await client.session.create({
|
|
170
|
+
body: {},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (result.error) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Failed to create session (${result.response.status}): ${JSON.stringify(result.error)}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return result.data.id;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clearSessionList() {
|
|
183
|
+
process.stdout.write("\x1b[?25l");
|
|
184
|
+
if (sessionListLineCount > 0) {
|
|
185
|
+
process.stdout.write(`\x1b[${sessionListLineCount}A`);
|
|
186
|
+
}
|
|
187
|
+
readline.cursorTo(process.stdout, 0);
|
|
188
|
+
readline.clearScreenDown(process.stdout);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function renderSessionList(): void {
|
|
192
|
+
clearSessionList();
|
|
193
|
+
|
|
194
|
+
sessionListLineCount = 0;
|
|
195
|
+
console.log(" \x1b[36;1mAvailable Sessions\x1b[0m");
|
|
196
|
+
sessionListLineCount++;
|
|
197
|
+
|
|
198
|
+
if (sessionSearchString) {
|
|
199
|
+
console.log(` \x1b[90mFilter: \x1b[0m\x1b[33m${sessionSearchString}\x1b[0m`);
|
|
200
|
+
sessionListLineCount++;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const filteredSessions = sessionList.filter((_, i) => sessionFilteredIndices.includes(i));
|
|
204
|
+
const recentSessions = filteredSessions.slice(sessionListOffset, sessionListOffset + 10);
|
|
205
|
+
const groupedByDate = recentSessions.reduce(
|
|
206
|
+
(acc, session) => {
|
|
207
|
+
const date = new Date(session.updatedAt).toLocaleDateString();
|
|
208
|
+
if (!acc[date]) {
|
|
209
|
+
acc[date] = [];
|
|
210
|
+
}
|
|
211
|
+
acc[date].push(session);
|
|
212
|
+
return acc;
|
|
213
|
+
},
|
|
214
|
+
{} as Record<string, typeof recentSessions>,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
for (const [date, sessions] of Object.entries(groupedByDate)) {
|
|
218
|
+
console.log(` \x1b[90m${date}\x1b[0m`);
|
|
219
|
+
sessionListLineCount++;
|
|
220
|
+
|
|
221
|
+
for (const session of sessions) {
|
|
222
|
+
const globalIndex = sessionList.indexOf(session);
|
|
223
|
+
const filteredIndex = sessionFilteredIndices.indexOf(globalIndex);
|
|
224
|
+
const isSelected = filteredIndex === selectedSessionIndex;
|
|
225
|
+
const isActive = session.id === config.sessionID;
|
|
226
|
+
const prefix = isSelected ? " >" : " -";
|
|
227
|
+
const title = session.title || "(no title)";
|
|
228
|
+
const name = isSelected ? `\x1b[33;1m${title}\x1b[0m` : title;
|
|
229
|
+
const status = isActive ? " (active)" : "";
|
|
230
|
+
|
|
231
|
+
console.log(`${prefix} ${name}${status}`);
|
|
232
|
+
sessionListLineCount++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function updateSessionFilter(): void {
|
|
238
|
+
if (!sessionSearchString) {
|
|
239
|
+
sessionFilteredIndices = sessionList.map((_, i) => i);
|
|
240
|
+
} else {
|
|
241
|
+
const search = sessionSearchString.toLowerCase();
|
|
242
|
+
sessionFilteredIndices = sessionList
|
|
243
|
+
.map((session, i) => ({ session, index: i }))
|
|
244
|
+
.filter(
|
|
245
|
+
({ session }) =>
|
|
246
|
+
session.title?.toLowerCase().includes(search) ||
|
|
247
|
+
session.id.toLowerCase().includes(search),
|
|
248
|
+
)
|
|
249
|
+
.map(({ index }) => index);
|
|
250
|
+
}
|
|
251
|
+
if (sessionFilteredIndices.length > 0) {
|
|
252
|
+
selectedSessionIndex = sessionFilteredIndices.indexOf(
|
|
253
|
+
sessionList.findIndex((s) => s.id === config.sessionID),
|
|
254
|
+
);
|
|
255
|
+
if (selectedSessionIndex === -1) selectedSessionIndex = 0;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { OpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import { config } from "../config";
|
|
3
|
+
import type { State } from "../index";
|
|
4
|
+
import type { Command } from "../types";
|
|
5
|
+
|
|
6
|
+
let command: Command = {
|
|
7
|
+
name: "/undo",
|
|
8
|
+
description: "Undo changes for the last request",
|
|
9
|
+
run,
|
|
10
|
+
running: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default command;
|
|
14
|
+
|
|
15
|
+
async function run(client: OpencodeClient, _state: State): Promise<void> {
|
|
16
|
+
if (!config.sessionID) return;
|
|
17
|
+
|
|
18
|
+
console.log("Fetching session messages...");
|
|
19
|
+
|
|
20
|
+
const messagesRes = await client.session.messages({
|
|
21
|
+
path: { id: config.sessionID },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (messagesRes.error) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Failed to fetch messages (${messagesRes.response.status}): ${JSON.stringify(messagesRes.error)}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const messages = messagesRes.data;
|
|
31
|
+
|
|
32
|
+
if (!messages || messages.length === 0) {
|
|
33
|
+
console.log("No messages to undo.\n");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lastMessage = messages[messages.length - 1];
|
|
38
|
+
|
|
39
|
+
if (!lastMessage || !lastMessage.info) {
|
|
40
|
+
console.log("No valid message to undo.\n");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (lastMessage.info.role !== "assistant") {
|
|
45
|
+
console.log("Last message is not an AI response, nothing to undo.\n");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(`Reverting last assistant message (${lastMessage.info.id})...`);
|
|
50
|
+
|
|
51
|
+
const revertRes = await client.session.revert({
|
|
52
|
+
path: { id: config.sessionID },
|
|
53
|
+
body: {
|
|
54
|
+
messageID: lastMessage.info.id,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (revertRes.error) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Failed to revert message (${revertRes.response.status}): ${JSON.stringify(revertRes.error)}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log("Successfully reverted last message.\n");
|
|
65
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface Config {
|
|
5
|
+
providerID: string;
|
|
6
|
+
modelID: string;
|
|
7
|
+
agentID: string;
|
|
8
|
+
sessionID?: string;
|
|
9
|
+
loggingEnabled: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ENV_VAR = "OPENCODE_MT_CONFIG_CONTENT";
|
|
13
|
+
|
|
14
|
+
export const config: Config = {
|
|
15
|
+
providerID: "opencode",
|
|
16
|
+
modelID: "big-pickle",
|
|
17
|
+
agentID: "build",
|
|
18
|
+
loggingEnabled: false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const CONFIG_PATH = `${process.env.HOME}/.config/opencode-miniterm/opencode-miniterm.json`;
|
|
22
|
+
|
|
23
|
+
export function loadConfig(): void {
|
|
24
|
+
const content = process.env[ENV_VAR];
|
|
25
|
+
if (content) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(content) as Partial<Config>;
|
|
28
|
+
Object.assign(config, parsed);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error("Failed to parse config from env var:", e instanceof Error ? e.message : e);
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
if (existsSync(CONFIG_PATH)) {
|
|
34
|
+
try {
|
|
35
|
+
const fileContent = readFileSync(CONFIG_PATH, "utf-8");
|
|
36
|
+
const parsed = JSON.parse(fileContent) as Partial<Config>;
|
|
37
|
+
Object.assign(config, parsed);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error("Failed to parse config from file:", e instanceof Error ? e.message : e);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function saveConfig(): void {
|
|
46
|
+
process.env[ENV_VAR] = JSON.stringify(config);
|
|
47
|
+
const configDir = dirname(CONFIG_PATH);
|
|
48
|
+
if (!existsSync(configDir)) {
|
|
49
|
+
mkdirSync(configDir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error("Failed to save config to file:", e instanceof Error ? e.message : e);
|
|
55
|
+
}
|
|
56
|
+
}
|