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/index.ts CHANGED
@@ -1,89 +1,28 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { createOpencodeClient, createOpencodeServer } from "@opencode-ai/sdk";
4
- import type { Event, FileDiff, Part, Todo, ToolPart } from "@opencode-ai/sdk";
5
- import { mkdir } from "node:fs/promises";
6
- import { glob } from "node:fs/promises";
7
- import { stat } from "node:fs/promises";
8
- import { open } from "node:fs/promises";
9
- import readline, { type Key } from "node:readline";
3
+ import { createOpencodeServer } from "@opencode-ai/sdk";
4
+ import readline from "node:readline";
10
5
  import * as ansi from "./ansi";
11
- import agentsCommand from "./commands/agents";
12
- import debugCommand from "./commands/debug";
13
- import detailsCommand from "./commands/details";
14
- import diffCommand from "./commands/diff";
15
- import exitCommand from "./commands/exit";
16
- import initCommand from "./commands/init";
17
- import logCommand, { isLoggingEnabled } from "./commands/log";
18
- import modelsCommand from "./commands/models";
19
- import newCommand from "./commands/new";
20
- import pageCommand from "./commands/page";
21
- import quitCommand from "./commands/quit";
22
- import runCommand from "./commands/run";
23
- import sessionsCommand from "./commands/sessions";
24
- import undoCommand from "./commands/undo";
25
6
  import { config, loadConfig, saveConfig } from "./config";
26
- import { getActiveDisplay, render, startAnimation, stopAnimation, writePrompt } from "./render";
27
-
28
- const SERVER_URL = "http://127.0.0.1:4096";
29
- const AUTH_USERNAME = process.env.OPENCODE_SERVER_USERNAME || "opencode";
30
- const AUTH_PASSWORD = process.env.OPENCODE_SERVER_PASSWORD || "";
31
-
32
- const SLASH_COMMANDS = [
33
- initCommand,
34
- agentsCommand,
35
- modelsCommand,
36
- sessionsCommand,
37
- newCommand,
38
- undoCommand,
39
- detailsCommand,
40
- diffCommand,
41
- debugCommand,
42
- logCommand,
43
- pageCommand,
44
- exitCommand,
45
- quitCommand,
46
- runCommand,
47
- ];
7
+ import { handleKeyPress, loadSessionHistory } from "./input";
8
+ import { getActiveDisplay, updateSessionTitle, writePrompt } from "./render";
9
+ import { createClient, createSession, startEventListener, validateSession } from "./server";
10
+ import type { State } from "./types";
48
11
 
49
12
  let server: Awaited<ReturnType<typeof createOpencodeServer>> | undefined;
50
- let client: ReturnType<typeof createOpencodeClient>;
51
-
52
- let processing = true;
53
- let retryInterval: ReturnType<typeof setInterval> | null = null;
54
- let isRequestActive = false;
55
-
56
- interface AccumulatedPart {
57
- key: string;
58
- title: "thinking" | "response" | "tool" | "files" | "todo";
59
- text: string;
60
- active?: boolean;
61
- durationMs?: number;
62
- }
63
-
64
- export interface State {
65
- sessionID: string;
66
- renderedLines: string[];
67
- accumulatedResponse: AccumulatedPart[];
68
- allEvents: Event[];
69
- write: (text: string) => void;
70
- lastFileAfter: Map<string, string>;
71
- }
72
-
73
- export { updateSessionTitle, setTerminalTitle };
74
13
 
75
14
  let state: State = {
15
+ // @ts-ignore This will get set
16
+ client: null,
76
17
  sessionID: "",
77
18
  renderedLines: [],
78
19
  accumulatedResponse: [],
79
20
  allEvents: [],
80
- write: (text) => process.stdout.write(text),
81
21
  lastFileAfter: new Map(),
22
+ write: (text) => process.stdout.write(text),
23
+ shutdown,
82
24
  };
83
25
 
84
- let logFile: Awaited<ReturnType<typeof open>> | null = null;
85
- let logFilePath: string | null = null;
86
-
87
26
  // ====================
88
27
  // MAIN ENTRY POINT
89
28
  // ====================
@@ -101,15 +40,7 @@ async function main() {
101
40
  }
102
41
 
103
42
  const cwd = process.cwd();
104
- client = createOpencodeClient({
105
- baseUrl: SERVER_URL,
106
- headers: AUTH_PASSWORD
107
- ? {
108
- Authorization: `Basic ${Buffer.from(`${AUTH_USERNAME}:${AUTH_PASSWORD}`).toString("base64")}`,
109
- }
110
- : undefined,
111
- directory: cwd,
112
- });
43
+ state.client = createClient(cwd);
113
44
 
114
45
  process.on("SIGINT", () => {
115
46
  process.stdout.write("\n");
@@ -120,8 +51,8 @@ async function main() {
120
51
  let isNewSession = false;
121
52
 
122
53
  const initialSessionID = config.sessionIDs[cwd];
123
- if (!initialSessionID || !(await validateSession(initialSessionID))) {
124
- state.sessionID = await createSession();
54
+ if (!initialSessionID || !(await validateSession(state, initialSessionID))) {
55
+ state.sessionID = await createSession(state);
125
56
  isNewSession = true;
126
57
  config.sessionIDs[cwd] = state.sessionID;
127
58
  saveConfig();
@@ -129,15 +60,15 @@ async function main() {
129
60
  state.sessionID = initialSessionID;
130
61
  }
131
62
 
132
- startEventListener();
63
+ startEventListener(state);
133
64
 
134
- await updateSessionTitle();
65
+ await updateSessionTitle(state);
135
66
 
136
- history = await loadSessionHistory();
67
+ await loadSessionHistory(state);
137
68
 
138
69
  process.stdout.write(`${ansi.CLEAR_SCREEN_UP}${ansi.CLEAR_FROM_CURSOR}`);
139
70
  process.stdout.write(ansi.CURSOR_HOME);
140
- const activeDisplay = await getActiveDisplay(client);
71
+ const activeDisplay = await getActiveDisplay(state.client);
141
72
  console.log(activeDisplay);
142
73
  if (!isNewSession) {
143
74
  console.log("Resumed last session");
@@ -145,7 +76,7 @@ async function main() {
145
76
  console.log();
146
77
  console.log(`${ansi.BRIGHT_BLACK}Ask anything...${ansi.RESET}\n`);
147
78
 
148
- const rl = readline.createInterface({
79
+ const _rl = readline.createInterface({
149
80
  input: process.stdin,
150
81
  output: undefined,
151
82
  });
@@ -157,7 +88,7 @@ async function main() {
157
88
  process.stdout.write(ansi.DISABLE_LINE_WRAP);
158
89
 
159
90
  process.stdin.on("keypress", async (str, key) => {
160
- handleKeyPress(str, key);
91
+ handleKeyPress(state, str, key);
161
92
  });
162
93
 
163
94
  writePrompt();
@@ -168,760 +99,6 @@ async function main() {
168
99
  }
169
100
  }
170
101
 
171
- // ====================
172
- // HANDLE INPUT
173
- // ====================
174
-
175
- let inputBuffer = "";
176
- let cursorPosition = 0;
177
- let completions: string[] = [];
178
- let history: string[] = [];
179
- let historyIndex = history.length;
180
- let selectedCompletion = 0;
181
- let completionCycling = false;
182
- let lastSpaceTime = 0;
183
- let currentInputBuffer: string | null = null;
184
-
185
- let oldInputBuffer = "";
186
- let oldWrappedRows = 0;
187
- let oldCursorRow = 0;
188
- function renderLine(): void {
189
- const consoleWidth = process.stdout.columns || 80;
190
-
191
- // Move to the start of the line (i.e. the prompt position)
192
- readline.cursorTo(process.stdout, 0);
193
- if (oldWrappedRows > 0) {
194
- if (cursorPosition < inputBuffer.length) {
195
- readline.moveCursor(process.stdout, 0, oldWrappedRows - oldCursorRow);
196
- }
197
- readline.moveCursor(process.stdout, 0, -oldWrappedRows);
198
- }
199
-
200
- // Find the position where the input has changed (i.e. where the user has
201
- // typed something)
202
- let start = 0;
203
- let currentCol = 2;
204
- let newWrappedRows = 0;
205
- for (let i = 0; i < Math.min(oldInputBuffer.length, inputBuffer.length); i++) {
206
- if (oldInputBuffer[i] !== inputBuffer[i]) {
207
- break;
208
- }
209
- if (currentCol >= consoleWidth) {
210
- readline.moveCursor(process.stdout, 0, 1);
211
- currentCol = 0;
212
- newWrappedRows++;
213
- }
214
- currentCol++;
215
- start++;
216
- }
217
-
218
- // Clear the old, changed, input
219
- readline.cursorTo(process.stdout, currentCol);
220
- readline.clearScreenDown(process.stdout);
221
-
222
- // Write the prompt if this is a fresh buffer
223
- if (start === 0) {
224
- readline.cursorTo(process.stdout, 0);
225
- writePrompt();
226
- readline.cursorTo(process.stdout, 2);
227
- }
228
-
229
- // Write the changes from the new input buffer
230
- let renderExtent = Math.max(cursorPosition + 1, inputBuffer.length);
231
- for (let i = start; i < renderExtent; i++) {
232
- if (currentCol >= consoleWidth) {
233
- process.stdout.write("\n");
234
- currentCol = 0;
235
- newWrappedRows++;
236
- }
237
- if (i < inputBuffer.length) {
238
- process.stdout.write(inputBuffer[i]!);
239
- }
240
- currentCol++;
241
- }
242
-
243
- // Calculate and move to the cursor's position
244
- let absolutePos = 2 + cursorPosition;
245
- let newCursorRow = Math.floor(absolutePos / consoleWidth);
246
- let newCursorCol = absolutePos % consoleWidth;
247
- readline.cursorTo(process.stdout, 0);
248
- readline.moveCursor(process.stdout, 0, -1 * (newWrappedRows - newCursorRow));
249
- readline.cursorTo(process.stdout, newCursorCol);
250
-
251
- oldInputBuffer = inputBuffer;
252
- oldWrappedRows = newWrappedRows;
253
- oldCursorRow = newCursorRow;
254
- }
255
-
256
- async function handleKeyPress(str: string, key: Key) {
257
- if (key.ctrl && key.name === "c") {
258
- process.stdout.write("\n");
259
- shutdown();
260
- return;
261
- }
262
-
263
- for (let command of SLASH_COMMANDS) {
264
- if (command.running && command.handleKey) {
265
- await command.handleKey(client, key, str);
266
- return;
267
- }
268
- }
269
-
270
- switch (key.name) {
271
- case "up": {
272
- if (historyIndex === history.length) {
273
- currentInputBuffer = inputBuffer;
274
- }
275
- if (history.length > 0) {
276
- if (historyIndex > 0) {
277
- historyIndex--;
278
- inputBuffer = history[historyIndex]!;
279
- } else {
280
- historyIndex = Math.max(-1, historyIndex - 1);
281
- inputBuffer = "";
282
- }
283
- cursorPosition = inputBuffer.length;
284
- renderLine();
285
- }
286
- return;
287
- }
288
- case "down": {
289
- if (history.length > 0) {
290
- if (historyIndex < history.length - 1) {
291
- historyIndex++;
292
- inputBuffer = history[historyIndex]!;
293
- } else {
294
- historyIndex = history.length;
295
- inputBuffer = currentInputBuffer || "";
296
- currentInputBuffer = null;
297
- }
298
- cursorPosition = inputBuffer.length;
299
- renderLine();
300
- }
301
- return;
302
- }
303
- case "tab": {
304
- if (!completionCycling) {
305
- await handleTab();
306
- }
307
- if (completionCycling && completions.length > 0) {
308
- await handleTab();
309
- }
310
- return;
311
- }
312
- case "escape": {
313
- if (isRequestActive) {
314
- if (state.sessionID) {
315
- client.session.abort({ path: { id: state.sessionID } }).catch(() => {});
316
- }
317
- stopAnimation();
318
- process.stdout.write(ansi.CURSOR_SHOW);
319
- process.stdout.write(`\r ${ansi.BRIGHT_BLACK}Cancelled request${ansi.RESET}\n`);
320
- writePrompt();
321
- isRequestActive = false;
322
- } else {
323
- inputBuffer = "";
324
- cursorPosition = 0;
325
- currentInputBuffer = null;
326
- renderLine();
327
- }
328
- return;
329
- }
330
- case "return": {
331
- await acceptInput();
332
- return;
333
- }
334
- case "backspace": {
335
- if (cursorPosition > 0) {
336
- inputBuffer = inputBuffer.slice(0, cursorPosition - 1) + inputBuffer.slice(cursorPosition);
337
- cursorPosition--;
338
- currentInputBuffer = null;
339
- }
340
- break;
341
- }
342
- case "delete": {
343
- if (cursorPosition < inputBuffer.length) {
344
- inputBuffer = inputBuffer.slice(0, cursorPosition) + inputBuffer.slice(cursorPosition + 1);
345
- currentInputBuffer = null;
346
- }
347
- break;
348
- }
349
- case "left": {
350
- if (key.meta) {
351
- cursorPosition = findPreviousWordBoundary(inputBuffer, cursorPosition);
352
- } else if (cursorPosition > 0) {
353
- cursorPosition--;
354
- }
355
- break;
356
- }
357
- case "right": {
358
- if (key.meta) {
359
- cursorPosition = findNextWordBoundary(inputBuffer, cursorPosition);
360
- } else if (cursorPosition < inputBuffer.length) {
361
- cursorPosition++;
362
- }
363
- break;
364
- }
365
- default: {
366
- if (str === " ") {
367
- const now = Date.now();
368
- if (
369
- now - lastSpaceTime < 500 &&
370
- cursorPosition > 0 &&
371
- inputBuffer[cursorPosition - 1] === " "
372
- ) {
373
- inputBuffer =
374
- inputBuffer.slice(0, cursorPosition - 1) + ". " + inputBuffer.slice(cursorPosition);
375
- cursorPosition += 1;
376
- } else {
377
- inputBuffer =
378
- inputBuffer.slice(0, cursorPosition) + str + inputBuffer.slice(cursorPosition);
379
- cursorPosition += str.length;
380
- }
381
- lastSpaceTime = now;
382
- } else if (str) {
383
- inputBuffer =
384
- inputBuffer.slice(0, cursorPosition) + str + inputBuffer.slice(cursorPosition);
385
- cursorPosition += str.length;
386
- }
387
- currentInputBuffer = null;
388
- }
389
- }
390
-
391
- completionCycling = false;
392
- completions = [];
393
- renderLine();
394
- }
395
-
396
- async function handleTab(): Promise<void> {
397
- const potentialCompletions = await getCompletions(inputBuffer);
398
-
399
- if (potentialCompletions.length === 0) {
400
- completionCycling = false;
401
- return;
402
- }
403
-
404
- if (!completionCycling) {
405
- completions = potentialCompletions;
406
- selectedCompletion = 0;
407
- completionCycling = true;
408
- inputBuffer = completions[0]!;
409
- cursorPosition = inputBuffer.length;
410
- renderLine();
411
- } else {
412
- selectedCompletion = (selectedCompletion + 1) % completions.length;
413
- inputBuffer = completions[selectedCompletion]!;
414
- cursorPosition = inputBuffer.length;
415
- renderLine();
416
- }
417
- }
418
-
419
- async function getCompletions(text: string): Promise<string[]> {
420
- if (text.startsWith("/")) {
421
- return ["/help", ...SLASH_COMMANDS.map((c) => c.name)].filter((cmd) => cmd.startsWith(text));
422
- }
423
-
424
- const atMatch = text.match(/(@[^\s]*)$/);
425
- if (atMatch) {
426
- const prefix = atMatch[0]!;
427
- const searchPattern = prefix.slice(1);
428
- const pattern = searchPattern.includes("/") ? searchPattern + "*" : "**/" + searchPattern + "*";
429
- const files = await getFileCompletions(pattern);
430
- return files.map((file: string) => text.replace(/@[^\s]*$/, "@" + file));
431
- }
432
-
433
- return [];
434
- }
435
-
436
- async function acceptInput(): Promise<void> {
437
- process.stdout.write("\n");
438
-
439
- const input = inputBuffer.trim();
440
-
441
- oldInputBuffer = "";
442
- inputBuffer = "";
443
- cursorPosition = 0;
444
- completionCycling = false;
445
- completions = [];
446
- currentInputBuffer = null;
447
-
448
- if (input) {
449
- if (history[history.length - 1] !== input) {
450
- history.push(input);
451
- }
452
- historyIndex = history.length;
453
- try {
454
- if (input === "/help") {
455
- process.stdout.write("\n");
456
- const maxCommandLength = Math.max(...SLASH_COMMANDS.map((c) => c.name.length));
457
- for (const cmd of SLASH_COMMANDS) {
458
- const padding = " ".repeat(maxCommandLength - cmd.name.length + 2);
459
- console.log(
460
- ` ${ansi.BRIGHT_WHITE}${cmd.name}${ansi.RESET}${padding}${ansi.BRIGHT_BLACK}${cmd.description}${ansi.RESET}`,
461
- );
462
- }
463
- console.log();
464
- writePrompt();
465
- return;
466
- } else if (input.startsWith("/")) {
467
- const parts = input.match(/(\/[^\s]+)\s*(.*)/)!;
468
- if (parts) {
469
- const commandName = parts[1];
470
- const extra = parts[2]?.trim();
471
- for (let command of SLASH_COMMANDS) {
472
- if (command.name === commandName) {
473
- process.stdout.write("\n");
474
- await command.run(client, state, extra);
475
- writePrompt();
476
- return;
477
- }
478
- }
479
- }
480
- return;
481
- }
482
-
483
- isRequestActive = true;
484
- process.stdout.write("\n");
485
- process.stdout.write(ansi.CURSOR_HIDE);
486
- startAnimation();
487
- if (isLoggingEnabled()) {
488
- console.log(`📝 ${ansi.BRIGHT_BLACK}Logging to ${getLogDir()}\n${ansi.RESET}`);
489
- }
490
- await sendMessage(state.sessionID, input);
491
- isRequestActive = false;
492
- } catch (error: any) {
493
- isRequestActive = false;
494
- if (error.message !== "Request cancelled") {
495
- stopAnimation();
496
- console.error("Error:", error.message);
497
- }
498
- }
499
- }
500
- }
501
-
502
- // ====================
503
- // SERVER COMMUNICATION
504
- // ====================
505
-
506
- async function createSession(): Promise<string> {
507
- const result = await client.session.create({
508
- body: {},
509
- });
510
-
511
- if (result.error) {
512
- if (result.response.status === 401 && !AUTH_PASSWORD) {
513
- throw new Error(
514
- "Server requires authentication. Set OPENCODE_SERVER_PASSWORD environment variable.",
515
- );
516
- }
517
- throw new Error(
518
- `Failed to create session (${result.response.status}): ${JSON.stringify(result.error)}`,
519
- );
520
- }
521
-
522
- return result.data.id;
523
- }
524
-
525
- async function validateSession(sessionID: string): Promise<boolean> {
526
- try {
527
- const result = await client.session.get({
528
- path: { id: sessionID },
529
- });
530
- return !result.error && result.response.status === 200;
531
- } catch {
532
- return false;
533
- }
534
- }
535
-
536
- async function updateSessionTitle(): Promise<void> {
537
- try {
538
- const result = await client.session.get({
539
- path: { id: state.sessionID },
540
- });
541
- if (!result.error && result.data?.title) {
542
- setTerminalTitle(result.data.title);
543
- } else {
544
- setTerminalTitle(state.sessionID.substring(0, 8));
545
- }
546
- } catch {
547
- setTerminalTitle(state.sessionID.substring(0, 8));
548
- }
549
- }
550
-
551
- async function loadSessionHistory(): Promise<string[]> {
552
- try {
553
- const result = await client.session.messages({
554
- path: { id: state.sessionID },
555
- });
556
- if (result.error || !result.data) {
557
- return [];
558
- }
559
-
560
- const history: string[] = [];
561
- for (const msg of result.data) {
562
- if (msg.info.role === "user") {
563
- const textParts = msg.parts
564
- .filter((p: Part) => p.type === "text")
565
- .map((p: Part) => (p as any).text || "")
566
- .filter(Boolean);
567
- const text = textParts.join("").trim();
568
- if (text && !text.startsWith("/")) {
569
- history.push(text);
570
- }
571
- }
572
- }
573
- return history;
574
- } catch {
575
- return [];
576
- }
577
- }
578
-
579
- async function startEventListener(): Promise<void> {
580
- try {
581
- const { stream } = await client.event.subscribe({
582
- onSseError: (error) => {
583
- console.error(
584
- `\n${ansi.RED}Connection error:${ansi.RESET}`,
585
- error instanceof Error ? error.message : String(error),
586
- );
587
- },
588
- });
589
-
590
- for await (const event of stream) {
591
- try {
592
- await processEvent(event);
593
- } catch (error) {
594
- console.error(
595
- `\n${ansi.RED}Event processing error:${ansi.RESET}`,
596
- error instanceof Error ? error.message : String(error),
597
- );
598
- }
599
- }
600
- } catch (error) {
601
- console.error(
602
- `\n${ansi.RED}Failed to connect to event stream:${ansi.RESET}`,
603
- error instanceof Error ? error.message : String(error),
604
- );
605
- }
606
- }
607
-
608
- async function sendMessage(sessionID: string, message: string) {
609
- processing = false;
610
- state.accumulatedResponse = [];
611
- state.allEvents = [];
612
- state.renderedLines = [];
613
-
614
- await createLogFile();
615
-
616
- await writeToLog(`User: ${message}\n\n`);
617
-
618
- const requestStartTime = Date.now();
619
-
620
- try {
621
- const result = await client.session.prompt({
622
- path: { id: sessionID },
623
- body: {
624
- model: {
625
- providerID: config.providerID,
626
- modelID: config.modelID,
627
- },
628
- parts: [{ type: "text", text: message }],
629
- },
630
- });
631
-
632
- if (result.error) {
633
- throw new Error(
634
- `Failed to send message (${result.response.status}): ${JSON.stringify(result.error)}`,
635
- );
636
- }
637
-
638
- // Play a chime when request is completed
639
- process.stdout.write("\x07");
640
-
641
- stopAnimation();
642
-
643
- const duration = Date.now() - requestStartTime;
644
- const durationText = formatDuration(duration);
645
- console.log(` ${ansi.BRIGHT_BLACK}Completed in ${durationText}${ansi.RESET}\n`);
646
-
647
- writePrompt();
648
- } catch (error: any) {
649
- throw error;
650
- } finally {
651
- await closeLogFile();
652
- }
653
- }
654
-
655
- // ====================
656
- // EVENT PROCESSING
657
- // ====================
658
-
659
- async function processEvent(event: Event): Promise<void> {
660
- if (retryInterval && event.type !== "session.status") {
661
- clearInterval(retryInterval);
662
- retryInterval = null;
663
- }
664
-
665
- state.allEvents.push(event);
666
-
667
- switch (event.type) {
668
- case "message.part.updated": {
669
- const part = event.properties.part;
670
- const delta = event.properties.delta;
671
- if (part) {
672
- await processPart(part);
673
- }
674
- if (delta !== undefined && part) {
675
- processDelta(part.id, delta);
676
- }
677
- break;
678
- }
679
-
680
- // @ts-ignore this definitely exists
681
- case "message.part.delta": {
682
- // @ts-ignore
683
- const partID = event.properties.partID;
684
- // @ts-ignore
685
- const delta = event.properties.delta;
686
- if (partID !== undefined && delta !== undefined) {
687
- processDelta(partID, delta);
688
- }
689
- break;
690
- }
691
-
692
- case "session.diff": {
693
- const diff = event.properties.diff;
694
- if (diff && diff.length > 0) {
695
- await processDiff(diff);
696
- }
697
- break;
698
- }
699
-
700
- case "session.idle":
701
- case "session.status":
702
- if (event.type === "session.status" && event.properties.status.type === "idle") {
703
- stopAnimation();
704
- isRequestActive = false;
705
- process.stdout.write(ansi.CURSOR_SHOW);
706
- if (retryInterval) {
707
- clearInterval(retryInterval);
708
- retryInterval = null;
709
- }
710
- writePrompt();
711
- }
712
- if (event.type === "session.status" && event.properties.status.type === "retry") {
713
- const message = event.properties.status.message;
714
- const retryTime = event.properties.status.next;
715
- const sessionID = event.properties.sessionID;
716
- console.error(`\n\n ${ansi.RED}Error:${ansi.RESET} ${message}`);
717
- console.error(` ${ansi.BRIGHT_BLACK}Session:${ansi.RESET} ${sessionID}`);
718
- if (retryTime) {
719
- if (retryInterval) {
720
- clearInterval(retryInterval);
721
- }
722
- const retryDate = new Date(retryTime);
723
-
724
- let lastSeconds = Math.max(0, Math.ceil((retryDate.getTime() - Date.now()) / 1000));
725
- console.error(` ${ansi.BRIGHT_BLACK}Retrying in ${lastSeconds}s...${ansi.RESET}`);
726
-
727
- retryInterval = setInterval(() => {
728
- const remaining = Math.max(0, Math.ceil((retryDate.getTime() - Date.now()) / 1000));
729
- if (remaining !== lastSeconds) {
730
- process.stdout.write(
731
- `\r ${ansi.BRIGHT_BLACK}Retrying in ${remaining}s...${ansi.RESET}`,
732
- );
733
- lastSeconds = remaining;
734
- }
735
- if (remaining === 0) {
736
- if (retryInterval) {
737
- clearInterval(retryInterval);
738
- retryInterval = null;
739
- }
740
- }
741
- }, 100);
742
- }
743
- }
744
- break;
745
-
746
- case "session.updated": {
747
- const session = event.properties.info;
748
- if (session && session.id === state.sessionID && session.title) {
749
- setTerminalTitle(session.title);
750
- }
751
- break;
752
- }
753
-
754
- case "todo.updated": {
755
- const todos = event.properties.todos;
756
- if (todos) {
757
- await processTodos(todos);
758
- }
759
-
760
- break;
761
- }
762
-
763
- default:
764
- break;
765
- }
766
- }
767
-
768
- async function processPart(part: Part): Promise<void> {
769
- switch (part.type) {
770
- case "step-start":
771
- processStepStart();
772
- break;
773
-
774
- case "reasoning":
775
- processReasoning(part);
776
- break;
777
-
778
- case "text":
779
- if (processing) {
780
- processText(part);
781
- }
782
- break;
783
-
784
- case "step-finish":
785
- break;
786
-
787
- case "tool":
788
- processToolUse(part);
789
- break;
790
-
791
- default:
792
- break;
793
- }
794
- }
795
-
796
- function processStepStart() {
797
- processing = true;
798
- }
799
-
800
- async function processReasoning(part: Part) {
801
- processing = true;
802
- let thinkingPart = findLastPart(part.id);
803
- if (!thinkingPart) {
804
- thinkingPart = { key: part.id, title: "thinking", text: (part as any).text || "" };
805
- state.accumulatedResponse.push(thinkingPart);
806
- } else {
807
- thinkingPart.text = (part as any).text || "";
808
- }
809
-
810
- const text = (part as any).text || "";
811
- const cleanText = ansi.stripAnsiCodes(text.trimStart());
812
- await writeToLog(`Thinking:\n\n${cleanText}\n\n`);
813
-
814
- render(state);
815
- }
816
-
817
- async function processText(part: Part) {
818
- let responsePart = findLastPart(part.id);
819
- if (!responsePart) {
820
- responsePart = { key: part.id, title: "response", text: (part as any).text || "" };
821
- state.accumulatedResponse.push(responsePart);
822
- } else {
823
- responsePart.text = (part as any).text || "";
824
- }
825
-
826
- const text = (part as any).text || "";
827
- const cleanText = ansi.stripAnsiCodes(text.trimStart());
828
- await writeToLog(`Response:\n\n${cleanText}\n\n`);
829
-
830
- render(state);
831
- }
832
-
833
- async function processToolUse(part: Part) {
834
- const toolPart = part as ToolPart;
835
- const toolName = toolPart.tool || "unknown";
836
- const toolInput =
837
- toolPart.state.input["description"] ||
838
- toolPart.state.input["filePath"] ||
839
- toolPart.state.input["path"] ||
840
- toolPart.state.input["include"] ||
841
- toolPart.state.input["pattern"] ||
842
- // TODO: more state.input props?
843
- "...";
844
- const toolText = `$ ${toolName}: ${ansi.BRIGHT_BLACK}${toolInput}${ansi.RESET}`;
845
-
846
- if (state.accumulatedResponse[state.accumulatedResponse.length - 1]?.title === "tool") {
847
- state.accumulatedResponse[state.accumulatedResponse.length - 1]!.text = toolText;
848
- } else {
849
- state.accumulatedResponse.push({ key: part.id, title: "tool", text: toolText });
850
- }
851
-
852
- const cleanToolText = ansi.stripAnsiCodes(toolText);
853
- await writeToLog(`$ ${cleanToolText}\n\n`);
854
-
855
- render(state);
856
- }
857
-
858
- function processDelta(partID: string, delta: string) {
859
- let responsePart = findLastPart(partID);
860
- if (responsePart) {
861
- responsePart.text += delta;
862
- }
863
-
864
- render(state);
865
- }
866
-
867
- async function processDiff(diff: FileDiff[]) {
868
- const parts: string[] = [];
869
- for (const file of diff) {
870
- const newAfter = file.after ?? "";
871
- const oldAfter = state.lastFileAfter.get(file.file);
872
- if (newAfter !== oldAfter) {
873
- const statusIcon = !file.before ? "A" : !file.after ? "D" : "M";
874
- const addStr = file.additions > 0 ? `${ansi.GREEN}+${file.additions}${ansi.RESET}` : "";
875
- const delStr = file.deletions > 0 ? `${ansi.RED}-${file.deletions}${ansi.RESET}` : "";
876
- const stats = [addStr, delStr].filter(Boolean).join(" ");
877
- const line = `${ansi.BLUE}${statusIcon}${ansi.RESET} ${file.file} ${stats}`;
878
- parts.push(line);
879
-
880
- state.lastFileAfter.set(file.file, newAfter);
881
- }
882
- }
883
-
884
- if (parts.length > 0) {
885
- state.accumulatedResponse.push({ key: "diff", title: "files", text: parts.join("\n") });
886
-
887
- const diffText = ansi.stripAnsiCodes(parts.join("\n"));
888
- await writeToLog(`${diffText}\n\n`);
889
-
890
- render(state);
891
- }
892
- }
893
-
894
- async function processTodos(todos: Todo[]) {
895
- let todoListText = "Todo:\n";
896
-
897
- for (let todo of todos) {
898
- let todoText = "";
899
- if (todo.status === "completed") {
900
- todoText += "- [✓] ";
901
- } else {
902
- todoText += "- [ ] ";
903
- }
904
- todoText += todo.content;
905
- todoListText += todoText + "\n";
906
- }
907
-
908
- state.accumulatedResponse.push({ key: "todo", title: "files", text: todoListText });
909
-
910
- const cleanTodoText = ansi.stripAnsiCodes(todoListText);
911
- await writeToLog(`${cleanTodoText}\n`);
912
-
913
- render(state);
914
- }
915
-
916
- function findLastPart(title: string) {
917
- for (let i = state.accumulatedResponse.length - 1; i >= 0; i--) {
918
- const part = state.accumulatedResponse[i];
919
- if (part?.key === title) {
920
- return part;
921
- }
922
- }
923
- }
924
-
925
102
  function shutdown() {
926
103
  if (process.stdin.setRawMode) {
927
104
  process.stdin.setRawMode(false);
@@ -934,141 +111,4 @@ function shutdown() {
934
111
  process.exit(0);
935
112
  }
936
113
 
937
- // ====================
938
- // USER INTERFACE
939
- // ====================
940
-
941
- function setTerminalTitle(sessionName: string): void {
942
- process.stdout.write(`\x1b]0;OC | ${sessionName}\x07`);
943
- }
944
-
945
- function findPreviousWordBoundary(text: string, pos: number): number {
946
- if (pos <= 0) return 0;
947
-
948
- let newPos = pos;
949
-
950
- while (newPos > 0 && /\s/.test(text[newPos - 1]!)) {
951
- newPos--;
952
- }
953
-
954
- while (newPos > 0 && !/\s/.test(text[newPos - 1]!)) {
955
- newPos--;
956
- }
957
-
958
- return newPos;
959
- }
960
-
961
- function findNextWordBoundary(text: string, pos: number): number {
962
- if (pos >= text.length) return text.length;
963
-
964
- let newPos = pos;
965
-
966
- while (newPos < text.length && !/\s/.test(text[newPos]!)) {
967
- newPos++;
968
- }
969
-
970
- while (newPos < text.length && /\s/.test(text[newPos]!)) {
971
- newPos++;
972
- }
973
-
974
- return newPos;
975
- }
976
-
977
- function formatDuration(ms: number): string {
978
- if (ms < 1000) {
979
- return `${ms}ms`;
980
- }
981
- const seconds = ms / 1000;
982
- if (seconds < 60) {
983
- return `${seconds.toFixed(1)}s`;
984
- }
985
- const minutes = Math.floor(seconds / 60);
986
- const remainingSeconds = Math.round(seconds % 60);
987
- if (minutes < 60) {
988
- return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
989
- }
990
- const hours = Math.floor(minutes / 60);
991
- const remainingMinutes = minutes % 60;
992
- return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
993
- }
994
-
995
- // ====================
996
- // LOGGING
997
- // ====================
998
-
999
- export function getLogDir(): string {
1000
- const homeDir = process.env.HOME || process.env.USERPROFILE || "";
1001
- return `${homeDir}/.local/share/opencode-miniterm/log`;
1002
- }
1003
-
1004
- async function createLogFile(): Promise<void> {
1005
- if (!isLoggingEnabled()) {
1006
- return;
1007
- }
1008
-
1009
- const logDir = getLogDir();
1010
- await mkdir(logDir, { recursive: true });
1011
-
1012
- const now = new Date();
1013
- const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
1014
- const filename = `${timestamp}.txt`;
1015
- logFilePath = `${logDir}/${filename}`;
1016
-
1017
- try {
1018
- logFile = await open(logFilePath, "w");
1019
- } catch (error) {
1020
- console.error("Failed to create log file:", error);
1021
- logFile = null;
1022
- logFilePath = null;
1023
- }
1024
- }
1025
-
1026
- async function closeLogFile(): Promise<void> {
1027
- if (logFile) {
1028
- try {
1029
- await logFile.close();
1030
- } catch (error) {
1031
- console.error("Failed to close log file:", error);
1032
- }
1033
- logFile = null;
1034
- logFilePath = null;
1035
- }
1036
- }
1037
-
1038
- async function writeToLog(text: string): Promise<void> {
1039
- if (logFile && isLoggingEnabled()) {
1040
- try {
1041
- await logFile.write(text);
1042
- } catch (error) {
1043
- console.error("Failed to write to log file:", error);
1044
- }
1045
- }
1046
- }
1047
-
1048
- // ====================
1049
- // UTILITIES
1050
- // ====================
1051
-
1052
- async function getFileCompletions(pattern: string): Promise<string[]> {
1053
- try {
1054
- const files: string[] = [];
1055
- for await (const file of glob(pattern)) {
1056
- if (
1057
- !file.startsWith("node_modules/") &&
1058
- !file.startsWith(".git/") &&
1059
- !file.startsWith("dist/") &&
1060
- !file.startsWith("build/")
1061
- ) {
1062
- const isDir = await stat(file)
1063
- .then((s) => s.isDirectory())
1064
- .catch(() => false);
1065
- files.push(isDir ? file + "/" : file);
1066
- }
1067
- }
1068
- return files.sort();
1069
- } catch {
1070
- return [];
1071
- }
1072
- }
1073
-
1074
114
  main().catch(console.error);