@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/src/tui.ts CHANGED
@@ -1,46 +1,50 @@
1
- import type * as readline from "node:readline";
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 = 28;
3
+ const SIDEBAR_W = 26;
6
4
  const MIN_CHAT_W = 30;
7
-
8
- interface ChatLine {
9
- role: "you" | "agent" | "error";
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[];
16
+ export interface TuiInfo {
15
17
  lastTurnIn: number;
16
18
  lastTurnOut: number;
17
19
  }
18
20
 
19
21
  export class SoferTui {
20
- private state: TuiState = { messages: [], lastTurnIn: 0, lastTurnOut: 0 };
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.messages.push({ role, text });
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
- setUsage(lastTurnIn: number, lastTurnOut: number): void {
56
- this.state.lastTurnIn = lastTurnIn;
57
- this.state.lastTurnOut = lastTurnOut;
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
- /** Start the interactive TUI loop. Returns when the user exits. */
61
- async run(onLine: (line: string) => Promise<void>): Promise<void> {
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(); // final render in normal mode
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
- process.exit(0);
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 on empty
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; // auto-scroll to latest
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
- // ── Terminal rendering ──────────────────────────────────────────────────
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"); // hide cursor
193
+ out.write("\x1b[?25l");
194
+ out.write("\x1b[H");
158
195
  out.write(this.buildFrame());
159
- out.write("\x1b[?25h"); // show cursor
196
+ out.write("\x1b[?25h");
160
197
  }
161
198
 
162
199
  private buildFrame(): string {
163
- const { columns, rows, chatW, chatH } = this;
164
- const rightW = columns - chatW - 1; // border column
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
- // ── Body ──────────────────────────────────────────────────────────────
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);
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 = wrappedLines.slice(this.scrollOff, this.scrollOff + chatH);
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 += line ? this.pad(line, chatW) : this.blank(chatW);
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
- // ── Input line ────────────────────────────────────────────────────────
192
- const prefix = "\x1b[36myou>\x1b[0m ";
193
- const prefixW = 4;
194
- const maxInputW = columns - prefixW - 1;
195
- let typed = this.inputBuf;
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
- buf += `${prefix}${typed}\x1b[K`; // clear to end of line
201
- // Place cursor at end of input
202
- const cursorCol = prefixW + Math.min(this.cursor, maxInputW);
203
- buf += `\x1b[${chatH + 2};${cursorCol}H`;
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
- private wrapLine(msg: ChatLine, width: number): string[] {
209
- const label =
210
- msg.role === "you"
211
- ? "\x1b[36myou>\x1b[0m "
212
- : msg.role === "agent"
213
- ? `\x1b[32m${this.agent.name.toLowerCase()}>\x1b[0m `
214
- : "\x1b[31merror>\x1b[0m ";
215
- const content = msg.text;
216
- const full = label + content;
217
- if (full.length <= width) return [full];
218
- // simple word-wrap
219
- const lines: string[] = [];
220
- let current = label;
221
- const words = content.split(" ");
222
- for (const word of words) {
223
- if (current.length + 1 + word.length <= width) {
224
- current += (current === label ? "" : " ") + word;
225
- } else {
226
- if (current.length > label.length) lines.push(current);
227
- current = ` ${word}`; // indent continuation
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
- // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping
236
- const ansiRe = /\x1b\[\d*;?\d*m/g;
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; // left & right padding
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
- return ` ${this.pad(line, innerW)} `;
293
+ const padded = padRight(line, innerW);
294
+ return ` ${padded} `;
261
295
  }
262
296
 
263
297
  private buildSidebar(width: number): string[] {
264
- const { agent, config, state } = this;
265
- const addr = `${agent.address.slice(0, 6)}...${agent.address.slice(-4)}`;
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
- `\x1b[1m${agent.name}\x1b[0m`,
272
- "─".repeat(width),
273
- `Model: ${this.shortModel(config.brain.model)}`,
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
- `Turn in: ${state.lastTurnIn}`,
277
- `Turn out: ${state.lastTurnOut}`,
278
- `Total in: ${totalIn}`,
279
- `Total out:${totalOut}`,
280
- `Cost: \x1b[33m$${cost}\x1b[0m`,
281
- "",
282
- `Addr: ${addr}`,
283
- `Network: ${config.network}`,
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
- private shortModel(model: string): string {
288
- const m = model.toLowerCase();
289
- if (m.includes("sonnet")) return "Sonnet 4";
290
- if (m.includes("haiku")) return "Haiku 3.5";
291
- if (m.includes("opus")) return "Opus 4";
292
- if (m.includes("fable")) return "Fable 5";
293
- return model;
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
  }