opencode-miniterm 1.0.14 → 1.0.15

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/src/server.ts ADDED
@@ -0,0 +1,399 @@
1
+ import { createOpencodeClient } from "@opencode-ai/sdk";
2
+ import type { Event, FileDiff, Part, Todo, ToolPart } from "@opencode-ai/sdk";
3
+ import * as ansi from "./ansi";
4
+ import { config } from "./config";
5
+ import { closeLogFile, createLogFile, writeToLog } from "./logs";
6
+ import { render, setTerminalTitle, stopAnimation, writePrompt } from "./render";
7
+ import type { State } from "./types";
8
+ import { formatDuration } from "./utils";
9
+
10
+ const SERVER_URL = "http://127.0.0.1:4096";
11
+ const AUTH_USERNAME = process.env.OPENCODE_SERVER_USERNAME || "opencode";
12
+ const AUTH_PASSWORD = process.env.OPENCODE_SERVER_PASSWORD || "";
13
+
14
+ let processing = true;
15
+ let retryInterval: ReturnType<typeof setInterval> | null = null;
16
+
17
+ export function createClient(cwd: string): ReturnType<typeof createOpencodeClient> {
18
+ return createOpencodeClient({
19
+ baseUrl: SERVER_URL,
20
+ headers: AUTH_PASSWORD
21
+ ? {
22
+ Authorization: `Basic ${Buffer.from(`${AUTH_USERNAME}:${AUTH_PASSWORD}`).toString("base64")}`,
23
+ }
24
+ : undefined,
25
+ directory: cwd,
26
+ });
27
+ }
28
+
29
+ export async function createSession(state: State): Promise<string> {
30
+ const result = await state.client.session.create({
31
+ body: {},
32
+ });
33
+
34
+ if (result.error) {
35
+ if (result.response.status === 401 && !AUTH_PASSWORD) {
36
+ throw new Error(
37
+ "Server requires authentication. Set OPENCODE_SERVER_PASSWORD environment variable.",
38
+ );
39
+ }
40
+ throw new Error(
41
+ `Failed to create session (${result.response.status}): ${JSON.stringify(result.error)}`,
42
+ );
43
+ }
44
+
45
+ return result.data.id;
46
+ }
47
+
48
+ export async function validateSession(state: State, sessionID: string): Promise<boolean> {
49
+ try {
50
+ const result = await state.client.session.get({
51
+ path: { id: sessionID },
52
+ });
53
+ return !result.error && result.response.status === 200;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ export async function startEventListener(state: State): Promise<void> {
60
+ try {
61
+ const { stream } = await state.client.event.subscribe({
62
+ onSseError: (error) => {
63
+ console.error(
64
+ `\n${ansi.RED}Connection error:${ansi.RESET}`,
65
+ error instanceof Error ? error.message : String(error),
66
+ );
67
+ },
68
+ });
69
+
70
+ for await (const event of stream) {
71
+ try {
72
+ await processEvent(state, event);
73
+ } catch (error) {
74
+ console.error(
75
+ `\n${ansi.RED}Event processing error:${ansi.RESET}`,
76
+ error instanceof Error ? error.message : String(error),
77
+ );
78
+ }
79
+ }
80
+ } catch (error) {
81
+ console.error(
82
+ `\n${ansi.RED}Failed to connect to event stream:${ansi.RESET}`,
83
+ error instanceof Error ? error.message : String(error),
84
+ );
85
+ }
86
+ }
87
+
88
+ export async function sendMessage(state: State, message: string) {
89
+ processing = false;
90
+ state.accumulatedResponse = [];
91
+ state.allEvents = [];
92
+ state.renderedLines = [];
93
+
94
+ await createLogFile();
95
+
96
+ await writeToLog(`User: ${message}\n\n`);
97
+
98
+ const requestStartTime = Date.now();
99
+
100
+ try {
101
+ const result = await state.client.session.prompt({
102
+ path: { id: state.sessionID },
103
+ body: {
104
+ model: {
105
+ providerID: config.providerID,
106
+ modelID: config.modelID,
107
+ },
108
+ parts: [{ type: "text", text: message }],
109
+ },
110
+ });
111
+
112
+ if (result.error) {
113
+ throw new Error(
114
+ `Failed to send message (${result.response.status}): ${JSON.stringify(result.error)}`,
115
+ );
116
+ }
117
+
118
+ // Play a chime when request is completed
119
+ process.stdout.write("\x07");
120
+
121
+ stopAnimation();
122
+
123
+ const duration = Date.now() - requestStartTime;
124
+ const durationText = formatDuration(duration, true);
125
+ console.log(` ${ansi.BRIGHT_BLACK}Completed in ${durationText}${ansi.RESET}\n`);
126
+
127
+ writePrompt();
128
+ } catch (error: any) {
129
+ throw error;
130
+ } finally {
131
+ await closeLogFile();
132
+ }
133
+ }
134
+
135
+ async function processEvent(state: State, event: Event): Promise<void> {
136
+ if (retryInterval && event.type !== "session.status") {
137
+ clearInterval(retryInterval);
138
+ retryInterval = null;
139
+ }
140
+
141
+ state.allEvents.push(event);
142
+
143
+ switch (event.type) {
144
+ case "message.part.updated": {
145
+ const part = event.properties.part;
146
+ const delta = event.properties.delta;
147
+ if (part) {
148
+ await processPart(state, part);
149
+ }
150
+ if (delta !== undefined && part) {
151
+ processDelta(state, part.id, delta);
152
+ }
153
+ break;
154
+ }
155
+
156
+ // @ts-ignore this definitely exists
157
+ case "message.part.delta": {
158
+ // @ts-ignore
159
+ const partID = event.properties.partID;
160
+ // @ts-ignore
161
+ const delta = event.properties.delta;
162
+ if (partID !== undefined && delta !== undefined) {
163
+ processDelta(state, partID, delta);
164
+ }
165
+ break;
166
+ }
167
+
168
+ case "session.diff": {
169
+ const diff = event.properties.diff;
170
+ if (diff && diff.length > 0) {
171
+ await processDiff(state, diff);
172
+ }
173
+ break;
174
+ }
175
+
176
+ case "session.idle":
177
+ case "session.status":
178
+ if (event.type === "session.status" && event.properties.status.type === "idle") {
179
+ stopAnimation();
180
+ // TODO: isRequestActive = false;
181
+ process.stdout.write(ansi.CURSOR_SHOW);
182
+ if (retryInterval) {
183
+ clearInterval(retryInterval);
184
+ retryInterval = null;
185
+ }
186
+ writePrompt();
187
+ }
188
+ if (event.type === "session.status" && event.properties.status.type === "retry") {
189
+ const message = event.properties.status.message;
190
+ const retryTime = event.properties.status.next;
191
+ const sessionID = event.properties.sessionID;
192
+ console.error(`\n\n ${ansi.RED}Error:${ansi.RESET} ${message}`);
193
+ console.error(` ${ansi.BRIGHT_BLACK}Session:${ansi.RESET} ${sessionID}`);
194
+ if (retryTime) {
195
+ if (retryInterval) {
196
+ clearInterval(retryInterval);
197
+ }
198
+ const retryDate = new Date(retryTime);
199
+
200
+ let lastSeconds = Math.max(0, Math.ceil((retryDate.getTime() - Date.now()) / 1000));
201
+ console.error(` ${ansi.BRIGHT_BLACK}Retrying in ${lastSeconds}s...${ansi.RESET}`);
202
+
203
+ retryInterval = setInterval(() => {
204
+ const remaining = Math.max(0, Math.ceil((retryDate.getTime() - Date.now()) / 1000));
205
+ if (remaining !== lastSeconds) {
206
+ process.stdout.write(
207
+ `\r ${ansi.BRIGHT_BLACK}Retrying in ${remaining}s...${ansi.RESET}`,
208
+ );
209
+ lastSeconds = remaining;
210
+ }
211
+ if (remaining === 0) {
212
+ if (retryInterval) {
213
+ clearInterval(retryInterval);
214
+ retryInterval = null;
215
+ }
216
+ }
217
+ }, 100);
218
+ }
219
+ }
220
+ break;
221
+
222
+ case "session.updated": {
223
+ const session = event.properties.info;
224
+ if (session && session.id === state.sessionID && session.title) {
225
+ setTerminalTitle(session.title);
226
+ }
227
+ break;
228
+ }
229
+
230
+ case "todo.updated": {
231
+ const todos = event.properties.todos;
232
+ if (todos) {
233
+ await processTodos(state, todos);
234
+ }
235
+
236
+ break;
237
+ }
238
+
239
+ default:
240
+ break;
241
+ }
242
+ }
243
+
244
+ async function processPart(state: State, part: Part): Promise<void> {
245
+ switch (part.type) {
246
+ case "step-start":
247
+ processStepStart();
248
+ break;
249
+
250
+ case "reasoning":
251
+ processReasoning(state, part);
252
+ break;
253
+
254
+ case "text":
255
+ if (processing) {
256
+ processText(state, part);
257
+ }
258
+ break;
259
+
260
+ case "step-finish":
261
+ break;
262
+
263
+ case "tool":
264
+ processToolUse(state, part);
265
+ break;
266
+
267
+ default:
268
+ break;
269
+ }
270
+ }
271
+
272
+ function processStepStart() {
273
+ processing = true;
274
+ }
275
+
276
+ async function processReasoning(state: State, part: Part) {
277
+ processing = true;
278
+ let thinkingPart = findLastPart(state, part.id);
279
+ if (!thinkingPart) {
280
+ thinkingPart = { key: part.id, title: "thinking", text: (part as any).text || "" };
281
+ state.accumulatedResponse.push(thinkingPart);
282
+ } else {
283
+ thinkingPart.text = (part as any).text || "";
284
+ }
285
+
286
+ const text = (part as any).text || "";
287
+ const cleanText = ansi.stripAnsiCodes(text.trimStart());
288
+ await writeToLog(`Thinking:\n\n${cleanText}\n\n`);
289
+
290
+ render(state);
291
+ }
292
+
293
+ async function processText(state: State, part: Part) {
294
+ let responsePart = findLastPart(state, part.id);
295
+ if (!responsePart) {
296
+ responsePart = { key: part.id, title: "response", text: (part as any).text || "" };
297
+ state.accumulatedResponse.push(responsePart);
298
+ } else {
299
+ responsePart.text = (part as any).text || "";
300
+ }
301
+
302
+ const text = (part as any).text || "";
303
+ const cleanText = ansi.stripAnsiCodes(text.trimStart());
304
+ await writeToLog(`Response:\n\n${cleanText}\n\n`);
305
+
306
+ render(state);
307
+ }
308
+
309
+ async function processToolUse(state: State, part: Part) {
310
+ const toolPart = part as ToolPart;
311
+ const toolName = toolPart.tool || "unknown";
312
+ const toolInput =
313
+ toolPart.state.input["description"] ||
314
+ toolPart.state.input["filePath"] ||
315
+ toolPart.state.input["path"] ||
316
+ toolPart.state.input["include"] ||
317
+ toolPart.state.input["pattern"] ||
318
+ // TODO: more state.input props?
319
+ "...";
320
+ const toolText = `$ ${toolName}: ${ansi.BRIGHT_BLACK}${toolInput}${ansi.RESET}`;
321
+
322
+ if (state.accumulatedResponse[state.accumulatedResponse.length - 1]?.title === "tool") {
323
+ state.accumulatedResponse[state.accumulatedResponse.length - 1]!.text = toolText;
324
+ } else {
325
+ state.accumulatedResponse.push({ key: part.id, title: "tool", text: toolText });
326
+ }
327
+
328
+ const cleanToolText = ansi.stripAnsiCodes(toolText);
329
+ await writeToLog(`$ ${cleanToolText}\n\n`);
330
+
331
+ render(state);
332
+ }
333
+
334
+ function processDelta(state: State, partID: string, delta: string) {
335
+ let responsePart = findLastPart(state, partID);
336
+ if (responsePart) {
337
+ responsePart.text += delta;
338
+ }
339
+
340
+ render(state);
341
+ }
342
+
343
+ async function processDiff(state: State, diff: FileDiff[]) {
344
+ const parts: string[] = [];
345
+ for (const file of diff) {
346
+ const newAfter = file.after ?? "";
347
+ const oldAfter = state.lastFileAfter.get(file.file);
348
+ if (newAfter !== oldAfter) {
349
+ const statusIcon = !file.before ? "A" : !file.after ? "D" : "M";
350
+ const addStr = file.additions > 0 ? `${ansi.GREEN}+${file.additions}${ansi.RESET}` : "";
351
+ const delStr = file.deletions > 0 ? `${ansi.RED}-${file.deletions}${ansi.RESET}` : "";
352
+ const stats = [addStr, delStr].filter(Boolean).join(" ");
353
+ const line = `${ansi.BLUE}${statusIcon}${ansi.RESET} ${file.file} ${stats}`;
354
+ parts.push(line);
355
+
356
+ state.lastFileAfter.set(file.file, newAfter);
357
+ }
358
+ }
359
+
360
+ if (parts.length > 0) {
361
+ state.accumulatedResponse.push({ key: "diff", title: "files", text: parts.join("\n") });
362
+
363
+ const diffText = ansi.stripAnsiCodes(parts.join("\n"));
364
+ await writeToLog(`${diffText}\n\n`);
365
+
366
+ render(state);
367
+ }
368
+ }
369
+
370
+ async function processTodos(state: State, todos: Todo[]) {
371
+ let todoListText = "Todo:\n";
372
+
373
+ for (let todo of todos) {
374
+ let todoText = "";
375
+ if (todo.status === "completed") {
376
+ todoText += "- [✓] ";
377
+ } else {
378
+ todoText += "- [ ] ";
379
+ }
380
+ todoText += todo.content;
381
+ todoListText += todoText + "\n";
382
+ }
383
+
384
+ state.accumulatedResponse.push({ key: "todo", title: "files", text: todoListText });
385
+
386
+ const cleanTodoText = ansi.stripAnsiCodes(todoListText);
387
+ await writeToLog(`${cleanTodoText}\n`);
388
+
389
+ render(state);
390
+ }
391
+
392
+ function findLastPart(state: State, title: string) {
393
+ for (let i = state.accumulatedResponse.length - 1; i >= 0; i--) {
394
+ const part = state.accumulatedResponse[i];
395
+ if (part?.key === title) {
396
+ return part;
397
+ }
398
+ }
399
+ }
package/src/types.ts CHANGED
@@ -1,11 +1,30 @@
1
- import type { OpencodeClient } from "@opencode-ai/sdk";
1
+ import { createOpencodeClient } from "@opencode-ai/sdk";
2
+ import type { Event, OpencodeClient } from "@opencode-ai/sdk";
2
3
  import type { Key } from "node:readline";
3
- import type { State } from "./index";
4
4
 
5
5
  export interface Command {
6
6
  name: string;
7
7
  description: string;
8
- run: (client: OpencodeClient, state: State, input?: string) => Promise<void> | void;
9
- handleKey?: (client: OpencodeClient, key: Key, input?: string) => Promise<void> | void;
8
+ run: (state: State, input?: string) => Promise<void> | void;
9
+ handleKey?: (state: State, key: Key, input?: string) => Promise<void> | void;
10
10
  running: boolean;
11
11
  }
12
+
13
+ export interface State {
14
+ client: ReturnType<typeof createOpencodeClient>;
15
+ sessionID: string;
16
+ renderedLines: string[];
17
+ accumulatedResponse: AccumulatedPart[];
18
+ allEvents: Event[];
19
+ lastFileAfter: Map<string, string>;
20
+ write: (text: string) => void;
21
+ shutdown: () => void;
22
+ }
23
+
24
+ interface AccumulatedPart {
25
+ key: string;
26
+ title: "thinking" | "response" | "tool" | "files" | "todo";
27
+ text: string;
28
+ active?: boolean;
29
+ durationMs?: number;
30
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,17 @@
1
+ export function formatDuration(ms: number, completed = false): string {
2
+ if (completed && ms < 1000) {
3
+ return `${ms}ms`;
4
+ }
5
+ const seconds = ms / 1000;
6
+ if (seconds < 60) {
7
+ return completed ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds)}s`;
8
+ }
9
+ const minutes = Math.floor(seconds / 60);
10
+ const remainingSeconds = Math.round(seconds % 60);
11
+ if (minutes < 60) {
12
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
13
+ }
14
+ const hours = Math.floor(minutes / 60);
15
+ const remainingMinutes = minutes % 60;
16
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
17
+ }
@@ -1,11 +1,13 @@
1
1
  import { stripANSI } from "bun";
2
2
  import { describe, expect, it, vi } from "bun:test";
3
- import { type State } from "../src";
4
3
  import * as ansi from "../src/ansi";
5
4
  import { render, wrapText } from "../src/render";
5
+ import type { State } from "../src/types";
6
6
 
7
7
  describe("render", () => {
8
8
  const createMockState = (overrides?: Partial<State>): State => ({
9
+ // @ts-ignore this doesn't get used in any of the test methods
10
+ client: null,
9
11
  sessionID: "",
10
12
  renderedLines: [],
11
13
  accumulatedResponse: [],
@@ -35,7 +37,7 @@ describe("render", () => {
35
37
 
36
38
  render(state);
37
39
 
38
- expect(write).toHaveBeenCalledWith("\x1b[5A\x1b[J");
40
+ expect(write).toHaveBeenCalledWith("\x1b[5A\x1b[0J");
39
41
  });
40
42
 
41
43
  it("should clear previous accumulated parts", () => {