@sofer_agent/cli 0.3.1 → 0.3.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/dist/bin.js +3 -1
- package/dist/bin.js.map +1 -1
- package/dist/chat.d.ts +1 -1
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +11 -6
- package/dist/chat.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +11 -3
- package/dist/init.js.map +1 -1
- package/dist/secrets.d.ts.map +1 -1
- package/dist/secrets.js.map +1 -1
- package/dist/tui.d.ts +18 -18
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +211 -115
- package/dist/tui.js.map +1 -1
- package/package.json +2 -2
- package/src/bin.ts +8 -2
- package/src/chat.ts +10 -6
- package/src/init.ts +16 -17
- package/src/secrets.ts +6 -2
- package/src/tui.ts +220 -124
package/src/tui.ts
CHANGED
|
@@ -1,46 +1,50 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { SoferConfig } from "@sofer_agent/core";
|
|
3
|
-
import type { SoferAgent } from "@sofer_agent/core";
|
|
1
|
+
import type { SoferAgent, SoferConfig } from "@sofer_agent/core";
|
|
4
2
|
|
|
5
|
-
const SIDEBAR_W =
|
|
3
|
+
const SIDEBAR_W = 26;
|
|
6
4
|
const MIN_CHAT_W = 30;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
const GUTTER = " ";
|
|
6
|
+
const LABEL_W = 6;
|
|
7
|
+
const INDENT = `${GUTTER}${" ".repeat(LABEL_W + 1)}`;
|
|
8
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
|
+
const SPINNER_MS = 80;
|
|
10
|
+
|
|
11
|
+
export interface ChatLine {
|
|
12
|
+
role: "you" | "agent" | "system" | "error";
|
|
10
13
|
text: string;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
export interface
|
|
14
|
-
messages: ChatLine[];
|
|
16
|
+
export interface TuiInfo {
|
|
15
17
|
lastTurnIn: number;
|
|
16
18
|
lastTurnOut: number;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export class SoferTui {
|
|
20
|
-
private state:
|
|
22
|
+
private state: ChatLine[] = [];
|
|
21
23
|
private inputBuf = "";
|
|
22
24
|
private cursor = 0;
|
|
23
|
-
private rl: readline.Interface | null = null;
|
|
24
25
|
private columns = 80;
|
|
25
26
|
private rows = 24;
|
|
26
|
-
private chatH = 20;
|
|
27
27
|
private chatW = 50;
|
|
28
|
+
private chatH = 20;
|
|
28
29
|
private scrollOff = 0;
|
|
30
|
+
// spinner
|
|
31
|
+
private status: "idle" | "thinking" = "idle";
|
|
32
|
+
private spinnerFrame = 0;
|
|
33
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
private turnStartedAt = 0;
|
|
35
|
+
// interrupt
|
|
36
|
+
private abortCtrl: AbortController | null = null;
|
|
37
|
+
// sidebar info
|
|
38
|
+
private info: TuiInfo = { lastTurnIn: 0, lastTurnOut: 0 };
|
|
39
|
+
// callbacks
|
|
29
40
|
private resolve: (() => void) | null = null;
|
|
30
|
-
private onLine: ((line: string) => void) | null = null;
|
|
41
|
+
private onLine: ((line: string, signal?: AbortSignal) => Promise<void>) | null = null;
|
|
31
42
|
|
|
32
43
|
constructor(
|
|
33
44
|
private readonly agent: SoferAgent,
|
|
34
45
|
private readonly config: SoferConfig,
|
|
35
46
|
) {}
|
|
36
47
|
|
|
37
|
-
get width() {
|
|
38
|
-
return this.columns;
|
|
39
|
-
}
|
|
40
|
-
get height() {
|
|
41
|
-
return this.rows;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
48
|
private measure(): void {
|
|
45
49
|
this.columns = process.stdout.columns ?? 80;
|
|
46
50
|
this.rows = process.stdout.rows ?? 24;
|
|
@@ -49,16 +53,35 @@ export class SoferTui {
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
addMessage(role: ChatLine["role"], text: string): void {
|
|
52
|
-
this.state.
|
|
56
|
+
this.state.push({ role, text });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setInfo(info: Partial<TuiInfo>): void {
|
|
60
|
+
Object.assign(this.info, info);
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
|
|
56
|
-
this.
|
|
57
|
-
this.
|
|
63
|
+
private startSpinner(): void {
|
|
64
|
+
if (this.spinnerTimer) return;
|
|
65
|
+
this.status = "thinking";
|
|
66
|
+
this.turnStartedAt = Date.now();
|
|
67
|
+
this.spinnerFrame = 0;
|
|
68
|
+
this.spinnerTimer = setInterval(() => {
|
|
69
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
70
|
+
this.render();
|
|
71
|
+
}, SPINNER_MS);
|
|
58
72
|
}
|
|
59
73
|
|
|
60
|
-
|
|
61
|
-
|
|
74
|
+
private stopSpinner(): void {
|
|
75
|
+
this.status = "idle";
|
|
76
|
+
this.turnStartedAt = 0;
|
|
77
|
+
if (this.spinnerTimer) {
|
|
78
|
+
clearInterval(this.spinnerTimer);
|
|
79
|
+
this.spinnerTimer = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Start the interactive TUI loop. */
|
|
84
|
+
async run(onLine: (line: string, signal?: AbortSignal) => Promise<void>): Promise<void> {
|
|
62
85
|
this.onLine = onLine;
|
|
63
86
|
this.measure();
|
|
64
87
|
|
|
@@ -74,11 +97,12 @@ export class SoferTui {
|
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
private shutdown(): void {
|
|
100
|
+
this.stopSpinner();
|
|
77
101
|
process.stdin.setRawMode?.(false);
|
|
78
102
|
process.stdin.removeAllListeners("data");
|
|
79
103
|
process.stdout.removeAllListeners("resize");
|
|
80
104
|
process.removeAllListeners("SIGWINCH");
|
|
81
|
-
this.render();
|
|
105
|
+
this.render();
|
|
82
106
|
process.stdout.write("\n");
|
|
83
107
|
this.resolve?.();
|
|
84
108
|
}
|
|
@@ -90,12 +114,25 @@ export class SoferTui {
|
|
|
90
114
|
for (const ch of str) {
|
|
91
115
|
const code = ch.charCodeAt(0);
|
|
92
116
|
if (code === 3) {
|
|
93
|
-
|
|
117
|
+
this.shutdown();
|
|
118
|
+
return;
|
|
94
119
|
} // Ctrl-C
|
|
95
120
|
if (code === 4 && this.inputBuf.length === 0) {
|
|
96
121
|
this.shutdown();
|
|
97
122
|
return;
|
|
98
|
-
} // Ctrl-D
|
|
123
|
+
} // Ctrl-D
|
|
124
|
+
if (code === 27) {
|
|
125
|
+
// Escape
|
|
126
|
+
if (this.status === "thinking" && this.abortCtrl) {
|
|
127
|
+
this.abortCtrl.abort();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.inputBuf = ""; // clear input on Esc when idle
|
|
131
|
+
this.cursor = 0;
|
|
132
|
+
this.render();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (this.status === "thinking") continue; // reject input while thinking
|
|
99
136
|
if (code === 13) {
|
|
100
137
|
this.submit();
|
|
101
138
|
return;
|
|
@@ -110,13 +147,6 @@ export class SoferTui {
|
|
|
110
147
|
this.render();
|
|
111
148
|
return;
|
|
112
149
|
}
|
|
113
|
-
if (code === 27 && this.inputBuf.length > 0) {
|
|
114
|
-
// Escape clears input
|
|
115
|
-
this.inputBuf = "";
|
|
116
|
-
this.cursor = 0;
|
|
117
|
-
this.render();
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
150
|
if (ch >= " ") {
|
|
121
151
|
this.inputBuf = this.inputBuf.slice(0, this.cursor) + ch + this.inputBuf.slice(this.cursor);
|
|
122
152
|
this.cursor++;
|
|
@@ -137,47 +167,51 @@ export class SoferTui {
|
|
|
137
167
|
this.shutdown();
|
|
138
168
|
return;
|
|
139
169
|
}
|
|
170
|
+
|
|
140
171
|
this.addMessage("you", line);
|
|
141
|
-
this.scrollOff = 0;
|
|
172
|
+
this.scrollOff = 0;
|
|
173
|
+
this.abortCtrl = new AbortController();
|
|
174
|
+
this.startSpinner();
|
|
142
175
|
this.render();
|
|
143
|
-
await this.onLine?.(line);
|
|
144
|
-
}
|
|
145
176
|
|
|
146
|
-
|
|
177
|
+
await this.onLine?.(line, this.abortCtrl.signal);
|
|
178
|
+
|
|
179
|
+
this.abortCtrl = null;
|
|
180
|
+
this.stopSpinner();
|
|
181
|
+
this.render();
|
|
182
|
+
}
|
|
147
183
|
|
|
184
|
+
// ── Rendering ───────────────────────────────────────────────────────────
|
|
148
185
|
private handleResize = (): void => {
|
|
149
186
|
this.measure();
|
|
150
187
|
this.render();
|
|
151
188
|
};
|
|
152
189
|
|
|
153
|
-
/** Reset cursor, clear, redraw everything. */
|
|
154
190
|
render(): void {
|
|
155
191
|
this.measure();
|
|
156
192
|
const out = process.stdout;
|
|
157
|
-
out.write("\x1b[?25l");
|
|
193
|
+
out.write("\x1b[?25l");
|
|
194
|
+
out.write("\x1b[H");
|
|
158
195
|
out.write(this.buildFrame());
|
|
159
|
-
out.write("\x1b[?25h");
|
|
196
|
+
out.write("\x1b[?25h");
|
|
160
197
|
}
|
|
161
198
|
|
|
162
199
|
private buildFrame(): string {
|
|
163
|
-
const {
|
|
164
|
-
const rightW = columns - chatW - 1;
|
|
165
|
-
|
|
166
|
-
let buf = "\x1b[H"; // cursor to top-left
|
|
200
|
+
const { chatW, chatH } = this;
|
|
201
|
+
const rightW = this.columns - chatW - 1;
|
|
202
|
+
let buf = "";
|
|
167
203
|
|
|
168
|
-
// ──
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
const wrappedLines = msgs.flatMap((m) => this.wrapLine(m, chatW));
|
|
172
|
-
const totalLines = wrappedLines.length;
|
|
173
|
-
const maxScroll = Math.max(0, totalLines - chatH);
|
|
204
|
+
// ── Chat body ─────────────────────────────────────────────────────────
|
|
205
|
+
const wrapped = this.state.flatMap((m) => this.wrapMessage(m, chatW));
|
|
206
|
+
const maxScroll = Math.max(0, wrapped.length - chatH);
|
|
174
207
|
if (this.scrollOff > maxScroll) this.scrollOff = maxScroll;
|
|
175
208
|
if (this.scrollOff < 0) this.scrollOff = 0;
|
|
176
|
-
const visible =
|
|
209
|
+
const visible = wrapped.slice(this.scrollOff, this.scrollOff + chatH);
|
|
177
210
|
|
|
178
211
|
for (let r = 0; r < chatH; r++) {
|
|
179
212
|
const line = visible[r];
|
|
180
|
-
buf +=
|
|
213
|
+
if (line) buf += this.pad(line, chatW);
|
|
214
|
+
else buf += " ".repeat(chatW);
|
|
181
215
|
if (r === 0) buf += "┬";
|
|
182
216
|
else if (r === chatH - 1) buf += "┴";
|
|
183
217
|
else buf += "│";
|
|
@@ -188,108 +222,170 @@ export class SoferTui {
|
|
|
188
222
|
// ── Divider ───────────────────────────────────────────────────────────
|
|
189
223
|
buf += `${"─".repeat(chatW)}┴${"─".repeat(rightW)}\n`;
|
|
190
224
|
|
|
191
|
-
// ──
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (typed.length > maxInputW) {
|
|
197
|
-
const start = typed.length - maxInputW;
|
|
198
|
-
typed = typed.slice(start);
|
|
225
|
+
// ── Spinner row ───────────────────────────────────────────────────────
|
|
226
|
+
if (this.status === "thinking") {
|
|
227
|
+
const frame = SPINNER_FRAMES[this.spinnerFrame];
|
|
228
|
+
const elapsed = this.turnStartedAt ? formatElapsed(Date.now() - this.turnStartedAt) : "";
|
|
229
|
+
buf += `\x1b[36m${frame} thinking…${elapsed ? ` ${elapsed}` : ""} (esc to interrupt)\x1b[0m\x1b[K\n`;
|
|
199
230
|
}
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
const
|
|
203
|
-
|
|
231
|
+
|
|
232
|
+
// ── Input bar ─────────────────────────────────────────────────────────
|
|
233
|
+
const prefix = "\x1b[36m> \x1b[0m";
|
|
234
|
+
const cursorChar = this.status === "idle" ? "\x1b[5m▋\x1b[25m" : "";
|
|
235
|
+
buf += `${prefix}${this.inputBuf}${cursorChar}\x1b[K`;
|
|
236
|
+
|
|
237
|
+
// Footer
|
|
238
|
+
const totalIn = this.agent.usage.tokens.inputTokens;
|
|
239
|
+
const totalOut = this.agent.usage.tokens.outputTokens;
|
|
240
|
+
const cost = this.agent.usage.costUsd.toFixed(4);
|
|
241
|
+
const footer = `\x1b[90m${this.agent.address.slice(0, 10)}… · tokens ${totalIn}/${totalOut} · $${cost} · /exit to quit\x1b[0m`;
|
|
242
|
+
buf += `\n${this.pad(footer, this.columns)}`;
|
|
243
|
+
|
|
244
|
+
// Place cursor at input position
|
|
245
|
+
const cursorCol = 2 + Math.min(this.cursor, this.columns - 5);
|
|
246
|
+
buf += `\x1b[${this.chatH + 2 + (this.status === "thinking" ? 1 : 0)};${cursorCol}H`;
|
|
204
247
|
|
|
205
248
|
return buf;
|
|
206
249
|
}
|
|
207
250
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
251
|
+
// ── Message wrapping with gutter ────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
private wrapMessage(msg: ChatLine, width: number): string[] {
|
|
254
|
+
const label = this.roleLabel(msg.role);
|
|
255
|
+
const bodyWidth = width - GUTTER.length - LABEL_W - 1;
|
|
256
|
+
const lines = wrapText(msg.text, bodyWidth);
|
|
257
|
+
return lines.map((l, i) => {
|
|
258
|
+
if (i === 0) return `${GUTTER}${label} ${l}`;
|
|
259
|
+
return `${INDENT}${l}`;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private roleLabel(role: ChatLine["role"]): string {
|
|
264
|
+
switch (role) {
|
|
265
|
+
case "you":
|
|
266
|
+
return "\x1b[36myou \x1b[0m"; // cyan "you", padded to LABEL_W
|
|
267
|
+
case "agent":
|
|
268
|
+
return `\x1b[32m${this.agent.name.toLowerCase().slice(0, LABEL_W).padEnd(LABEL_W)}\x1b[0m`;
|
|
269
|
+
case "system":
|
|
270
|
+
return "\x1b[90msys \x1b[0m";
|
|
271
|
+
case "error":
|
|
272
|
+
return "\x1b[31merr \x1b[0m";
|
|
229
273
|
}
|
|
230
|
-
if (current.length > label.length) lines.push(current);
|
|
231
|
-
return lines.length > 0 ? lines : [full];
|
|
232
274
|
}
|
|
233
275
|
|
|
234
276
|
private pad(s: string, w: number): string {
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
const stripped = s.replace(ansiRe, "");
|
|
238
|
-
const padW = Math.max(0, w - stripped.length);
|
|
277
|
+
const stripped = stripAnsi(s);
|
|
278
|
+
const padW = Math.max(0, w - visibleLen(stripped));
|
|
239
279
|
return s + " ".repeat(padW);
|
|
240
280
|
}
|
|
241
281
|
|
|
242
|
-
private blank(w: number): string {
|
|
243
|
-
return " ".repeat(w);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
282
|
// ── Sidebar ─────────────────────────────────────────────────────────────
|
|
247
283
|
|
|
248
284
|
private sidebarRow(row: number, total: number, width: number): string {
|
|
249
|
-
const innerW = width - 2;
|
|
285
|
+
const innerW = width - 2;
|
|
250
286
|
const lines = this.buildSidebar(innerW);
|
|
251
|
-
|
|
252
|
-
// center the sidebar content vertically
|
|
253
287
|
const gap = Math.max(0, total - lines.length);
|
|
254
288
|
const topPad = Math.floor(gap / 2);
|
|
255
289
|
const idx = row - topPad;
|
|
256
|
-
|
|
257
290
|
if (idx < 0 || idx >= lines.length) return " ".repeat(width);
|
|
258
291
|
const line = lines[idx];
|
|
259
292
|
if (!line) return " ".repeat(width);
|
|
260
|
-
|
|
293
|
+
const padded = padRight(line, innerW);
|
|
294
|
+
return ` ${padded} `;
|
|
261
295
|
}
|
|
262
296
|
|
|
263
297
|
private buildSidebar(width: number): string[] {
|
|
264
|
-
const { agent, config,
|
|
265
|
-
const addr = `${agent.address.slice(0, 6)}
|
|
298
|
+
const { agent, config, info } = this;
|
|
299
|
+
const addr = `${agent.address.slice(0, 6)}…${agent.address.slice(-4)}`;
|
|
300
|
+
const hbar = "─".repeat(width);
|
|
301
|
+
const thinBar = "·".repeat(width);
|
|
266
302
|
const totalIn = agent.usage.tokens.inputTokens;
|
|
267
303
|
const totalOut = agent.usage.tokens.outputTokens;
|
|
268
304
|
const cost = agent.usage.costUsd.toFixed(4);
|
|
269
305
|
|
|
270
306
|
return [
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
`Max out: ${config.brain.maxOutputTokens}`,
|
|
307
|
+
hbar,
|
|
308
|
+
`\x1b[1;36m${agent.name}\x1b[0m`,
|
|
309
|
+
`\x1b[90m${addr}\x1b[0m`,
|
|
275
310
|
"",
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
`
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
`
|
|
311
|
+
`\x1b[90mnetwork\x1b[0m ${config.network}`,
|
|
312
|
+
`\x1b[90mmodel\x1b[0m ${shortModel(config.brain.model)}`,
|
|
313
|
+
thinBar,
|
|
314
|
+
`\x1b[90mlast turn\x1b[0m`,
|
|
315
|
+
` in ${info.lastTurnIn}`,
|
|
316
|
+
` out ${info.lastTurnOut}`,
|
|
317
|
+
`\x1b[90mtotal\x1b[0m`,
|
|
318
|
+
` in ${totalIn}`,
|
|
319
|
+
` out ${totalOut}`,
|
|
320
|
+
`\x1b[90mcost\x1b[0m \x1b[33m$${cost}\x1b[0m`,
|
|
321
|
+
thinBar,
|
|
322
|
+
`\x1b[90maddress\x1b[0m`,
|
|
323
|
+
` ${agent.address.slice(0, 16)}`,
|
|
324
|
+
` ${agent.address.slice(16, 32)}`,
|
|
325
|
+
` ${agent.address.slice(32)}`,
|
|
326
|
+
hbar,
|
|
284
327
|
];
|
|
285
328
|
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
function wrapText(text: string, width: number): string[] {
|
|
334
|
+
if (!text) return [""];
|
|
335
|
+
const lines: string[] = [];
|
|
336
|
+
const words = text.split(" ");
|
|
337
|
+
let current = "";
|
|
338
|
+
for (const word of words) {
|
|
339
|
+
if (current && current.length + 1 + word.length > width) {
|
|
340
|
+
lines.push(current);
|
|
341
|
+
current = word;
|
|
342
|
+
} else {
|
|
343
|
+
current = current ? `${current} ${word}` : word;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (current) lines.push(current);
|
|
347
|
+
return lines.length > 0 ? lines : [""];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function stripAnsi(s: string): string {
|
|
351
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping
|
|
352
|
+
return s.replace(/\x1b\[\d*;?\d*m/g, "");
|
|
353
|
+
}
|
|
286
354
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
355
|
+
function visibleLen(s: string): number {
|
|
356
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping
|
|
357
|
+
const ansiRe = /\x1b\[\d*;?\d*m/g;
|
|
358
|
+
let len = 0;
|
|
359
|
+
let lastIdx = 0;
|
|
360
|
+
let match = ansiRe.exec(s);
|
|
361
|
+
while (match !== null) {
|
|
362
|
+
len += match.index - lastIdx;
|
|
363
|
+
lastIdx = match.index + match[0].length;
|
|
364
|
+
match = ansiRe.exec(s);
|
|
294
365
|
}
|
|
366
|
+
len += s.length - lastIdx;
|
|
367
|
+
return len;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function padRight(s: string, width: number): string {
|
|
371
|
+
const len = visibleLen(s);
|
|
372
|
+
if (len >= width) return s;
|
|
373
|
+
return s + " ".repeat(width - len);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function formatElapsed(ms: number): string {
|
|
377
|
+
const sec = Math.floor(ms / 1000);
|
|
378
|
+
if (sec < 60) return `${sec}s`;
|
|
379
|
+
const m = Math.floor(sec / 60);
|
|
380
|
+
const s = sec % 60;
|
|
381
|
+
return `${m}m${String(s).padStart(2, "0")}s`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function shortModel(model: string): string {
|
|
385
|
+
const m = model.toLowerCase();
|
|
386
|
+
if (m.includes("sonnet")) return "Sonnet 4";
|
|
387
|
+
if (m.includes("haiku")) return "Haiku";
|
|
388
|
+
if (m.includes("opus")) return "Opus 4";
|
|
389
|
+
if (m.includes("fable")) return "Fable 5";
|
|
390
|
+
return model.length > 14 ? `${model.slice(0, 13)}…` : model;
|
|
295
391
|
}
|