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