opencode-miniterm 1.0.2 → 1.0.4
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/AGENTS.md +46 -11
- package/bun.lock +2 -2
- package/package.json +3 -3
- package/src/ansi.ts +4 -0
- package/src/commands/page.ts +5 -6
- package/src/index.ts +106 -35
- package/src/render.ts +76 -32
- package/test/render.test.ts +93 -41
package/AGENTS.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
# OpenCode
|
|
1
|
+
# OpenCode Miniterm - Agent Guidelines
|
|
2
2
|
|
|
3
3
|
## Build & Development Commands
|
|
4
4
|
|
|
5
5
|
### Run the application
|
|
6
|
+
|
|
6
7
|
```bash
|
|
7
8
|
bun run src/index.ts
|
|
8
9
|
# Or just:
|
|
@@ -10,46 +11,55 @@ bun src/index.ts
|
|
|
10
11
|
```
|
|
11
12
|
|
|
12
13
|
### Build (when bundler is added)
|
|
14
|
+
|
|
13
15
|
```bash
|
|
14
16
|
bun build src/index.ts --outdir dist
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
### Testing
|
|
20
|
+
|
|
18
21
|
No test framework is currently configured. Add one of these to package.json:
|
|
22
|
+
|
|
19
23
|
- **Bun Test**: `bun test` (recommended - built-in, fast)
|
|
20
24
|
- **Jest**: `npm test` or `bun run test`
|
|
21
25
|
- **Vitest**: `vitest`
|
|
22
26
|
|
|
23
27
|
To run a single test (once configured):
|
|
28
|
+
|
|
24
29
|
- Bun Test: `bun test --test-name-pattern "testName"`
|
|
25
30
|
- Jest: `npm test -- testName`
|
|
26
31
|
- Vitest: `vitest run testName`
|
|
27
32
|
|
|
28
33
|
### Linting & Formatting (recommended additions)
|
|
34
|
+
|
|
29
35
|
Install and configure these tools:
|
|
36
|
+
|
|
30
37
|
```bash
|
|
31
38
|
bun add -d eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser prettier
|
|
32
39
|
```
|
|
33
40
|
|
|
34
41
|
Commands to add to package.json:
|
|
42
|
+
|
|
35
43
|
```json
|
|
36
44
|
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
"lint": "eslint src --ext .ts",
|
|
46
|
+
"lint:fix": "eslint src --ext .ts --fix",
|
|
47
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
48
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
42
50
|
}
|
|
43
51
|
```
|
|
44
52
|
|
|
45
53
|
## Code Style Guidelines
|
|
46
54
|
|
|
47
55
|
### TypeScript Configuration
|
|
56
|
+
|
|
48
57
|
- Use strict mode: `"strict": true` in tsconfig.json
|
|
49
58
|
- Target ES2022+ for modern Node/Bun features
|
|
50
59
|
- Use `moduleResolution: "bundler"` for Bun compatibility
|
|
51
60
|
|
|
52
61
|
### Imports
|
|
62
|
+
|
|
53
63
|
- Use ES6 imports (ESM): `import { something } from 'module'`
|
|
54
64
|
- Group imports in this order:
|
|
55
65
|
1. Node/Bun built-ins
|
|
@@ -59,6 +69,7 @@ Commands to add to package.json:
|
|
|
59
69
|
- Avoid default exports; prefer named exports for better tree-shaking
|
|
60
70
|
|
|
61
71
|
### Formatting
|
|
72
|
+
|
|
62
73
|
- Use 2 spaces for indentation
|
|
63
74
|
- Use single quotes for strings
|
|
64
75
|
- Use semicolons at end of statements
|
|
@@ -67,6 +78,7 @@ Commands to add to package.json:
|
|
|
67
78
|
- Spaces around operators: `a = b + c` not `a=b+c`
|
|
68
79
|
|
|
69
80
|
### Types & Type Safety
|
|
81
|
+
|
|
70
82
|
- Always provide explicit return types for functions
|
|
71
83
|
- Use `interface` for object shapes, `type` for unions/primitives
|
|
72
84
|
- Avoid `any`; use `unknown` when type is truly unknown
|
|
@@ -74,6 +86,7 @@ Commands to add to package.json:
|
|
|
74
86
|
- Leverage Bun's built-in type definitions (from `bun-types`)
|
|
75
87
|
|
|
76
88
|
### Naming Conventions
|
|
89
|
+
|
|
77
90
|
- **Files**: kebab-case: `my-service.ts`
|
|
78
91
|
- **Variables/Functions**: camelCase: `myFunction`
|
|
79
92
|
- **Classes**: PascalCase: `MyService`
|
|
@@ -82,14 +95,18 @@ Commands to add to package.json:
|
|
|
82
95
|
- **Types/Interfaces**: PascalCase, often with suffixes: `UserService`, `ConfigOptions`
|
|
83
96
|
|
|
84
97
|
### Error Handling
|
|
98
|
+
|
|
85
99
|
- Use try/catch for async operations
|
|
86
100
|
- Create custom error classes for domain-specific errors:
|
|
87
101
|
```ts
|
|
88
102
|
class TerminalError extends Error {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
constructor(
|
|
104
|
+
message: string,
|
|
105
|
+
public code: string,
|
|
106
|
+
) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = "TerminalError";
|
|
109
|
+
}
|
|
93
110
|
}
|
|
94
111
|
```
|
|
95
112
|
- Always include error context in error messages
|
|
@@ -97,24 +114,28 @@ Commands to add to package.json:
|
|
|
97
114
|
- Never swallow errors silently
|
|
98
115
|
|
|
99
116
|
### Async/Promise Handling
|
|
117
|
+
|
|
100
118
|
- Use async/await over .then()/.catch()
|
|
101
119
|
- Handle promise rejections: `process.on('unhandledRejection')`
|
|
102
120
|
- Use Bun's optimized APIs where available (e.g., `Bun.file()`)
|
|
103
121
|
- Implement timeouts for network requests
|
|
104
122
|
|
|
105
123
|
### Code Organization
|
|
124
|
+
|
|
106
125
|
- Structure by feature/domain, not by file type
|
|
107
126
|
- Keep files focused: one responsibility per file
|
|
108
127
|
- Export at file end; avoid export分散
|
|
109
128
|
- Use barrel files (`index.ts`) for cleaner imports
|
|
110
129
|
|
|
111
130
|
### Comments
|
|
131
|
+
|
|
112
132
|
- Use JSDoc for public APIs: `/** @description ... */`
|
|
113
133
|
- Comment WHY, not WHAT
|
|
114
134
|
- Keep comments current with code changes
|
|
115
135
|
- Avoid inline comments for obvious logic
|
|
116
136
|
|
|
117
137
|
### Performance (Bun-Specific)
|
|
138
|
+
|
|
118
139
|
- Leverage Bun's fast I/O: `Bun.write()`, `Bun.file()`
|
|
119
140
|
- Use `TextEncoder`/`TextDecoder` for encoding
|
|
120
141
|
- Prefer native over polyfills
|
|
@@ -123,6 +144,7 @@ Commands to add to package.json:
|
|
|
123
144
|
## Project Context
|
|
124
145
|
|
|
125
146
|
This is an alternative terminal UI for OpenCode. Focus on:
|
|
147
|
+
|
|
126
148
|
- Fast, responsive terminal rendering
|
|
127
149
|
- Clean CLI UX with good error messages
|
|
128
150
|
- Efficient resource usage (memory/CPU)
|
|
@@ -138,6 +160,7 @@ This is an alternative terminal UI for OpenCode. Focus on:
|
|
|
138
160
|
## OpenCode Server Integration
|
|
139
161
|
|
|
140
162
|
### Starting the Server
|
|
163
|
+
|
|
141
164
|
- Use `opencode serve` to start a headless HTTP server (not `opencode server`)
|
|
142
165
|
- Default URL: `http://127.0.0.1:4096` (port may vary, can be 0/random)
|
|
143
166
|
- Server requires 2-3 seconds to initialize before accepting requests
|
|
@@ -145,6 +168,7 @@ This is an alternative terminal UI for OpenCode. Focus on:
|
|
|
145
168
|
- Always handle SIGINT to properly shut down the server process
|
|
146
169
|
|
|
147
170
|
### Authentication
|
|
171
|
+
|
|
148
172
|
- Server may require HTTP Basic Auth if `OPENCODE_SERVER_PASSWORD` is set
|
|
149
173
|
- Username: `OPENCODE_SERVER_USERNAME` env var (default: 'opencode')
|
|
150
174
|
- Password: `OPENCODE_SERVER_PASSWORD` env var (required if server has password set)
|
|
@@ -153,6 +177,7 @@ This is an alternative terminal UI for OpenCode. Focus on:
|
|
|
153
177
|
- Check env vars at startup: `echo $OPENCODE_SERVER_PASSWORD` to verify it's set
|
|
154
178
|
|
|
155
179
|
### Creating Sessions
|
|
180
|
+
|
|
156
181
|
```ts
|
|
157
182
|
POST /session
|
|
158
183
|
Headers: { "Content-Type": "application/json", "Authorization": "Basic <creds>" }
|
|
@@ -161,14 +186,17 @@ Response: { id: string, title?: string, ... }
|
|
|
161
186
|
```
|
|
162
187
|
|
|
163
188
|
### Getting Available Models
|
|
189
|
+
|
|
164
190
|
```ts
|
|
165
191
|
GET /config/providers
|
|
166
192
|
Headers: { "Authorization": "Basic <creds>" }
|
|
167
193
|
Response: { providers: Provider[], default: { [key: string]: string } }
|
|
168
194
|
```
|
|
195
|
+
|
|
169
196
|
Note: `/models` endpoint returns HTML documentation, not JSON. Use `/config/providers` for programmatic access.
|
|
170
197
|
|
|
171
198
|
### Sending Messages
|
|
199
|
+
|
|
172
200
|
```ts
|
|
173
201
|
POST /session/:id/message
|
|
174
202
|
Headers: { "Content-Type": "application/json", "Authorization": "Basic <creds>" }
|
|
@@ -183,6 +211,7 @@ Response: { info: Message, parts: Part[] }
|
|
|
183
211
|
```
|
|
184
212
|
|
|
185
213
|
### Getting Session Messages
|
|
214
|
+
|
|
186
215
|
```ts
|
|
187
216
|
GET /session/:id/message
|
|
188
217
|
Headers: { "Authorization": "Basic <creds>" }
|
|
@@ -190,20 +219,24 @@ Response: { info: Message, parts: Part[] }[]
|
|
|
190
219
|
```
|
|
191
220
|
|
|
192
221
|
### Undoing Messages (Revert)
|
|
222
|
+
|
|
193
223
|
```ts
|
|
194
224
|
POST /session/:id/revert
|
|
195
225
|
Headers: { "Content-Type": "application/json", "Authorization": "Basic <creds>" }
|
|
196
226
|
Body: { messageID: string, partID?: string }
|
|
197
227
|
Response: { id: string, revert: { messageID, snapshot, diff } }
|
|
198
228
|
```
|
|
229
|
+
|
|
199
230
|
Typically used to undo the last assistant message by fetching messages first, then reverting the last one.
|
|
200
231
|
|
|
201
232
|
**IMPORTANT**: The `model` field is required when sending messages. Without it, the request will hang indefinitely. Get available models from `GET /config/providers` or `GET /models`. Common models:
|
|
233
|
+
|
|
202
234
|
- `big-pickle` (opencode provider) - default, high quality
|
|
203
235
|
- `glm-5-free` (opencode provider) - free GLM model
|
|
204
236
|
- `gpt-5-nano` (opencode provider) - fast GPT model
|
|
205
237
|
|
|
206
238
|
### Response Format
|
|
239
|
+
|
|
207
240
|
- Response has `{ info, parts }` structure
|
|
208
241
|
- Parts can be: `step-start`, `reasoning`, `text`, `step-finish`, `tool_use`, `tool_result`
|
|
209
242
|
- `step-start` - Indicates beginning of a thinking/processing step
|
|
@@ -215,6 +248,7 @@ Typically used to undo the last assistant message by fetching messages first, th
|
|
|
215
248
|
- Display reasoning and text parts to the user for transparency
|
|
216
249
|
|
|
217
250
|
### Server-Sent Events (SSE)
|
|
251
|
+
|
|
218
252
|
- Connect to event stream at `/event` for real-time updates
|
|
219
253
|
- Events include: `message.part.updated`, `session.status`, `session.updated`, `message.updated`, `session.diff`, `session.idle`
|
|
220
254
|
- Event structure: `{ type: string, properties: {...} }`
|
|
@@ -224,6 +258,7 @@ Typically used to undo the last assistant message by fetching messages first, th
|
|
|
224
258
|
- Delta updates allow streaming reasoning and text for better UX
|
|
225
259
|
|
|
226
260
|
### Error Handling
|
|
261
|
+
|
|
227
262
|
- Server returns 401 Unauthorized when authentication is missing/invalid
|
|
228
263
|
- Handle connection errors (server may not be ready yet)
|
|
229
264
|
- Always parse error text from response for debugging
|
|
@@ -240,4 +275,4 @@ Typically used to undo the last assistant message by fetching messages first, th
|
|
|
240
275
|
- Never commit API keys, tokens, or secrets
|
|
241
276
|
- Validate all user inputs
|
|
242
277
|
- Sanitize terminal output to prevent injection
|
|
243
|
-
- Use environment variables for configuration
|
|
278
|
+
- Use environment variables for configuration
|
package/bun.lock
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
|
|
48
48
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
|
49
49
|
|
|
50
|
-
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.
|
|
50
|
+
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.15", "", {}, "sha512-NUJNlyBCdZ4R0EBLjJziEQOp2XbRPJosaMcTcWSWO5XJPKGUpz0u8ql+5cR8K+v2RJ+hp2NobtNwpjEYfe6BRQ=="],
|
|
51
51
|
|
|
52
52
|
"@trivago/prettier-plugin-sort-imports": ["@trivago/prettier-plugin-sort-imports@6.0.2", "", { "dependencies": { "@babel/generator": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "javascript-natural-sort": "^0.7.1", "lodash-es": "^4.17.21", "minimatch": "^9.0.0", "parse-imports-exports": "^0.2.4" }, "peerDependencies": { "@vue/compiler-sfc": "3.x", "prettier": "2.x - 3.x", "prettier-plugin-ember-template-tag": ">= 2.0.0", "prettier-plugin-svelte": "3.x", "svelte": "4.x || 5.x" }, "optionalPeers": ["@vue/compiler-sfc", "prettier-plugin-ember-template-tag", "prettier-plugin-svelte", "svelte"] }, "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA=="],
|
|
53
53
|
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
|
|
72
72
|
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260122.2", "", { "os": "win32", "cpu": "x64" }, "sha512-TJH+sSn7vxcGvaSm+fhcpW//dfSGBZGHrxoy0edAbE7UpFf8ub/3cILL5V1pCsF7w3aLNNgVKRpUkowdUgYcPQ=="],
|
|
73
73
|
|
|
74
|
-
"allmark": ["allmark@1.0.
|
|
74
|
+
"allmark": ["allmark@1.0.1", "", { "bin": { "allmark": "dist/bin/index.mjs" } }, "sha512-r4eeGshglWb3G99D4anWSPpieZ9kw9zXHeGd6NXjBbsbq+aCq/7W9hkrqYq0ywlf43ync2KKfmjeTHJum0AHEQ=="],
|
|
75
75
|
|
|
76
76
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
|
77
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-miniterm",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "A small front-end terminal UI for OpenCode",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"typescript": "^5"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@opencode-ai/sdk": "^1.2.
|
|
32
|
-
"allmark": "^1.0.
|
|
31
|
+
"@opencode-ai/sdk": "^1.2.15",
|
|
32
|
+
"allmark": "^1.0.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/ansi.ts
CHANGED
|
@@ -9,12 +9,16 @@ export const CURSOR_UP = (lines: number) => `\x1b[${lines}A`;
|
|
|
9
9
|
export const RESET = "\x1b[0m";
|
|
10
10
|
export const BRIGHT_WHITE = "\x1b[97m";
|
|
11
11
|
export const BRIGHT_BLACK = "\x1b[90m";
|
|
12
|
+
export const BOLD_BRIGHT_BLACK = "\x1b[1;90m";
|
|
12
13
|
export const RED = "\x1b[31m";
|
|
13
14
|
export const GREEN = "\x1b[32m";
|
|
14
15
|
export const BLUE = "\x1b[34m";
|
|
15
16
|
export const CYAN = "\x1b[36m";
|
|
16
17
|
export const BOLD_MAGENTA = "\x1b[1;35m";
|
|
17
18
|
export const STRIKETHROUGH = "\x1b[9m";
|
|
19
|
+
export const WHITE_BACKGROUND = "\x1b[47m";
|
|
20
|
+
export const BRIGHT_BLACK_BACKGROUND = "\x1b[0;100m";
|
|
21
|
+
export const BOLD_BLACK = "\x1b[1;30m";
|
|
18
22
|
export const ANSI_CODE_PATTERN = /^\x1b\[[0-9;]*m/;
|
|
19
23
|
|
|
20
24
|
export function stripAnsiCodes(str: string): string {
|
package/src/commands/page.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OpencodeClient } from "@opencode-ai/sdk";
|
|
2
2
|
import type { Key } from "node:readline";
|
|
3
|
+
import * as ansi from "../ansi";
|
|
3
4
|
import type { State } from "../index";
|
|
4
5
|
import { wrapText } from "../render";
|
|
5
6
|
import type { Command } from "../types";
|
|
@@ -24,18 +25,16 @@ function run(client: OpencodeClient, state: State): void {
|
|
|
24
25
|
if (!part || !part.text.trim()) continue;
|
|
25
26
|
|
|
26
27
|
if (part.title === "thinking") {
|
|
27
|
-
pages.push(`💭
|
|
28
|
+
pages.push(`💭 ${ansi.BRIGHT_BLACK}${part.text.trimStart()}${ansi.RESET}`);
|
|
28
29
|
} else if (part.title === "response") {
|
|
29
|
-
pages.push(
|
|
30
|
-
} else
|
|
31
|
-
pages.push(part.text);
|
|
32
|
-
} else if (part.title === "files") {
|
|
30
|
+
pages.push(`💭 ${part.text.trimStart()}`);
|
|
31
|
+
} else {
|
|
33
32
|
pages.push(part.text);
|
|
34
33
|
}
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
if (pages.length === 0) {
|
|
38
|
-
console.log(
|
|
37
|
+
console.log(`${ansi.BRIGHT_BLACK}No parts to display yet.${ansi.RESET}\n`);
|
|
39
38
|
return;
|
|
40
39
|
}
|
|
41
40
|
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,7 @@ let client: ReturnType<typeof createOpencodeClient>;
|
|
|
50
50
|
|
|
51
51
|
let processing = true;
|
|
52
52
|
let retryInterval: ReturnType<typeof setInterval> | null = null;
|
|
53
|
+
let isRequestActive = false;
|
|
53
54
|
|
|
54
55
|
interface AccumulatedPart {
|
|
55
56
|
key: string;
|
|
@@ -134,6 +135,8 @@ async function main() {
|
|
|
134
135
|
|
|
135
136
|
await updateSessionTitle();
|
|
136
137
|
|
|
138
|
+
const sessionHistory = await loadSessionHistory();
|
|
139
|
+
|
|
137
140
|
const activeDisplay = await getActiveDisplay(client);
|
|
138
141
|
|
|
139
142
|
process.stdout.write(`${ansi.CLEAR_SCREEN_UP}${ansi.CLEAR_FROM_CURSOR}`);
|
|
@@ -158,11 +161,13 @@ async function main() {
|
|
|
158
161
|
let inputBuffer = "";
|
|
159
162
|
let cursorPosition = 0;
|
|
160
163
|
let completions: string[] = [];
|
|
161
|
-
let history: string[] =
|
|
162
|
-
let historyIndex =
|
|
164
|
+
let history: string[] = sessionHistory;
|
|
165
|
+
let historyIndex = history.length;
|
|
163
166
|
let selectedCompletion = 0;
|
|
164
167
|
let showCompletions = false;
|
|
165
168
|
let completionCycling = false;
|
|
169
|
+
let lastSpaceTime = 0;
|
|
170
|
+
let currentInputBuffer: string | null = null;
|
|
166
171
|
|
|
167
172
|
const getCompletions = async (text: string): Promise<string[]> => {
|
|
168
173
|
if (text.startsWith("/")) {
|
|
@@ -249,6 +254,7 @@ async function main() {
|
|
|
249
254
|
showCompletions = false;
|
|
250
255
|
completionCycling = false;
|
|
251
256
|
completions = [];
|
|
257
|
+
currentInputBuffer = null;
|
|
252
258
|
|
|
253
259
|
if (input) {
|
|
254
260
|
if (history[history.length - 1] !== input) {
|
|
@@ -281,13 +287,16 @@ async function main() {
|
|
|
281
287
|
return;
|
|
282
288
|
}
|
|
283
289
|
|
|
290
|
+
isRequestActive = true;
|
|
284
291
|
process.stdout.write(ansi.CURSOR_HIDE);
|
|
285
292
|
startAnimation();
|
|
286
293
|
if (isLoggingEnabled()) {
|
|
287
294
|
console.log(`📝 ${ansi.BRIGHT_BLACK}Logging to ${getLogDir()}\n${ansi.RESET}`);
|
|
288
295
|
}
|
|
289
296
|
await sendMessage(state.sessionID, input);
|
|
297
|
+
isRequestActive = false;
|
|
290
298
|
} catch (error: any) {
|
|
299
|
+
isRequestActive = false;
|
|
291
300
|
if (error.message !== "Request cancelled") {
|
|
292
301
|
stopAnimation();
|
|
293
302
|
console.error("Error:", error.message);
|
|
@@ -310,11 +319,17 @@ async function main() {
|
|
|
310
319
|
|
|
311
320
|
switch (key.name) {
|
|
312
321
|
case "up": {
|
|
322
|
+
if (historyIndex === history.length) {
|
|
323
|
+
currentInputBuffer = inputBuffer;
|
|
324
|
+
}
|
|
313
325
|
if (history.length > 0) {
|
|
314
326
|
if (historyIndex > 0) {
|
|
315
327
|
historyIndex--;
|
|
328
|
+
inputBuffer = history[historyIndex]!;
|
|
329
|
+
} else {
|
|
330
|
+
historyIndex = Math.max(-1, historyIndex - 1);
|
|
331
|
+
inputBuffer = "";
|
|
316
332
|
}
|
|
317
|
-
inputBuffer = history[historyIndex]!;
|
|
318
333
|
cursorPosition = inputBuffer.length;
|
|
319
334
|
renderLine();
|
|
320
335
|
}
|
|
@@ -324,9 +339,11 @@ async function main() {
|
|
|
324
339
|
if (history.length > 0) {
|
|
325
340
|
if (historyIndex < history.length - 1) {
|
|
326
341
|
historyIndex++;
|
|
342
|
+
inputBuffer = history[historyIndex]!;
|
|
327
343
|
} else {
|
|
328
344
|
historyIndex = history.length;
|
|
329
|
-
inputBuffer = "";
|
|
345
|
+
inputBuffer = currentInputBuffer || "";
|
|
346
|
+
currentInputBuffer = null;
|
|
330
347
|
}
|
|
331
348
|
cursorPosition = inputBuffer.length;
|
|
332
349
|
renderLine();
|
|
@@ -343,13 +360,21 @@ async function main() {
|
|
|
343
360
|
return;
|
|
344
361
|
}
|
|
345
362
|
case "escape": {
|
|
346
|
-
if (
|
|
347
|
-
|
|
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();
|
|
348
377
|
}
|
|
349
|
-
stopAnimation();
|
|
350
|
-
process.stdout.write(ansi.CURSOR_SHOW);
|
|
351
|
-
process.stdout.write(`\r${ansi.BRIGHT_BLACK}Cancelled request${ansi.RESET}\n`);
|
|
352
|
-
writePrompt();
|
|
353
378
|
return;
|
|
354
379
|
}
|
|
355
380
|
case "return": {
|
|
@@ -361,6 +386,7 @@ async function main() {
|
|
|
361
386
|
inputBuffer =
|
|
362
387
|
inputBuffer.slice(0, cursorPosition - 1) + inputBuffer.slice(cursorPosition);
|
|
363
388
|
cursorPosition--;
|
|
389
|
+
currentInputBuffer = null;
|
|
364
390
|
}
|
|
365
391
|
break;
|
|
366
392
|
}
|
|
@@ -368,6 +394,7 @@ async function main() {
|
|
|
368
394
|
if (cursorPosition < inputBuffer.length) {
|
|
369
395
|
inputBuffer =
|
|
370
396
|
inputBuffer.slice(0, cursorPosition) + inputBuffer.slice(cursorPosition + 1);
|
|
397
|
+
currentInputBuffer = null;
|
|
371
398
|
}
|
|
372
399
|
break;
|
|
373
400
|
}
|
|
@@ -389,9 +416,30 @@ async function main() {
|
|
|
389
416
|
}
|
|
390
417
|
default: {
|
|
391
418
|
if (str) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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;
|
|
395
443
|
}
|
|
396
444
|
}
|
|
397
445
|
}
|
|
@@ -459,6 +507,34 @@ async function updateSessionTitle(): Promise<void> {
|
|
|
459
507
|
}
|
|
460
508
|
}
|
|
461
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
|
+
|
|
462
538
|
async function startEventListener(): Promise<void> {
|
|
463
539
|
try {
|
|
464
540
|
const { stream } = await client.event.subscribe({
|
|
@@ -525,7 +601,7 @@ async function sendMessage(sessionID: string, message: string) {
|
|
|
525
601
|
|
|
526
602
|
const duration = Date.now() - requestStartTime;
|
|
527
603
|
const durationText = formatDuration(duration);
|
|
528
|
-
console.log(
|
|
604
|
+
console.log(` ${ansi.BRIGHT_BLACK}Completed in ${durationText}${ansi.RESET}\n`);
|
|
529
605
|
|
|
530
606
|
writePrompt();
|
|
531
607
|
|
|
@@ -589,6 +665,7 @@ async function processEvent(event: Event): Promise<void> {
|
|
|
589
665
|
case "session.status":
|
|
590
666
|
if (event.type === "session.status" && event.properties.status.type === "idle") {
|
|
591
667
|
stopAnimation();
|
|
668
|
+
isRequestActive = false;
|
|
592
669
|
process.stdout.write(ansi.CURSOR_SHOW);
|
|
593
670
|
if (retryInterval) {
|
|
594
671
|
clearInterval(retryInterval);
|
|
@@ -697,7 +774,7 @@ async function processReasoning(part: Part) {
|
|
|
697
774
|
|
|
698
775
|
const text = (part as any).text || "";
|
|
699
776
|
const cleanText = ansi.stripAnsiCodes(text.trimStart());
|
|
700
|
-
await writeToLog(
|
|
777
|
+
await writeToLog(`Thinking:\n\n${cleanText}\n\n`);
|
|
701
778
|
|
|
702
779
|
render(state);
|
|
703
780
|
}
|
|
@@ -713,7 +790,7 @@ async function processText(part: Part) {
|
|
|
713
790
|
|
|
714
791
|
const text = (part as any).text || "";
|
|
715
792
|
const cleanText = ansi.stripAnsiCodes(text.trimStart());
|
|
716
|
-
await writeToLog(
|
|
793
|
+
await writeToLog(`Response:\n\n${cleanText}\n\n`);
|
|
717
794
|
|
|
718
795
|
render(state);
|
|
719
796
|
}
|
|
@@ -722,7 +799,7 @@ async function processToolUse(part: Part) {
|
|
|
722
799
|
const toolPart = part as ToolPart;
|
|
723
800
|
const toolName = toolPart.tool || "unknown";
|
|
724
801
|
const toolInput = toolPart.state.input["description"] || toolPart.state.input["filePath"] || {};
|
|
725
|
-
const toolText =
|
|
802
|
+
const toolText = `${ansi.BRIGHT_BLACK}$${ansi.RESET} ${toolName}: ${ansi.BRIGHT_BLACK}${toolInput}${ansi.RESET}`;
|
|
726
803
|
|
|
727
804
|
if (state.accumulatedResponse[state.accumulatedResponse.length - 1]?.title === "tool") {
|
|
728
805
|
state.accumulatedResponse[state.accumulatedResponse.length - 1]!.text = toolText;
|
|
@@ -731,7 +808,7 @@ async function processToolUse(part: Part) {
|
|
|
731
808
|
}
|
|
732
809
|
|
|
733
810
|
const cleanToolText = ansi.stripAnsiCodes(toolText);
|
|
734
|
-
await writeToLog(`${cleanToolText}\n\n`);
|
|
811
|
+
await writeToLog(`$ ${cleanToolText}\n\n`);
|
|
735
812
|
|
|
736
813
|
render(state);
|
|
737
814
|
}
|
|
@@ -746,28 +823,26 @@ function processDelta(partID: string, delta: string) {
|
|
|
746
823
|
}
|
|
747
824
|
|
|
748
825
|
async function processDiff(diff: FileDiff[]) {
|
|
749
|
-
let hasChanges = false;
|
|
750
826
|
const parts: string[] = [];
|
|
751
827
|
for (const file of diff) {
|
|
752
|
-
const status = !file.before ? "added" : !file.after ? "deleted" : "modified";
|
|
753
|
-
const statusIcon = status === "added" ? "A" : status === "modified" ? "M" : "D";
|
|
754
|
-
const statusLabel =
|
|
755
|
-
status === "added" ? "added" : status === "modified" ? "modified" : "deleted";
|
|
756
|
-
const addStr = file.additions > 0 ? `${ansi.GREEN}+${file.additions}${ansi.RESET}` : "";
|
|
757
|
-
const delStr = file.deletions > 0 ? `${ansi.RED}-${file.deletions}${ansi.RESET}` : "";
|
|
758
|
-
const stats = [addStr, delStr].filter(Boolean).join(" ");
|
|
759
|
-
const line = ` ${ansi.BLUE}${statusIcon}${ansi.RESET} ${file.file} (${statusLabel}) ${stats}`;
|
|
760
|
-
parts.push(line);
|
|
761
|
-
|
|
762
828
|
const newAfter = file.after ?? "";
|
|
763
829
|
const oldAfter = state.lastFileAfter.get(file.file);
|
|
764
830
|
if (newAfter !== oldAfter) {
|
|
765
|
-
|
|
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
|
+
|
|
766
841
|
state.lastFileAfter.set(file.file, newAfter);
|
|
767
842
|
}
|
|
768
843
|
}
|
|
769
844
|
|
|
770
|
-
if (
|
|
845
|
+
if (parts.length > 0) {
|
|
771
846
|
state.accumulatedResponse.push({ key: "diff", title: "files", text: parts.join("\n") });
|
|
772
847
|
|
|
773
848
|
const diffText = ansi.stripAnsiCodes(parts.join("\n"));
|
|
@@ -783,15 +858,11 @@ async function processTodos(todos: Todo[]) {
|
|
|
783
858
|
for (let todo of todos) {
|
|
784
859
|
let todoText = "";
|
|
785
860
|
if (todo.status === "completed") {
|
|
786
|
-
todoText += ansi.STRIKETHROUGH;
|
|
787
861
|
todoText += "- [✓] ";
|
|
788
862
|
} else {
|
|
789
863
|
todoText += "- [ ] ";
|
|
790
864
|
}
|
|
791
865
|
todoText += todo.content;
|
|
792
|
-
if (todo.status === "completed") {
|
|
793
|
-
todoText += ansi.RESET;
|
|
794
|
-
}
|
|
795
866
|
todoListText += todoText + "\n";
|
|
796
867
|
}
|
|
797
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,41 +22,52 @@ export function render(state: State, details = false): void {
|
|
|
26
22
|
}
|
|
27
23
|
|
|
28
24
|
if (part.title === "thinking") {
|
|
25
|
+
if (part.active === false) {
|
|
26
|
+
// We've already checked all the parts before here
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
29
|
part.active = !foundPart;
|
|
30
30
|
foundPart = true;
|
|
31
|
-
} else if (part.title === "
|
|
32
|
-
part.active =
|
|
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 {
|
|
31
|
+
} else if (part.title === "response") {
|
|
32
|
+
part.active = true;
|
|
40
33
|
foundPart = true;
|
|
34
|
+
} else {
|
|
41
35
|
part.active = true;
|
|
42
36
|
}
|
|
43
37
|
}
|
|
44
38
|
|
|
39
|
+
let lastPartWasTool = false;
|
|
45
40
|
for (let i = 0; i < state.accumulatedResponse.length; i++) {
|
|
46
41
|
const part = state.accumulatedResponse[i];
|
|
47
42
|
if (!part || !part.active) continue;
|
|
48
43
|
if (!part.text.trim()) continue;
|
|
49
44
|
|
|
50
45
|
if (part.title === "thinking") {
|
|
46
|
+
// Show max 10 thinking lines
|
|
51
47
|
const partText = details ? part.text.trimStart() : lastThinkingLines(part.text.trimStart());
|
|
52
|
-
output +=
|
|
48
|
+
output += "<ocmt-thinking>\n";
|
|
49
|
+
output += `💭 ${partText}\n\n`;
|
|
50
|
+
output += "</ocmt-thinking>\n";
|
|
53
51
|
} else if (part.title === "response") {
|
|
52
|
+
// Show all response lines
|
|
54
53
|
const doc = parse(part.text.trimStart(), gfm);
|
|
55
54
|
const partText = renderToConsole(doc);
|
|
56
55
|
output += `💬 ${partText}\n\n`;
|
|
57
56
|
} else if (part.title === "tool") {
|
|
57
|
+
// TODO: Show max 10 tool/file lines?
|
|
58
|
+
if (lastPartWasTool && output.endsWith("\n\n")) {
|
|
59
|
+
output = output.substring(0, output.length - 1);
|
|
60
|
+
}
|
|
58
61
|
output += part.text + "\n\n";
|
|
59
62
|
} else if (part.title === "files") {
|
|
63
|
+
// TODO: Show max 10 tool/file lines?
|
|
60
64
|
output += part.text + "\n\n";
|
|
61
65
|
} else if (part.title === "todo") {
|
|
66
|
+
// Show the whole todo list
|
|
62
67
|
output += part.text + "\n\n";
|
|
63
68
|
}
|
|
69
|
+
|
|
70
|
+
lastPartWasTool = part.title === "tool";
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
if (output) {
|
|
@@ -84,6 +91,9 @@ export function render(state: State, details = false): void {
|
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
state.renderedLines = lines;
|
|
94
|
+
} else if (state.renderedLines.length > 0) {
|
|
95
|
+
clearRenderedLines(state, state.renderedLines.length);
|
|
96
|
+
state.renderedLines = [];
|
|
87
97
|
}
|
|
88
98
|
}
|
|
89
99
|
|
|
@@ -130,15 +140,27 @@ function clearRenderedLines(state: State, linesToClear: number): void {
|
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
export function wrapText(text: string, width: number): string[] {
|
|
143
|
+
const INDENT = " ";
|
|
144
|
+
const indentLength = INDENT.length;
|
|
133
145
|
const lines: string[] = [];
|
|
134
|
-
let currentLine =
|
|
135
|
-
let visibleLength =
|
|
146
|
+
let currentLine = INDENT;
|
|
147
|
+
let visibleLength = indentLength;
|
|
136
148
|
let i = 0;
|
|
137
149
|
|
|
150
|
+
let inThinking = false;
|
|
151
|
+
|
|
138
152
|
const pushLine = () => {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
if (currentLine === " <ocmt-thinking>") {
|
|
154
|
+
inThinking = true;
|
|
155
|
+
} else if (currentLine === " </ocmt-thinking>") {
|
|
156
|
+
inThinking = false;
|
|
157
|
+
} else {
|
|
158
|
+
let text = inThinking ? `${ansi.BRIGHT_BLACK}${currentLine}${ansi.RESET}` : currentLine;
|
|
159
|
+
lines.push(text);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
currentLine = INDENT;
|
|
163
|
+
visibleLength = indentLength;
|
|
142
164
|
};
|
|
143
165
|
|
|
144
166
|
const addWord = (word: string, wordVisibleLength: number) => {
|
|
@@ -150,21 +172,21 @@ export function wrapText(text: string, width: number): string[] {
|
|
|
150
172
|
: visibleLength + 1 + wordVisibleLength <= width;
|
|
151
173
|
|
|
152
174
|
if (wouldFit) {
|
|
153
|
-
if (visibleLength >
|
|
175
|
+
if (visibleLength > indentLength) {
|
|
154
176
|
currentLine += " ";
|
|
155
177
|
visibleLength++;
|
|
156
178
|
}
|
|
157
179
|
currentLine += word;
|
|
158
180
|
visibleLength += wordVisibleLength;
|
|
159
|
-
} else if (visibleLength >
|
|
181
|
+
} else if (visibleLength > indentLength) {
|
|
160
182
|
pushLine();
|
|
161
|
-
currentLine = word;
|
|
162
|
-
visibleLength = wordVisibleLength;
|
|
183
|
+
currentLine = INDENT + word;
|
|
184
|
+
visibleLength = indentLength + wordVisibleLength;
|
|
163
185
|
} else if (wordVisibleLength <= width) {
|
|
164
|
-
currentLine = word;
|
|
165
|
-
visibleLength = wordVisibleLength;
|
|
186
|
+
currentLine = INDENT + word;
|
|
187
|
+
visibleLength = indentLength + wordVisibleLength;
|
|
166
188
|
} else {
|
|
167
|
-
const wordWidth = width;
|
|
189
|
+
const wordWidth = width - indentLength;
|
|
168
190
|
for (let w = 0; w < word.length; ) {
|
|
169
191
|
let segment = "";
|
|
170
192
|
let segmentVisible = 0;
|
|
@@ -192,8 +214,8 @@ export function wrapText(text: string, width: number): string[] {
|
|
|
192
214
|
if (currentLine) {
|
|
193
215
|
pushLine();
|
|
194
216
|
}
|
|
195
|
-
currentLine = segment;
|
|
196
|
-
visibleLength = segmentVisible;
|
|
217
|
+
currentLine = INDENT + segment;
|
|
218
|
+
visibleLength = indentLength + segmentVisible;
|
|
197
219
|
}
|
|
198
220
|
}
|
|
199
221
|
}
|
|
@@ -237,7 +259,7 @@ export function wrapText(text: string, width: number): string[] {
|
|
|
237
259
|
}
|
|
238
260
|
}
|
|
239
261
|
|
|
240
|
-
if (currentLine || lines.length === 0) {
|
|
262
|
+
if (currentLine.trim() || lines.length === 0) {
|
|
241
263
|
pushLine();
|
|
242
264
|
}
|
|
243
265
|
|
|
@@ -252,18 +274,40 @@ export function writePrompt(): void {
|
|
|
252
274
|
|
|
253
275
|
const ANIMATION_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇"];
|
|
254
276
|
let animationInterval: ReturnType<typeof setInterval> | null = null;
|
|
277
|
+
let requestStartTime: number | null = null;
|
|
255
278
|
|
|
256
|
-
export function startAnimation(): void {
|
|
279
|
+
export function startAnimation(startTime?: number): void {
|
|
257
280
|
if (animationInterval) return;
|
|
258
281
|
|
|
282
|
+
requestStartTime = startTime || Date.now();
|
|
283
|
+
|
|
259
284
|
let index = 0;
|
|
260
285
|
animationInterval = setInterval(() => {
|
|
261
|
-
|
|
262
|
-
|
|
286
|
+
const elapsed = Date.now() - requestStartTime!;
|
|
287
|
+
const elapsedText = formatDuration(elapsed);
|
|
288
|
+
|
|
289
|
+
process.stdout.write(
|
|
290
|
+
`\r${ansi.BOLD_MAGENTA}${ANIMATION_CHARS[index]} ${ansi.RESET}${ansi.BRIGHT_BLACK}Running for ${elapsedText}${ansi.RESET}`,
|
|
291
|
+
);
|
|
263
292
|
index = (index + 1) % ANIMATION_CHARS.length;
|
|
264
293
|
}, 100);
|
|
265
294
|
}
|
|
266
295
|
|
|
296
|
+
function formatDuration(ms: number): string {
|
|
297
|
+
const seconds = ms / 1000;
|
|
298
|
+
if (seconds < 60) {
|
|
299
|
+
return `${Math.round(seconds)}s`;
|
|
300
|
+
}
|
|
301
|
+
const minutes = Math.floor(seconds / 60);
|
|
302
|
+
const remainingSeconds = Math.round(seconds % 60);
|
|
303
|
+
if (minutes < 60) {
|
|
304
|
+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
|
305
|
+
}
|
|
306
|
+
const hours = Math.floor(minutes / 60);
|
|
307
|
+
const remainingMinutes = minutes % 60;
|
|
308
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
309
|
+
}
|
|
310
|
+
|
|
267
311
|
export function stopAnimation(): void {
|
|
268
312
|
if (animationInterval) {
|
|
269
313
|
clearInterval(animationInterval);
|
package/test/render.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { stripANSI } from "bun";
|
|
1
2
|
import { describe, expect, it, vi } from "bun:test";
|
|
2
3
|
import { type State } from "../src";
|
|
4
|
+
import * as ansi from "../src/ansi";
|
|
3
5
|
import { render, wrapText } from "../src/render";
|
|
4
6
|
|
|
5
7
|
describe("render", () => {
|
|
@@ -61,7 +63,7 @@ describe("render", () => {
|
|
|
61
63
|
const calls = write.mock.calls.map((c) => c[0]);
|
|
62
64
|
expect(calls.some((c) => c.includes("\u001B[2A"))).toBe(true);
|
|
63
65
|
const outputCall = calls.find((c) => c.includes("i've done it"));
|
|
64
|
-
expect(outputCall).toContain(
|
|
66
|
+
expect(outputCall).toContain(`💬`);
|
|
65
67
|
});
|
|
66
68
|
});
|
|
67
69
|
|
|
@@ -76,8 +78,7 @@ describe("render", () => {
|
|
|
76
78
|
render(state);
|
|
77
79
|
|
|
78
80
|
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
79
|
-
expect(output).toContain(
|
|
80
|
-
expect(output).toContain("分析问题");
|
|
81
|
+
expect(output).toContain(`${ansi.BRIGHT_BLACK} 💭 分析问题${ansi.RESET}`);
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
it("should only show thinking indicator for last thinking part", () => {
|
|
@@ -93,9 +94,8 @@ describe("render", () => {
|
|
|
93
94
|
render(state);
|
|
94
95
|
|
|
95
96
|
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
96
|
-
expect(output).toContain(
|
|
97
|
-
expect(output).toContain("
|
|
98
|
-
expect(output).not.toMatch(/first.*Thinking/);
|
|
97
|
+
expect(output).toContain(`${ansi.BRIGHT_BLACK} 💭 second${ansi.RESET}`);
|
|
98
|
+
expect(output).not.toContain("first");
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
it("should skip parts with empty text", () => {
|
|
@@ -121,6 +121,31 @@ describe("render", () => {
|
|
|
121
121
|
|
|
122
122
|
expect(write).toHaveBeenCalled();
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
it("all thinking lines should be gray", () => {
|
|
126
|
+
const write = vi.fn();
|
|
127
|
+
const state = createMockState({
|
|
128
|
+
accumulatedResponse: [
|
|
129
|
+
{
|
|
130
|
+
key: "xxx",
|
|
131
|
+
title: "thinking",
|
|
132
|
+
text: "Cookware and bakeware is food preparation equipment, such as cooking pots, pans, baking sheets etc. used in kitchens. Cookware is used on a stove or range cooktop, while bakeware is used in an oven. Some utensils are considered both cookware and bakeware.",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
write,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
render(state);
|
|
139
|
+
|
|
140
|
+
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
141
|
+
expect(output).toContain(
|
|
142
|
+
`${ansi.BRIGHT_BLACK} 💭 Cookware and bakeware is food preparation equipment, such as cooking pots, pans, baking sheets etc. used in kitchens. Cookware is used on a stove${ansi.RESET}`,
|
|
143
|
+
);
|
|
144
|
+
expect(output).toContain(
|
|
145
|
+
`${ansi.BRIGHT_BLACK} or range cooktop, while bakeware is used in an oven. Some utensils are considered both cookware and bakeware.${ansi.RESET}`,
|
|
146
|
+
);
|
|
147
|
+
expect(output).not.toContain("first");
|
|
148
|
+
});
|
|
124
149
|
});
|
|
125
150
|
|
|
126
151
|
describe("response parts", () => {
|
|
@@ -134,23 +159,43 @@ describe("render", () => {
|
|
|
134
159
|
render(state);
|
|
135
160
|
|
|
136
161
|
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
137
|
-
expect(output).toContain(
|
|
138
|
-
expect(output).toContain("Hello world");
|
|
162
|
+
expect(output).toContain(`💬 Hello world`);
|
|
139
163
|
});
|
|
140
164
|
});
|
|
141
165
|
|
|
142
166
|
describe("tool parts", () => {
|
|
143
|
-
it("should render tool part
|
|
167
|
+
it("should render tool part with indicator", () => {
|
|
144
168
|
const write = vi.fn();
|
|
145
169
|
const state = createMockState({
|
|
146
|
-
accumulatedResponse: [{ key: "xxx", title: "tool", text: "
|
|
170
|
+
accumulatedResponse: [{ key: "xxx", title: "tool", text: "bash: ls -la" }],
|
|
147
171
|
write,
|
|
148
172
|
});
|
|
149
173
|
|
|
150
174
|
render(state);
|
|
151
175
|
|
|
152
176
|
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
153
|
-
expect(output).toContain("
|
|
177
|
+
expect(output).toContain("bash: ls -la");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("multiple tool parts separated by inactive parts shouldn't have a blank line", () => {
|
|
181
|
+
const write = vi.fn();
|
|
182
|
+
const state = createMockState({
|
|
183
|
+
accumulatedResponse: [
|
|
184
|
+
{ key: "xxx", title: "tool", text: "foo" },
|
|
185
|
+
{ key: "xxx", title: "thinking", text: "need to do the stuff" },
|
|
186
|
+
{ key: "xxx", title: "tool", text: "bar" },
|
|
187
|
+
{ key: "xxx", title: "thinking", text: "ok, more stuff" },
|
|
188
|
+
{ key: "xxx", title: "tool", text: "baz" },
|
|
189
|
+
],
|
|
190
|
+
write,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
render(state);
|
|
194
|
+
|
|
195
|
+
const output = stripANSI(write.mock.calls.map((c) => c[0]).join("")).replaceAll(" ", "");
|
|
196
|
+
console.log(output);
|
|
197
|
+
expect(output).toContain("foo\nbar\n\n💭 ok");
|
|
198
|
+
expect(output).toContain("stuff\n\nbaz");
|
|
154
199
|
});
|
|
155
200
|
});
|
|
156
201
|
|
|
@@ -238,7 +283,7 @@ describe("render", () => {
|
|
|
238
283
|
const state = createMockState({
|
|
239
284
|
accumulatedResponse: [
|
|
240
285
|
{ key: "xxx", title: "thinking", text: "分析中" },
|
|
241
|
-
{ key: "xxx", title: "tool", text: "
|
|
286
|
+
{ key: "xxx", title: "tool", text: "bash: npm test" },
|
|
242
287
|
{ key: "xxx", title: "response", text: "Test results: 5 passed" },
|
|
243
288
|
],
|
|
244
289
|
write,
|
|
@@ -247,10 +292,9 @@ describe("render", () => {
|
|
|
247
292
|
render(state);
|
|
248
293
|
|
|
249
294
|
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
250
|
-
expect(output).not.toContain(
|
|
251
|
-
expect(output).
|
|
252
|
-
expect(output).toContain(
|
|
253
|
-
expect(output).toContain("Test results: 5 passed");
|
|
295
|
+
expect(output).not.toContain(`分析中`);
|
|
296
|
+
expect(output).toContain(`bash: npm test`);
|
|
297
|
+
expect(output).toContain(`💬 Test results: 5 passed`);
|
|
254
298
|
});
|
|
255
299
|
});
|
|
256
300
|
});
|
|
@@ -259,115 +303,123 @@ describe("wrapText", () => {
|
|
|
259
303
|
describe("basic wrapping", () => {
|
|
260
304
|
it("should return single line for text shorter than width", () => {
|
|
261
305
|
const result = wrapText("hello", 20);
|
|
262
|
-
expect(result).toEqual(["hello"]);
|
|
306
|
+
expect(result).toEqual([" hello"]);
|
|
263
307
|
});
|
|
264
308
|
|
|
265
309
|
it("should wrap text longer than width", () => {
|
|
266
310
|
const result = wrapText("hello world this is a long text", 10);
|
|
267
|
-
expect(result).toEqual(["hello", "world this", "
|
|
311
|
+
expect(result).toEqual([" hello", " world", " this is", " a long", " text"]);
|
|
268
312
|
});
|
|
269
313
|
|
|
270
314
|
it("should handle text exactly at width", () => {
|
|
271
315
|
const result = wrapText("1234567890", 10);
|
|
272
|
-
expect(result).toEqual(["1234567890"]);
|
|
316
|
+
expect(result).toEqual([" 1234567890"]);
|
|
273
317
|
});
|
|
274
318
|
|
|
275
319
|
it("should break long word that exceeds width", () => {
|
|
276
320
|
const result = wrapText("12345678901", 10);
|
|
277
|
-
expect(result).toEqual(["
|
|
321
|
+
expect(result).toEqual([" ", " 12345678", " 901"]);
|
|
278
322
|
});
|
|
279
323
|
});
|
|
280
324
|
|
|
281
325
|
describe("multiple lines", () => {
|
|
282
326
|
it("should preserve existing newlines", () => {
|
|
283
327
|
const result = wrapText("line1\nline2\nline3", 20);
|
|
284
|
-
expect(result).toEqual(["line1", "line2", "line3"]);
|
|
328
|
+
expect(result).toEqual([" line1", " line2", " line3"]);
|
|
285
329
|
});
|
|
286
330
|
|
|
287
331
|
it("should wrap lines that are too long", () => {
|
|
288
332
|
const result = wrapText("very long line1\nshort\nvery long line2", 10);
|
|
289
|
-
expect(result).toEqual([
|
|
333
|
+
expect(result).toEqual([
|
|
334
|
+
" very",
|
|
335
|
+
" long",
|
|
336
|
+
" line1",
|
|
337
|
+
" short",
|
|
338
|
+
" very",
|
|
339
|
+
" long",
|
|
340
|
+
" line2",
|
|
341
|
+
]);
|
|
290
342
|
});
|
|
291
343
|
|
|
292
344
|
it("should handle empty lines", () => {
|
|
293
345
|
const result = wrapText("line1\n\nline3", 20);
|
|
294
|
-
expect(result).toEqual(["line1", "", "line3"]);
|
|
346
|
+
expect(result).toEqual([" line1", " ", " line3"]);
|
|
295
347
|
});
|
|
296
348
|
});
|
|
297
349
|
|
|
298
350
|
describe("ANSI codes", () => {
|
|
299
351
|
it("should preserve ANSI codes in output", () => {
|
|
300
352
|
const result = wrapText("\x1b[31mred\x1b[0m text", 20);
|
|
301
|
-
expect(result).toEqual(["\x1b[31mred\x1b[0m text"]);
|
|
353
|
+
expect(result).toEqual([" \x1b[31mred\x1b[0m text"]);
|
|
302
354
|
});
|
|
303
355
|
|
|
304
356
|
it("should not count ANSI codes toward visible width", () => {
|
|
305
357
|
const result = wrapText("\x1b[31mred\x1b[0m text", 8);
|
|
306
|
-
expect(result).toEqual(["\x1b[31mred\x1b[0m text"]);
|
|
358
|
+
expect(result).toEqual([" \x1b[31mred\x1b[0m", " text"]);
|
|
307
359
|
});
|
|
308
360
|
|
|
309
361
|
it("should handle multiple ANSI codes", () => {
|
|
310
362
|
const result = wrapText("\x1b[31m\x1b[1mbold red\x1b[0m\x1b[32m green\x1b[0m", 10);
|
|
311
|
-
expect(result).toEqual(["\x1b[31m\x1b[1mbold red\x1b[0m\x1b[32m", "green\x1b[0m"]);
|
|
363
|
+
expect(result).toEqual([" \x1b[31m\x1b[1mbold red\x1b[0m\x1b[32m", " green\x1b[0m"]);
|
|
312
364
|
});
|
|
313
365
|
|
|
314
366
|
it("should handle ANSI codes at wrap boundary", () => {
|
|
315
367
|
const result = wrapText("12345\x1b[31m67890\x1b[0m", 10);
|
|
316
|
-
expect(result).toEqual(["12345\x1b[31m67890\x1b[0m"]);
|
|
368
|
+
expect(result).toEqual([" 12345\x1b[31m67890\x1b[0m"]);
|
|
317
369
|
});
|
|
318
370
|
});
|
|
319
371
|
|
|
320
372
|
describe("edge cases", () => {
|
|
321
373
|
it("should handle empty string", () => {
|
|
322
374
|
const result = wrapText("", 20);
|
|
323
|
-
expect(result).toEqual([""]);
|
|
375
|
+
expect(result).toEqual([" "]);
|
|
324
376
|
});
|
|
325
377
|
|
|
326
378
|
it("should handle single character", () => {
|
|
327
379
|
const result = wrapText("a", 20);
|
|
328
|
-
expect(result).toEqual(["a"]);
|
|
380
|
+
expect(result).toEqual([" a"]);
|
|
329
381
|
});
|
|
330
382
|
|
|
331
383
|
it("should handle width of 1", () => {
|
|
332
384
|
const result = wrapText("a b c", 1);
|
|
333
|
-
expect(result).toEqual(["a", "b", "c"]);
|
|
385
|
+
expect(result).toEqual([" a", " b", " c"]);
|
|
334
386
|
});
|
|
335
387
|
|
|
336
388
|
it("should handle carriage return characters", () => {
|
|
337
389
|
const result = wrapText("hello\r\nworld", 20);
|
|
338
|
-
expect(result).toEqual(["hello", "world"]);
|
|
390
|
+
expect(result).toEqual([" hello", " world"]);
|
|
339
391
|
});
|
|
340
392
|
|
|
341
393
|
it("should handle trailing newline", () => {
|
|
342
394
|
const result = wrapText("hello\n", 20);
|
|
343
|
-
expect(result).toEqual(["hello"]);
|
|
395
|
+
expect(result).toEqual([" hello"]);
|
|
344
396
|
});
|
|
345
397
|
|
|
346
398
|
it("should handle multiple trailing newlines", () => {
|
|
347
399
|
const result = wrapText("hello\n\n", 20);
|
|
348
|
-
expect(result).toEqual(["hello", ""]);
|
|
400
|
+
expect(result).toEqual([" hello", " "]);
|
|
349
401
|
});
|
|
350
402
|
|
|
351
403
|
it("should handle leading newline", () => {
|
|
352
404
|
const result = wrapText("\nhello", 20);
|
|
353
|
-
expect(result).toEqual(["", "hello"]);
|
|
405
|
+
expect(result).toEqual([" ", " hello"]);
|
|
354
406
|
});
|
|
355
407
|
});
|
|
356
408
|
|
|
357
409
|
describe("real-world scenarios", () => {
|
|
358
|
-
it("should wrap thinking output with
|
|
410
|
+
it("should wrap thinking output with indicator", () => {
|
|
359
411
|
const result = wrapText(
|
|
360
|
-
"
|
|
361
|
-
|
|
412
|
+
"Let me analyze this problem step by step to find the best solution",
|
|
413
|
+
36,
|
|
362
414
|
);
|
|
363
415
|
expect(result.length).toBeGreaterThan(1);
|
|
364
|
-
expect(result[0]).toBe("
|
|
416
|
+
expect(result[0]).toBe(" Let me analyze this problem step");
|
|
365
417
|
});
|
|
366
418
|
|
|
367
|
-
it("should wrap response output with
|
|
419
|
+
it("should wrap response output with indicator", () => {
|
|
368
420
|
const result = wrapText(
|
|
369
|
-
"
|
|
370
|
-
|
|
421
|
+
"Here is the solution:\nWe need to implement the fix by updating the wrapText function",
|
|
422
|
+
26,
|
|
371
423
|
);
|
|
372
424
|
expect(result.length).toBeGreaterThan(1);
|
|
373
425
|
});
|