opencode-miniterm 1.0.14 → 1.0.16

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/input.ts ADDED
@@ -0,0 +1,453 @@
1
+ import type { Part } from "@opencode-ai/sdk";
2
+ import { glob } from "node:fs/promises";
3
+ import { stat } from "node:fs/promises";
4
+ import readline, { type Key } from "node:readline";
5
+ import * as ansi from "./ansi";
6
+ import agentsCommand from "./commands/agents";
7
+ import debugCommand from "./commands/debug";
8
+ import detailsCommand from "./commands/details";
9
+ import diffCommand from "./commands/diff";
10
+ import exitCommand from "./commands/exit";
11
+ import initCommand from "./commands/init";
12
+ import logCommand from "./commands/log";
13
+ import modelsCommand from "./commands/models";
14
+ import newCommand from "./commands/new";
15
+ import pageCommand from "./commands/page";
16
+ import quitCommand from "./commands/quit";
17
+ import runCommand from "./commands/run";
18
+ import sessionsCommand from "./commands/sessions";
19
+ import undoCommand from "./commands/undo";
20
+ import { getLogDir, isLoggingEnabled } from "./logs";
21
+ import { startAnimation, stopAnimation, writePrompt } from "./render";
22
+ import { sendMessage } from "./server";
23
+ import type { State } from "./types";
24
+
25
+ const SLASH_COMMANDS = [
26
+ initCommand,
27
+ agentsCommand,
28
+ modelsCommand,
29
+ sessionsCommand,
30
+ newCommand,
31
+ undoCommand,
32
+ detailsCommand,
33
+ diffCommand,
34
+ debugCommand,
35
+ logCommand,
36
+ pageCommand,
37
+ exitCommand,
38
+ quitCommand,
39
+ runCommand,
40
+ ];
41
+
42
+ let inputBuffer = "";
43
+ let cursorPosition = 0;
44
+ let completions: string[] = [];
45
+ let history: string[] = [];
46
+ let historyIndex = history.length;
47
+ let selectedCompletion = 0;
48
+ let completionCycling = false;
49
+ let lastSpaceTime = 0;
50
+ let currentInputBuffer: string | null = null;
51
+ let isRequestActive = false;
52
+
53
+ let oldInputBuffer = "";
54
+ let oldWrappedRows = 0;
55
+ let oldCursorRow = 0;
56
+ export function renderLine(): void {
57
+ const consoleWidth = process.stdout.columns || 80;
58
+
59
+ // Move to the start of the line (i.e. the prompt position)
60
+ readline.cursorTo(process.stdout, 0);
61
+ if (oldWrappedRows > 0) {
62
+ if (cursorPosition < inputBuffer.length) {
63
+ readline.moveCursor(process.stdout, 0, oldWrappedRows - oldCursorRow);
64
+ }
65
+ readline.moveCursor(process.stdout, 0, -oldWrappedRows);
66
+ }
67
+
68
+ // Find the position where the input has changed (i.e. where the user has
69
+ // typed something)
70
+ let start = 0;
71
+ let currentCol = 2;
72
+ let newWrappedRows = 0;
73
+ for (let i = 0; i < Math.min(oldInputBuffer.length, inputBuffer.length); i++) {
74
+ if (oldInputBuffer[i] !== inputBuffer[i]) {
75
+ break;
76
+ }
77
+ if (currentCol >= consoleWidth) {
78
+ readline.moveCursor(process.stdout, 0, 1);
79
+ currentCol = 0;
80
+ newWrappedRows++;
81
+ }
82
+ currentCol++;
83
+ start++;
84
+ }
85
+
86
+ // Clear the old, changed, input
87
+ readline.cursorTo(process.stdout, currentCol);
88
+ readline.clearScreenDown(process.stdout);
89
+
90
+ // Write the prompt if this is a fresh buffer
91
+ if (start === 0) {
92
+ readline.cursorTo(process.stdout, 0);
93
+ writePrompt();
94
+ readline.cursorTo(process.stdout, 2);
95
+ }
96
+
97
+ // Write the changes from the new input buffer
98
+ let renderExtent = Math.max(cursorPosition + 1, inputBuffer.length);
99
+ for (let i = start; i < renderExtent; i++) {
100
+ if (currentCol >= consoleWidth) {
101
+ process.stdout.write("\n");
102
+ currentCol = 0;
103
+ newWrappedRows++;
104
+ }
105
+ if (i < inputBuffer.length) {
106
+ process.stdout.write(inputBuffer[i]!);
107
+ }
108
+ currentCol++;
109
+ }
110
+
111
+ // Calculate and move to the cursor's position
112
+ let absolutePos = 2 + cursorPosition;
113
+ let newCursorRow = Math.floor(absolutePos / consoleWidth);
114
+ let newCursorCol = absolutePos % consoleWidth;
115
+ readline.cursorTo(process.stdout, 0);
116
+ readline.moveCursor(process.stdout, 0, -1 * (newWrappedRows - newCursorRow));
117
+ readline.cursorTo(process.stdout, newCursorCol);
118
+
119
+ oldInputBuffer = inputBuffer;
120
+ oldWrappedRows = newWrappedRows;
121
+ oldCursorRow = newCursorRow;
122
+ }
123
+
124
+ export async function handleKeyPress(state: State, str: string, key: Key) {
125
+ if (key.ctrl && key.name === "c") {
126
+ process.stdout.write("\n");
127
+ state.shutdown();
128
+ return;
129
+ }
130
+
131
+ for (let command of SLASH_COMMANDS) {
132
+ if (command.running && command.handleKey) {
133
+ await command.handleKey(state, key, str);
134
+ return;
135
+ }
136
+ }
137
+
138
+ switch (key.name) {
139
+ case "up": {
140
+ if (historyIndex === history.length) {
141
+ currentInputBuffer = inputBuffer;
142
+ }
143
+ if (history.length > 0) {
144
+ if (historyIndex > 0) {
145
+ historyIndex--;
146
+ inputBuffer = history[historyIndex]!;
147
+ } else {
148
+ historyIndex = Math.max(-1, historyIndex - 1);
149
+ inputBuffer = "";
150
+ }
151
+ cursorPosition = inputBuffer.length;
152
+ renderLine();
153
+ }
154
+ return;
155
+ }
156
+ case "down": {
157
+ if (history.length > 0) {
158
+ if (historyIndex < history.length - 1) {
159
+ historyIndex++;
160
+ inputBuffer = history[historyIndex]!;
161
+ } else {
162
+ historyIndex = history.length;
163
+ inputBuffer = currentInputBuffer || "";
164
+ currentInputBuffer = null;
165
+ }
166
+ cursorPosition = inputBuffer.length;
167
+ renderLine();
168
+ }
169
+ return;
170
+ }
171
+ case "tab": {
172
+ if (!completionCycling) {
173
+ await handleTab();
174
+ }
175
+ if (completionCycling && completions.length > 0) {
176
+ await handleTab();
177
+ }
178
+ return;
179
+ }
180
+ case "escape": {
181
+ if (isRequestActive) {
182
+ if (state.sessionID) {
183
+ state.client.session.abort({ path: { id: state.sessionID } }).catch(() => {});
184
+ }
185
+ stopAnimation();
186
+ process.stdout.write(ansi.CURSOR_SHOW);
187
+ process.stdout.write(`\r ${ansi.BRIGHT_BLACK}Cancelled request${ansi.RESET}\n`);
188
+ writePrompt();
189
+ isRequestActive = false;
190
+ } else {
191
+ inputBuffer = "";
192
+ cursorPosition = 0;
193
+ currentInputBuffer = null;
194
+ renderLine();
195
+ }
196
+ return;
197
+ }
198
+ case "return": {
199
+ await acceptInput(state);
200
+ return;
201
+ }
202
+ case "backspace": {
203
+ if (cursorPosition > 0) {
204
+ inputBuffer = inputBuffer.slice(0, cursorPosition - 1) + inputBuffer.slice(cursorPosition);
205
+ cursorPosition--;
206
+ currentInputBuffer = null;
207
+ }
208
+ break;
209
+ }
210
+ case "delete": {
211
+ if (cursorPosition < inputBuffer.length) {
212
+ inputBuffer = inputBuffer.slice(0, cursorPosition) + inputBuffer.slice(cursorPosition + 1);
213
+ currentInputBuffer = null;
214
+ }
215
+ break;
216
+ }
217
+ case "left": {
218
+ if (key.meta) {
219
+ cursorPosition = findPreviousWordBoundary(inputBuffer, cursorPosition);
220
+ } else if (cursorPosition > 0) {
221
+ cursorPosition--;
222
+ }
223
+ break;
224
+ }
225
+ case "right": {
226
+ if (key.meta) {
227
+ cursorPosition = findNextWordBoundary(inputBuffer, cursorPosition);
228
+ } else if (cursorPosition < inputBuffer.length) {
229
+ cursorPosition++;
230
+ }
231
+ break;
232
+ }
233
+ default: {
234
+ if (str === " ") {
235
+ const now = Date.now();
236
+ if (
237
+ now - lastSpaceTime < 500 &&
238
+ cursorPosition > 0 &&
239
+ inputBuffer[cursorPosition - 1] === " "
240
+ ) {
241
+ inputBuffer =
242
+ inputBuffer.slice(0, cursorPosition - 1) + ". " + inputBuffer.slice(cursorPosition);
243
+ cursorPosition += 1;
244
+ } else {
245
+ inputBuffer =
246
+ inputBuffer.slice(0, cursorPosition) + str + inputBuffer.slice(cursorPosition);
247
+ cursorPosition += str.length;
248
+ }
249
+ lastSpaceTime = now;
250
+ } else if (str) {
251
+ inputBuffer =
252
+ inputBuffer.slice(0, cursorPosition) + str + inputBuffer.slice(cursorPosition);
253
+ cursorPosition += str.length;
254
+ }
255
+ currentInputBuffer = null;
256
+ }
257
+ }
258
+
259
+ completionCycling = false;
260
+ completions = [];
261
+ renderLine();
262
+ }
263
+
264
+ async function handleTab(): Promise<void> {
265
+ const potentialCompletions = await getCompletions(inputBuffer);
266
+
267
+ if (potentialCompletions.length === 0) {
268
+ completionCycling = false;
269
+ return;
270
+ }
271
+
272
+ if (!completionCycling) {
273
+ completions = potentialCompletions;
274
+ selectedCompletion = 0;
275
+ completionCycling = true;
276
+ inputBuffer = completions[0]!;
277
+ cursorPosition = inputBuffer.length;
278
+ renderLine();
279
+ } else {
280
+ selectedCompletion = (selectedCompletion + 1) % completions.length;
281
+ inputBuffer = completions[selectedCompletion]!;
282
+ cursorPosition = inputBuffer.length;
283
+ renderLine();
284
+ }
285
+ }
286
+
287
+ async function getCompletions(text: string): Promise<string[]> {
288
+ if (text.startsWith("/")) {
289
+ return ["/help", ...SLASH_COMMANDS.map((c) => c.name)].filter((cmd) => cmd.startsWith(text));
290
+ }
291
+
292
+ const atMatch = text.match(/(@[^\s]*)$/);
293
+ if (atMatch) {
294
+ const prefix = atMatch[0]!;
295
+ const searchPattern = prefix.slice(1);
296
+ const pattern = searchPattern.includes("/") ? searchPattern + "*" : "**/" + searchPattern + "*";
297
+ const files = await getFileCompletions(pattern);
298
+ return files.map((file: string) => text.replace(/@[^\s]*$/, "@" + file));
299
+ }
300
+
301
+ return [];
302
+ }
303
+
304
+ async function getFileCompletions(pattern: string): Promise<string[]> {
305
+ try {
306
+ const files: string[] = [];
307
+ for await (const file of glob(pattern)) {
308
+ if (
309
+ !file.startsWith("node_modules/") &&
310
+ !file.startsWith(".git/") &&
311
+ !file.startsWith("dist/") &&
312
+ !file.startsWith("build/")
313
+ ) {
314
+ const isDir = await stat(file)
315
+ .then((s) => s.isDirectory())
316
+ .catch(() => false);
317
+ files.push(isDir ? file + "/" : file);
318
+ }
319
+ }
320
+ return files.sort();
321
+ } catch {
322
+ return [];
323
+ }
324
+ }
325
+
326
+ async function acceptInput(state: State): Promise<void> {
327
+ process.stdout.write("\n");
328
+
329
+ const input = inputBuffer.trim();
330
+
331
+ oldInputBuffer = "";
332
+ oldWrappedRows = 0;
333
+ oldCursorRow = 0;
334
+
335
+ inputBuffer = "";
336
+ cursorPosition = 0;
337
+ completionCycling = false;
338
+ completions = [];
339
+ currentInputBuffer = null;
340
+
341
+ if (input) {
342
+ if (history[history.length - 1] !== input) {
343
+ history.push(input);
344
+ }
345
+ historyIndex = history.length;
346
+ try {
347
+ if (input === "/help") {
348
+ process.stdout.write("\n");
349
+ const maxCommandLength = Math.max(...SLASH_COMMANDS.map((c) => c.name.length));
350
+ for (const cmd of SLASH_COMMANDS) {
351
+ const padding = " ".repeat(maxCommandLength - cmd.name.length + 2);
352
+ console.log(
353
+ ` ${ansi.BRIGHT_WHITE}${cmd.name}${ansi.RESET}${padding}${ansi.BRIGHT_BLACK}${cmd.description}${ansi.RESET}`,
354
+ );
355
+ }
356
+ console.log();
357
+ writePrompt();
358
+ return;
359
+ } else if (input.startsWith("/")) {
360
+ const parts = input.match(/(\/[^\s]+)\s*(.*)/)!;
361
+ if (parts) {
362
+ const commandName = parts[1];
363
+ const extra = parts[2]?.trim();
364
+ for (let command of SLASH_COMMANDS) {
365
+ if (command.name === commandName) {
366
+ process.stdout.write("\n");
367
+ await command.run(state, extra);
368
+ writePrompt();
369
+ return;
370
+ }
371
+ }
372
+ }
373
+ return;
374
+ }
375
+
376
+ isRequestActive = true;
377
+ process.stdout.write("\n");
378
+ process.stdout.write(ansi.CURSOR_HIDE);
379
+ startAnimation();
380
+ if (isLoggingEnabled()) {
381
+ console.log(`📝 ${ansi.BRIGHT_BLACK}Logging to ${getLogDir()}\n${ansi.RESET}`);
382
+ }
383
+ await sendMessage(state, input);
384
+ isRequestActive = false;
385
+ } catch (error: any) {
386
+ isRequestActive = false;
387
+ if (error.message !== "Request cancelled") {
388
+ stopAnimation();
389
+ console.error("Error:", error.message);
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ export async function loadSessionHistory(state: State): Promise<string[]> {
396
+ try {
397
+ const result = await state.client.session.messages({
398
+ path: { id: state.sessionID },
399
+ });
400
+ if (result.error || !result.data) {
401
+ return [];
402
+ }
403
+
404
+ const history: string[] = [];
405
+ for (const msg of result.data) {
406
+ if (msg.info.role === "user") {
407
+ const textParts = msg.parts
408
+ .filter((p: Part) => p.type === "text")
409
+ .map((p: Part) => (p as any).text || "")
410
+ .filter(Boolean);
411
+ const text = textParts.join("").trim();
412
+ if (text && !text.startsWith("/")) {
413
+ history.push(text);
414
+ }
415
+ }
416
+ }
417
+ return history;
418
+ } catch {
419
+ return [];
420
+ }
421
+ }
422
+
423
+ function findPreviousWordBoundary(text: string, pos: number): number {
424
+ if (pos <= 0) return 0;
425
+
426
+ let newPos = pos;
427
+
428
+ while (newPos > 0 && /\s/.test(text[newPos - 1]!)) {
429
+ newPos--;
430
+ }
431
+
432
+ while (newPos > 0 && !/\s/.test(text[newPos - 1]!)) {
433
+ newPos--;
434
+ }
435
+
436
+ return newPos;
437
+ }
438
+
439
+ function findNextWordBoundary(text: string, pos: number): number {
440
+ if (pos >= text.length) return text.length;
441
+
442
+ let newPos = pos;
443
+
444
+ while (newPos < text.length && !/\s/.test(text[newPos]!)) {
445
+ newPos++;
446
+ }
447
+
448
+ while (newPos < text.length && /\s/.test(text[newPos]!)) {
449
+ newPos++;
450
+ }
451
+
452
+ return newPos;
453
+ }
package/src/logs.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { open } from "node:fs/promises";
3
+ import { config } from "./config";
4
+
5
+ let logFile: Awaited<ReturnType<typeof open>> | null = null;
6
+ let logFilePath: string | null = null;
7
+
8
+ export function isLoggingEnabled(): boolean {
9
+ return config.loggingEnabled;
10
+ }
11
+
12
+ export function getLogDir(): string {
13
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
14
+ return `${homeDir}/.local/share/opencode-miniterm/log`;
15
+ }
16
+
17
+ export async function createLogFile(): Promise<void> {
18
+ if (!isLoggingEnabled()) {
19
+ return;
20
+ }
21
+
22
+ const logDir = getLogDir();
23
+ await mkdir(logDir, { recursive: true });
24
+
25
+ const now = new Date();
26
+ const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
27
+ const filename = `${timestamp}.txt`;
28
+ logFilePath = `${logDir}/${filename}`;
29
+
30
+ try {
31
+ logFile = await open(logFilePath, "w");
32
+ } catch (error) {
33
+ console.error("Failed to create log file:", error);
34
+ logFile = null;
35
+ logFilePath = null;
36
+ }
37
+ }
38
+
39
+ export async function closeLogFile(): Promise<void> {
40
+ if (logFile) {
41
+ try {
42
+ await logFile.close();
43
+ } catch (error) {
44
+ console.error("Failed to close log file:", error);
45
+ }
46
+ logFile = null;
47
+ logFilePath = null;
48
+ }
49
+ }
50
+
51
+ export async function writeToLog(text: string): Promise<void> {
52
+ if (logFile && isLoggingEnabled()) {
53
+ try {
54
+ await logFile.write(text);
55
+ } catch (error) {
56
+ console.error("Failed to write to log file:", error);
57
+ }
58
+ }
59
+ }
package/src/render.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { OpencodeClient } from "@opencode-ai/sdk";
2
2
  import { gfm, parse, renderToConsole } from "allmark";
3
- import readline from "node:readline";
4
3
  import * as ansi from "./ansi";
5
4
  import { config } from "./config";
6
- import type { State } from "./index";
5
+ import type { State } from "./types";
6
+ import { formatDuration } from "./utils";
7
7
 
8
8
  export function render(state: State, details = false): void {
9
9
  let output = "";
@@ -139,10 +139,9 @@ function lastThinkingLines(text: string): string {
139
139
 
140
140
  function clearRenderedLines(state: State, linesToClear: number): void {
141
141
  if (linesToClear > 0) {
142
- readline.moveCursor(process.stdout, 0, -1 * linesToClear);
143
- readline.clearScreenDown(process.stdout);
142
+ state.write(`${ansi.CURSOR_UP(linesToClear)}${ansi.CLEAR_FROM_CURSOR}`);
144
143
  }
145
- readline.cursorTo(process.stdout, 0);
144
+ state.write(`${ansi.CURSOR_HOME}`);
146
145
  }
147
146
 
148
147
  export function wrapText(text: string, width: number): string[] {
@@ -309,21 +308,6 @@ export function startAnimation(startTime?: number): void {
309
308
  }, 100);
310
309
  }
311
310
 
312
- function formatDuration(ms: number): string {
313
- const seconds = ms / 1000;
314
- if (seconds < 60) {
315
- return `${Math.round(seconds)}s`;
316
- }
317
- const minutes = Math.floor(seconds / 60);
318
- const remainingSeconds = Math.round(seconds % 60);
319
- if (minutes < 60) {
320
- return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
321
- }
322
- const hours = Math.floor(minutes / 60);
323
- const remainingMinutes = minutes % 60;
324
- return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
325
- }
326
-
327
311
  export function stopAnimation(): void {
328
312
  if (animationInterval) {
329
313
  clearInterval(animationInterval);
@@ -378,3 +362,22 @@ export async function getActiveDisplay(client: OpencodeClient): Promise<string>
378
362
 
379
363
  return parts.join(" ");
380
364
  }
365
+
366
+ export async function updateSessionTitle(state: State): Promise<void> {
367
+ try {
368
+ const result = await state.client.session.get({
369
+ path: { id: state.sessionID },
370
+ });
371
+ if (!result.error && result.data?.title) {
372
+ setTerminalTitle(result.data.title);
373
+ } else {
374
+ setTerminalTitle(state.sessionID.substring(0, 8));
375
+ }
376
+ } catch {
377
+ setTerminalTitle(state.sessionID.substring(0, 8));
378
+ }
379
+ }
380
+
381
+ export function setTerminalTitle(sessionName: string): void {
382
+ process.stdout.write(`\x1b]0;OC | ${sessionName}\x07`);
383
+ }