opencode-miniterm 1.0.1 → 1.0.3

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
@@ -14,7 +14,6 @@ import detailsCommand from "./commands/details";
14
14
  import diffCommand from "./commands/diff";
15
15
  import exitCommand from "./commands/exit";
16
16
  import initCommand from "./commands/init";
17
- import killCommand from "./commands/kill";
18
17
  import logCommand, { isLoggingEnabled } from "./commands/log";
19
18
  import modelsCommand from "./commands/models";
20
19
  import newCommand from "./commands/new";
@@ -42,7 +41,6 @@ const SLASH_COMMANDS = [
42
41
  debugCommand,
43
42
  logCommand,
44
43
  pageCommand,
45
- killCommand,
46
44
  exitCommand,
47
45
  quitCommand,
48
46
  runCommand,
@@ -52,6 +50,7 @@ let client: ReturnType<typeof createOpencodeClient>;
52
50
 
53
51
  let processing = true;
54
52
  let retryInterval: ReturnType<typeof setInterval> | null = null;
53
+ let isRequestActive = false;
55
54
 
56
55
  interface AccumulatedPart {
57
56
  key: string;
@@ -122,11 +121,11 @@ async function main() {
122
121
  try {
123
122
  let isNewSession = false;
124
123
 
125
- const initialSessionID = config.sessionID;
124
+ const initialSessionID = config.sessionIDs[cwd];
126
125
  if (!initialSessionID || !(await validateSession(initialSessionID))) {
127
126
  state.sessionID = await createSession();
128
127
  isNewSession = true;
129
- config.sessionID = state.sessionID;
128
+ config.sessionIDs[cwd] = state.sessionID;
130
129
  saveConfig();
131
130
  } else {
132
131
  state.sessionID = initialSessionID;
@@ -136,6 +135,8 @@ async function main() {
136
135
 
137
136
  await updateSessionTitle();
138
137
 
138
+ const sessionHistory = await loadSessionHistory();
139
+
139
140
  const activeDisplay = await getActiveDisplay(client);
140
141
 
141
142
  process.stdout.write(`${ansi.CLEAR_SCREEN_UP}${ansi.CLEAR_FROM_CURSOR}`);
@@ -160,11 +161,13 @@ async function main() {
160
161
  let inputBuffer = "";
161
162
  let cursorPosition = 0;
162
163
  let completions: string[] = [];
163
- let history: string[] = [];
164
- let historyIndex = -1;
164
+ let history: string[] = sessionHistory;
165
+ let historyIndex = history.length;
165
166
  let selectedCompletion = 0;
166
167
  let showCompletions = false;
167
168
  let completionCycling = false;
169
+ let lastSpaceTime = 0;
170
+ let currentInputBuffer: string | null = null;
168
171
 
169
172
  const getCompletions = async (text: string): Promise<string[]> => {
170
173
  if (text.startsWith("/")) {
@@ -251,6 +254,7 @@ async function main() {
251
254
  showCompletions = false;
252
255
  completionCycling = false;
253
256
  completions = [];
257
+ currentInputBuffer = null;
254
258
 
255
259
  if (input) {
256
260
  if (history[history.length - 1] !== input) {
@@ -283,13 +287,16 @@ async function main() {
283
287
  return;
284
288
  }
285
289
 
290
+ isRequestActive = true;
286
291
  process.stdout.write(ansi.CURSOR_HIDE);
287
292
  startAnimation();
288
293
  if (isLoggingEnabled()) {
289
294
  console.log(`📝 ${ansi.BRIGHT_BLACK}Logging to ${getLogDir()}\n${ansi.RESET}`);
290
295
  }
291
296
  await sendMessage(state.sessionID, input);
297
+ isRequestActive = false;
292
298
  } catch (error: any) {
299
+ isRequestActive = false;
293
300
  if (error.message !== "Request cancelled") {
294
301
  stopAnimation();
295
302
  console.error("Error:", error.message);
@@ -312,11 +319,17 @@ async function main() {
312
319
 
313
320
  switch (key.name) {
314
321
  case "up": {
322
+ if (historyIndex === history.length) {
323
+ currentInputBuffer = inputBuffer;
324
+ }
315
325
  if (history.length > 0) {
316
326
  if (historyIndex > 0) {
317
327
  historyIndex--;
328
+ inputBuffer = history[historyIndex]!;
329
+ } else {
330
+ historyIndex = Math.max(-1, historyIndex - 1);
331
+ inputBuffer = "";
318
332
  }
319
- inputBuffer = history[historyIndex]!;
320
333
  cursorPosition = inputBuffer.length;
321
334
  renderLine();
322
335
  }
@@ -326,9 +339,11 @@ async function main() {
326
339
  if (history.length > 0) {
327
340
  if (historyIndex < history.length - 1) {
328
341
  historyIndex++;
342
+ inputBuffer = history[historyIndex]!;
329
343
  } else {
330
344
  historyIndex = history.length;
331
- inputBuffer = "";
345
+ inputBuffer = currentInputBuffer || "";
346
+ currentInputBuffer = null;
332
347
  }
333
348
  cursorPosition = inputBuffer.length;
334
349
  renderLine();
@@ -345,13 +360,21 @@ async function main() {
345
360
  return;
346
361
  }
347
362
  case "escape": {
348
- if (state.sessionID) {
349
- client.session.abort({ path: { id: state.sessionID } }).catch(() => {});
363
+ if (isRequestActive) {
364
+ if (state.sessionID) {
365
+ client.session.abort({ path: { id: state.sessionID } }).catch(() => {});
366
+ }
367
+ stopAnimation();
368
+ process.stdout.write(ansi.CURSOR_SHOW);
369
+ process.stdout.write(`\r ${ansi.BRIGHT_BLACK}Cancelled request${ansi.RESET}\n`);
370
+ writePrompt();
371
+ isRequestActive = false;
372
+ } else {
373
+ inputBuffer = "";
374
+ cursorPosition = 0;
375
+ currentInputBuffer = null;
376
+ renderLine();
350
377
  }
351
- stopAnimation();
352
- process.stdout.write(ansi.CURSOR_SHOW);
353
- process.stdout.write(`\r${ansi.BRIGHT_BLACK}Cancelled request${ansi.RESET}\n`);
354
- writePrompt();
355
378
  return;
356
379
  }
357
380
  case "return": {
@@ -363,6 +386,7 @@ async function main() {
363
386
  inputBuffer =
364
387
  inputBuffer.slice(0, cursorPosition - 1) + inputBuffer.slice(cursorPosition);
365
388
  cursorPosition--;
389
+ currentInputBuffer = null;
366
390
  }
367
391
  break;
368
392
  }
@@ -370,6 +394,7 @@ async function main() {
370
394
  if (cursorPosition < inputBuffer.length) {
371
395
  inputBuffer =
372
396
  inputBuffer.slice(0, cursorPosition) + inputBuffer.slice(cursorPosition + 1);
397
+ currentInputBuffer = null;
373
398
  }
374
399
  break;
375
400
  }
@@ -391,9 +416,30 @@ async function main() {
391
416
  }
392
417
  default: {
393
418
  if (str) {
394
- inputBuffer =
395
- inputBuffer.slice(0, cursorPosition) + str + inputBuffer.slice(cursorPosition);
396
- cursorPosition += str.length;
419
+ if (str === " ") {
420
+ const now = Date.now();
421
+ if (
422
+ now - lastSpaceTime < 500 &&
423
+ cursorPosition > 0 &&
424
+ inputBuffer[cursorPosition - 1] === " "
425
+ ) {
426
+ inputBuffer =
427
+ inputBuffer.slice(0, cursorPosition - 1) +
428
+ ". " +
429
+ inputBuffer.slice(cursorPosition);
430
+ cursorPosition += 1;
431
+ } else {
432
+ inputBuffer =
433
+ inputBuffer.slice(0, cursorPosition) + str + inputBuffer.slice(cursorPosition);
434
+ cursorPosition += str.length;
435
+ }
436
+ lastSpaceTime = now;
437
+ } else {
438
+ inputBuffer =
439
+ inputBuffer.slice(0, cursorPosition) + str + inputBuffer.slice(cursorPosition);
440
+ cursorPosition += str.length;
441
+ }
442
+ currentInputBuffer = null;
397
443
  }
398
444
  }
399
445
  }
@@ -461,6 +507,34 @@ async function updateSessionTitle(): Promise<void> {
461
507
  }
462
508
  }
463
509
 
510
+ async function loadSessionHistory(): Promise<string[]> {
511
+ try {
512
+ const result = await client.session.messages({
513
+ path: { id: state.sessionID },
514
+ });
515
+ if (result.error || !result.data) {
516
+ return [];
517
+ }
518
+
519
+ const history: string[] = [];
520
+ for (const msg of result.data) {
521
+ if (msg.info.role === "user") {
522
+ const textParts = msg.parts
523
+ .filter((p: Part) => p.type === "text")
524
+ .map((p: Part) => (p as any).text || "")
525
+ .filter(Boolean);
526
+ const text = textParts.join("").trim();
527
+ if (text && !text.startsWith("/")) {
528
+ history.push(text);
529
+ }
530
+ }
531
+ }
532
+ return history;
533
+ } catch {
534
+ return [];
535
+ }
536
+ }
537
+
464
538
  async function startEventListener(): Promise<void> {
465
539
  try {
466
540
  const { stream } = await client.event.subscribe({
@@ -527,7 +601,7 @@ async function sendMessage(sessionID: string, message: string) {
527
601
 
528
602
  const duration = Date.now() - requestStartTime;
529
603
  const durationText = formatDuration(duration);
530
- console.log(`${ansi.BRIGHT_BLACK}Completed in ${durationText}${ansi.RESET}\n`);
604
+ console.log(` ${ansi.BRIGHT_BLACK}Completed in ${durationText}${ansi.RESET}\n`);
531
605
 
532
606
  writePrompt();
533
607
 
@@ -591,6 +665,7 @@ async function processEvent(event: Event): Promise<void> {
591
665
  case "session.status":
592
666
  if (event.type === "session.status" && event.properties.status.type === "idle") {
593
667
  stopAnimation();
668
+ isRequestActive = false;
594
669
  process.stdout.write(ansi.CURSOR_SHOW);
595
670
  if (retryInterval) {
596
671
  clearInterval(retryInterval);
@@ -699,7 +774,7 @@ async function processReasoning(part: Part) {
699
774
 
700
775
  const text = (part as any).text || "";
701
776
  const cleanText = ansi.stripAnsiCodes(text.trimStart());
702
- await writeToLog(`💭 Thinking...\n\n${cleanText}\n\n`);
777
+ await writeToLog(`Thinking:\n\n${cleanText}\n\n`);
703
778
 
704
779
  render(state);
705
780
  }
@@ -715,7 +790,7 @@ async function processText(part: Part) {
715
790
 
716
791
  const text = (part as any).text || "";
717
792
  const cleanText = ansi.stripAnsiCodes(text.trimStart());
718
- await writeToLog(`💬 Response:\n\n${cleanText}\n\n`);
793
+ await writeToLog(`Response:\n\n${cleanText}\n\n`);
719
794
 
720
795
  render(state);
721
796
  }
@@ -724,7 +799,7 @@ async function processToolUse(part: Part) {
724
799
  const toolPart = part as ToolPart;
725
800
  const toolName = toolPart.tool || "unknown";
726
801
  const toolInput = toolPart.state.input["description"] || toolPart.state.input["filePath"] || {};
727
- const toolText = `🔧 ${toolName}: ${ansi.BRIGHT_BLACK}${toolInput}${ansi.RESET}`;
802
+ const toolText = `${ansi.BRIGHT_BLACK}$${ansi.RESET} ${toolName}: ${ansi.BRIGHT_BLACK}${toolInput}${ansi.RESET}`;
728
803
 
729
804
  if (state.accumulatedResponse[state.accumulatedResponse.length - 1]?.title === "tool") {
730
805
  state.accumulatedResponse[state.accumulatedResponse.length - 1]!.text = toolText;
@@ -733,7 +808,7 @@ async function processToolUse(part: Part) {
733
808
  }
734
809
 
735
810
  const cleanToolText = ansi.stripAnsiCodes(toolText);
736
- await writeToLog(`${cleanToolText}\n\n`);
811
+ await writeToLog(`$ ${cleanToolText}\n\n`);
737
812
 
738
813
  render(state);
739
814
  }
@@ -748,28 +823,26 @@ function processDelta(partID: string, delta: string) {
748
823
  }
749
824
 
750
825
  async function processDiff(diff: FileDiff[]) {
751
- let hasChanges = false;
752
826
  const parts: string[] = [];
753
827
  for (const file of diff) {
754
- const status = !file.before ? "added" : !file.after ? "deleted" : "modified";
755
- const statusIcon = status === "added" ? "A" : status === "modified" ? "M" : "D";
756
- const statusLabel =
757
- status === "added" ? "added" : status === "modified" ? "modified" : "deleted";
758
- const addStr = file.additions > 0 ? `${ansi.GREEN}+${file.additions}${ansi.RESET}` : "";
759
- const delStr = file.deletions > 0 ? `${ansi.RED}-${file.deletions}${ansi.RESET}` : "";
760
- const stats = [addStr, delStr].filter(Boolean).join(" ");
761
- const line = ` ${ansi.BLUE}${statusIcon}${ansi.RESET} ${file.file} (${statusLabel}) ${stats}`;
762
- parts.push(line);
763
-
764
828
  const newAfter = file.after ?? "";
765
829
  const oldAfter = state.lastFileAfter.get(file.file);
766
830
  if (newAfter !== oldAfter) {
767
- hasChanges = true;
831
+ const status = !file.before ? "added" : !file.after ? "deleted" : "modified";
832
+ const statusIcon = status === "added" ? "A" : status === "modified" ? "M" : "D";
833
+ const statusLabel =
834
+ status === "added" ? "added" : status === "modified" ? "modified" : "deleted";
835
+ const addStr = file.additions > 0 ? `${ansi.GREEN}+${file.additions}${ansi.RESET}` : "";
836
+ const delStr = file.deletions > 0 ? `${ansi.RED}-${file.deletions}${ansi.RESET}` : "";
837
+ const stats = [addStr, delStr].filter(Boolean).join(" ");
838
+ const line = `${ansi.BLUE}${statusIcon}${ansi.RESET} ${file.file} (${statusLabel}) ${stats}`;
839
+ parts.push(line);
840
+
768
841
  state.lastFileAfter.set(file.file, newAfter);
769
842
  }
770
843
  }
771
844
 
772
- if (hasChanges) {
845
+ if (parts.length > 0) {
773
846
  state.accumulatedResponse.push({ key: "diff", title: "files", text: parts.join("\n") });
774
847
 
775
848
  const diffText = ansi.stripAnsiCodes(parts.join("\n"));
@@ -785,15 +858,11 @@ async function processTodos(todos: Todo[]) {
785
858
  for (let todo of todos) {
786
859
  let todoText = "";
787
860
  if (todo.status === "completed") {
788
- todoText += ansi.STRIKETHROUGH;
789
861
  todoText += "- [✓] ";
790
862
  } else {
791
863
  todoText += "- [ ] ";
792
864
  }
793
865
  todoText += todo.content;
794
- if (todo.status === "completed") {
795
- todoText += ansi.RESET;
796
- }
797
866
  todoListText += todoText + "\n";
798
867
  }
799
868
 
package/src/render.ts CHANGED
@@ -12,11 +12,7 @@ export function render(state: State, details = false): void {
12
12
  }
13
13
 
14
14
  // Only show the last (i.e. active) thinking part
15
- // Only show the last (i.e. active) tool use
16
- // Only show the last files part between parts
17
15
  let foundPart = false;
18
- let foundFiles = false;
19
- let foundTodo = false;
20
16
  for (let i = state.accumulatedResponse.length - 1; i >= 0; i--) {
21
17
  const part = state.accumulatedResponse[i];
22
18
  if (!part) continue;
@@ -26,18 +22,15 @@ export function render(state: State, details = false): void {
26
22
  }
27
23
 
28
24
  if (part.title === "thinking") {
25
+ if (part.active === false) {
26
+ break;
27
+ }
29
28
  part.active = !foundPart;
30
29
  foundPart = true;
31
- } else if (part.title === "tool") {
32
- part.active = !foundPart;
33
- } else if (part.title === "files") {
34
- part.active = !foundFiles;
35
- foundFiles = true;
36
- } else if (part.title === "todo") {
37
- part.active = !foundTodo;
38
- foundTodo = true;
39
- } else {
30
+ } else if (part.title === "response") {
31
+ part.active = true;
40
32
  foundPart = true;
33
+ } else {
41
34
  part.active = true;
42
35
  }
43
36
  }
@@ -48,17 +41,19 @@ export function render(state: State, details = false): void {
48
41
  if (!part.text.trim()) continue;
49
42
 
50
43
  if (part.title === "thinking") {
44
+ // Show max 10 thinking lines
51
45
  const partText = details ? part.text.trimStart() : lastThinkingLines(part.text.trimStart());
52
- output += `💭 ${ansi.BRIGHT_BLACK}${partText}${ansi.RESET}\n\n`;
46
+ output += `${ansi.BOLD_BRIGHT_BLACK}~${ansi.RESET} ${ansi.BRIGHT_BLACK}${partText}${ansi.RESET}\n\n`;
53
47
  } else if (part.title === "response") {
48
+ // Show all response lines
54
49
  const doc = parse(part.text.trimStart(), gfm);
55
50
  const partText = renderToConsole(doc);
56
- output += `💬 ${partText}\n\n`;
57
- } else if (part.title === "tool") {
58
- output += part.text + "\n\n";
59
- } else if (part.title === "files") {
51
+ output += `${ansi.WHITE_BACKGROUND}${ansi.BOLD_BLACK}*${ansi.RESET} ${partText}\n\n`;
52
+ } else if (part.title === "tool" || part.title === "files") {
53
+ // TODO: Show max 10 tool/file lines?
60
54
  output += part.text + "\n\n";
61
55
  } else if (part.title === "todo") {
56
+ // Show the whole todo list
62
57
  output += part.text + "\n\n";
63
58
  }
64
59
  }
@@ -84,6 +79,9 @@ export function render(state: State, details = false): void {
84
79
  }
85
80
 
86
81
  state.renderedLines = lines;
82
+ } else if (state.renderedLines.length > 0) {
83
+ clearRenderedLines(state, state.renderedLines.length);
84
+ state.renderedLines = [];
87
85
  }
88
86
  }
89
87
 
@@ -130,15 +128,17 @@ function clearRenderedLines(state: State, linesToClear: number): void {
130
128
  }
131
129
 
132
130
  export function wrapText(text: string, width: number): string[] {
131
+ const INDENT = " ";
132
+ const indentLength = INDENT.length;
133
133
  const lines: string[] = [];
134
- let currentLine = "";
135
- let visibleLength = 0;
134
+ let currentLine = INDENT;
135
+ let visibleLength = indentLength;
136
136
  let i = 0;
137
137
 
138
138
  const pushLine = () => {
139
139
  lines.push(currentLine);
140
- currentLine = "";
141
- visibleLength = 0;
140
+ currentLine = INDENT;
141
+ visibleLength = indentLength;
142
142
  };
143
143
 
144
144
  const addWord = (word: string, wordVisibleLength: number) => {
@@ -150,21 +150,21 @@ export function wrapText(text: string, width: number): string[] {
150
150
  : visibleLength + 1 + wordVisibleLength <= width;
151
151
 
152
152
  if (wouldFit) {
153
- if (visibleLength > 0) {
153
+ if (visibleLength > indentLength) {
154
154
  currentLine += " ";
155
155
  visibleLength++;
156
156
  }
157
157
  currentLine += word;
158
158
  visibleLength += wordVisibleLength;
159
- } else if (visibleLength > 0) {
159
+ } else if (visibleLength > indentLength) {
160
160
  pushLine();
161
- currentLine = word;
162
- visibleLength = wordVisibleLength;
161
+ currentLine = INDENT + word;
162
+ visibleLength = indentLength + wordVisibleLength;
163
163
  } else if (wordVisibleLength <= width) {
164
- currentLine = word;
165
- visibleLength = wordVisibleLength;
164
+ currentLine = INDENT + word;
165
+ visibleLength = indentLength + wordVisibleLength;
166
166
  } else {
167
- const wordWidth = width;
167
+ const wordWidth = width - indentLength;
168
168
  for (let w = 0; w < word.length; ) {
169
169
  let segment = "";
170
170
  let segmentVisible = 0;
@@ -192,8 +192,8 @@ export function wrapText(text: string, width: number): string[] {
192
192
  if (currentLine) {
193
193
  pushLine();
194
194
  }
195
- currentLine = segment;
196
- visibleLength = segmentVisible;
195
+ currentLine = INDENT + segment;
196
+ visibleLength = indentLength + segmentVisible;
197
197
  }
198
198
  }
199
199
  }
@@ -237,7 +237,7 @@ export function wrapText(text: string, width: number): string[] {
237
237
  }
238
238
  }
239
239
 
240
- if (currentLine || lines.length === 0) {
240
+ if (currentLine.trim() || lines.length === 0) {
241
241
  pushLine();
242
242
  }
243
243
 
@@ -252,18 +252,40 @@ export function writePrompt(): void {
252
252
 
253
253
  const ANIMATION_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇"];
254
254
  let animationInterval: ReturnType<typeof setInterval> | null = null;
255
+ let requestStartTime: number | null = null;
255
256
 
256
- export function startAnimation(): void {
257
+ export function startAnimation(startTime?: number): void {
257
258
  if (animationInterval) return;
258
259
 
260
+ requestStartTime = startTime || Date.now();
261
+
259
262
  let index = 0;
260
263
  animationInterval = setInterval(() => {
261
- process.stdout.write(`\r${ansi.BOLD_MAGENTA}`);
262
- process.stdout.write(`${ANIMATION_CHARS[index]}${ansi.RESET}`);
264
+ const elapsed = Date.now() - requestStartTime!;
265
+ const elapsedText = formatDuration(elapsed);
266
+
267
+ process.stdout.write(
268
+ `\r${ansi.BOLD_MAGENTA}${ANIMATION_CHARS[index]} ${ansi.RESET}${ansi.BRIGHT_BLACK}Running for ${elapsedText}${ansi.RESET}`,
269
+ );
263
270
  index = (index + 1) % ANIMATION_CHARS.length;
264
271
  }, 100);
265
272
  }
266
273
 
274
+ function formatDuration(ms: number): string {
275
+ const seconds = ms / 1000;
276
+ if (seconds < 60) {
277
+ return `${Math.round(seconds)}s`;
278
+ }
279
+ const minutes = Math.floor(seconds / 60);
280
+ const remainingSeconds = Math.round(seconds % 60);
281
+ if (minutes < 60) {
282
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
283
+ }
284
+ const hours = Math.floor(minutes / 60);
285
+ const remainingMinutes = minutes % 60;
286
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
287
+ }
288
+
267
289
  export function stopAnimation(): void {
268
290
  if (animationInterval) {
269
291
  clearInterval(animationInterval);