@agjs/tsforge 0.1.12 → 0.1.14

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.1.12",
4
+ "version": "0.1.14",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
package/src/cli.ts CHANGED
@@ -25,9 +25,11 @@ import {
25
25
  renderEvent,
26
26
  renderMessage,
27
27
  renderStatus,
28
+ StatusBar,
28
29
  welcomeBanner,
29
30
  STYLE,
30
31
  RESET,
32
+ type IStatusInfo,
31
33
  } from "./render";
32
34
  import type { ITask } from "./spec";
33
35
  import type { Reporter, ILoopEvent } from "./loop";
@@ -440,12 +442,14 @@ function makeSpinner(): {
440
442
  clear: () => void;
441
443
  stop: () => void;
442
444
  setLabel: (label: string) => void;
445
+ onTick: (cb: () => void) => void;
443
446
  } {
444
447
  let timer: ReturnType<typeof setInterval> | null = null;
445
448
  let startedAt = 0;
446
449
  let frame = 0;
447
450
  let drawn = false;
448
451
  let label = "thinking";
452
+ let onTickCb: (() => void) | null = null;
449
453
 
450
454
  const clear = (): void => {
451
455
  if (drawn) {
@@ -462,6 +466,7 @@ function makeSpinner(): {
462
466
  `${ERASE_LINE} ${STYLE.dim}${SPINNER_FRAMES[frame] ?? ""} ${label} · ${secs}s${RESET}`
463
467
  );
464
468
  drawn = true;
469
+ onTickCb?.(); // repaint the pinned status bar with live tok/s / context
465
470
  };
466
471
 
467
472
  return {
@@ -486,6 +491,9 @@ function makeSpinner(): {
486
491
  setLabel: (l: string): void => {
487
492
  label = l;
488
493
  },
494
+ onTick: (cb: () => void): void => {
495
+ onTickCb = cb;
496
+ },
489
497
  };
490
498
  }
491
499
 
@@ -962,6 +970,7 @@ async function repl(args: ICliArgs): Promise<number> {
962
970
  active = new AbortController();
963
971
  const started = performance.now();
964
972
 
973
+ lastStatus = "working"; // reflected live on the bar (● working) during the turn
965
974
  spinner.start();
966
975
 
967
976
  try {
@@ -1220,22 +1229,52 @@ async function repl(args: ICliArgs): Promise<number> {
1220
1229
  return false;
1221
1230
  };
1222
1231
 
1223
- // The persistent status line, shown above every prompt so the model, real
1224
- // context-window usage, scope, and last-turn outcome are always in view.
1232
+ // Current state as the status surface sees it shared by the pinned bar and
1233
+ // the inline fallback so both show identical content.
1234
+ const statusInfo = (): IStatusInfo => ({
1235
+ model: modelInfo(provider.config).model,
1236
+ contextTokens: session.contextTokens,
1237
+ contextWindow,
1238
+ turns: lastTurns,
1239
+ elapsedMs: lastElapsedMs,
1240
+ status: lastStatus,
1241
+ scope: scopeLabel(session.scope) + (planMode ? " · PLAN" : ""),
1242
+ tokensPerSecond: session.metrics.lastTokensPerSecond,
1243
+ });
1244
+
1245
+ // Pinned bottom status bar when we're on a real terminal; otherwise the bar is
1246
+ // inactive and `prompt()` falls back to the inline status line (pipes, --log).
1247
+ const statusBar = new StatusBar(process.stdout, true, true);
1248
+
1249
+ // Repaint the bar on every spinner tick so tok/s and the context meter update
1250
+ // live mid-turn (both read live session state), not just at turn boundaries.
1251
+ spinner.onTick(() => {
1252
+ if (statusBar.active) {
1253
+ statusBar.update(statusInfo());
1254
+ }
1255
+ });
1256
+
1257
+ process.stdout.on("resize", () => {
1258
+ statusBar.resize(statusInfo());
1259
+ });
1260
+
1261
+ // Restore the terminal even on an unexpected exit (teardown is idempotent).
1262
+ process.on("exit", () => {
1263
+ statusBar.teardown();
1264
+ });
1265
+
1266
+ // The prompt. With the bar pinned it repaints the bar and shows only the
1267
+ // input marker; otherwise it prints the inline status line above the marker.
1225
1268
  const prompt = (): void => {
1269
+ if (statusBar.active) {
1270
+ statusBar.update(statusInfo());
1271
+ process.stdout.write("\n› ");
1272
+
1273
+ return;
1274
+ }
1275
+
1226
1276
  process.stdout.write("\n");
1227
- process.stdout.write(
1228
- renderStatus({
1229
- model: modelInfo(provider.config).model,
1230
- contextTokens: session.contextTokens,
1231
- contextWindow,
1232
- turns: lastTurns,
1233
- elapsedMs: lastElapsedMs,
1234
- status: lastStatus,
1235
- scope: scopeLabel(session.scope) + (planMode ? " · PLAN" : ""),
1236
- tokensPerSecond: session.metrics.lastTokensPerSecond,
1237
- })
1238
- );
1277
+ process.stdout.write(renderStatus(statusInfo()));
1239
1278
  process.stdout.write("› ");
1240
1279
  };
1241
1280
 
@@ -1316,9 +1355,13 @@ async function repl(args: ICliArgs): Promise<number> {
1316
1355
 
1317
1356
  rl.on("close", () => {
1318
1357
  closed = true;
1358
+ statusBar.teardown();
1319
1359
  maybeFinish();
1320
1360
  });
1321
1361
 
1362
+ // Pin the bar before the first turn so it's visible while that turn streams.
1363
+ statusBar.install(statusInfo());
1364
+
1322
1365
  if (args.task.length > 0) {
1323
1366
  void runLine(args.task); // sent as the first message; prompts when done
1324
1367
  } else {
@@ -1326,6 +1369,8 @@ async function repl(args: ICliArgs): Promise<number> {
1326
1369
  }
1327
1370
  });
1328
1371
 
1372
+ statusBar.teardown(); // belt-and-suspenders: restore the terminal on loop exit
1373
+
1329
1374
  return 0;
1330
1375
  }
1331
1376
 
@@ -44,15 +44,11 @@ function humanDuration(ms: number): string {
44
44
  }
45
45
 
46
46
  /**
47
- * The post-turn status line — model, context-window usage, turns, elapsed, last
48
- * outcome, scope — the at-a-glance summary modern CLIs keep on screen. Dim, one
49
- * line, printed after a turn settles.
47
+ * The status segments — model, context-window usage, turns, elapsed, tok/s, last
48
+ * outcome, scope — as a plain-text list. Shared by the inline `renderStatus`
49
+ * fallback and the pinned `StatusBar`, so both show identical content.
50
50
  */
51
- export function renderStatus(
52
- info: IStatusInfo,
53
- opts: IRenderOptions = {}
54
- ): string {
55
- const color = opts.color ?? true;
51
+ export function statusSegments(info: IStatusInfo): string[] {
56
52
  const pct =
57
53
  info.contextWindow > 0
58
54
  ? Math.round((info.contextTokens / info.contextWindow) * 100)
@@ -77,6 +73,20 @@ export function renderStatus(
77
73
 
78
74
  bits.push(info.status, info.scope);
79
75
 
76
+ return bits;
77
+ }
78
+
79
+ /**
80
+ * The post-turn status line — the inline fallback used when a pinned status bar
81
+ * can't be installed (non-TTY, piped, `--log`, tiny terminal). Dim, one line.
82
+ */
83
+ export function renderStatus(
84
+ info: IStatusInfo,
85
+ opts: IRenderOptions = {}
86
+ ): string {
87
+ const color = opts.color ?? true;
88
+ const bits = statusSegments(info);
89
+
80
90
  return `${paint(` ⎯ ${bits.join(" · ")}`, STYLE.dim, color)}\n`;
81
91
  }
82
92
 
@@ -1,5 +1,15 @@
1
1
  export * from "./render.types";
2
- export { renderEvent, renderMessage, renderStatus } from "./ansi";
2
+ export {
3
+ renderEvent,
4
+ renderMessage,
5
+ renderStatus,
6
+ statusSegments,
7
+ } from "./ansi";
8
+ export {
9
+ StatusBar,
10
+ buildBarFrame,
11
+ type IStatusBarTerminal,
12
+ } from "./status-bar";
3
13
  export { welcomeBanner, type IBannerInfo } from "./banner";
4
14
  export { box, table, GLYPH } from "./box";
5
15
  export { renderMarkdown, formatTables, highlightCode } from "./markdown";
@@ -0,0 +1,230 @@
1
+ import type { IStatusInfo } from "./render.types";
2
+ import { STYLE, paint } from "./style";
3
+
4
+ const ESC = "\x1b";
5
+
6
+ /** Rows reserved at the bottom: a top border rule + the segments line. */
7
+ const RESERVED_ROWS = 2;
8
+
9
+ /** Below this height, a 2-row bar would crowd the conversation — fall back to inline. */
10
+ const MIN_ROWS = 5;
11
+
12
+ /** Cells in the context meter. */
13
+ const METER_CELLS = 9;
14
+
15
+ /** The minimal terminal surface the bar needs — matches `process.stdout` but is
16
+ * injectable so the controller is testable without a real TTY. */
17
+ export interface IStatusBarTerminal {
18
+ readonly isTTY?: boolean;
19
+ readonly rows?: number;
20
+ readonly columns?: number;
21
+ write(data: string): boolean;
22
+ }
23
+
24
+ /** A bar segment: visible text plus the ANSI color code to paint it with. */
25
+ interface ISegment {
26
+ readonly text: string;
27
+ readonly code: string;
28
+ }
29
+
30
+ /** Compact seconds/minutes for the elapsed segment. */
31
+ function humanSeconds(ms: number): string {
32
+ const total = Math.round(ms / 1000);
33
+
34
+ return total < 60
35
+ ? `${total}s`
36
+ : `${Math.floor(total / 60)}m${String(total % 60).padStart(2, "0")}s`;
37
+ }
38
+
39
+ /** The context-usage meter, colored green / amber / red by fill. */
40
+ function meterSegment(info: IStatusInfo): ISegment {
41
+ const pct =
42
+ info.contextWindow > 0
43
+ ? Math.round((info.contextTokens / info.contextWindow) * 100)
44
+ : 0;
45
+ const filled = Math.max(
46
+ 0,
47
+ Math.min(METER_CELLS, Math.round((pct / 100) * METER_CELLS))
48
+ );
49
+ const code = pct >= 90 ? STYLE.red : pct >= 70 ? STYLE.yellow : STYLE.green;
50
+
51
+ return {
52
+ text: `▕${"█".repeat(filled)}${"░".repeat(METER_CELLS - filled)}▏ ${pct}%`,
53
+ code,
54
+ };
55
+ }
56
+
57
+ /** Glyph + color for the run outcome. */
58
+ function statusSegment(status: string): ISegment {
59
+ if (status === "ready" || status === "done" || status === "responded") {
60
+ return { text: `✓ ${status}`, code: STYLE.green };
61
+ }
62
+
63
+ if (status === "stuck") {
64
+ return { text: `✗ ${status}`, code: STYLE.red };
65
+ }
66
+
67
+ return { text: `● ${status}`, code: STYLE.yellow };
68
+ }
69
+
70
+ /** The ordered segments shown on the bar (model, meter, tok/s, turns, status, scope). */
71
+ function barSegments(info: IStatusInfo): ISegment[] {
72
+ const segs: ISegment[] = [
73
+ { text: info.model, code: STYLE.brand },
74
+ meterSegment(info),
75
+ ];
76
+
77
+ if (info.tokensPerSecond !== undefined && info.tokensPerSecond > 0) {
78
+ segs.push({
79
+ text: `⚡ ${info.tokensPerSecond} tok/s`,
80
+ code: STYLE.brandLight,
81
+ });
82
+ }
83
+
84
+ if (info.turns > 0) {
85
+ segs.push({
86
+ text: `↻ ${info.turns}·${humanSeconds(info.elapsedMs)}`,
87
+ code: STYLE.dim,
88
+ });
89
+ }
90
+
91
+ segs.push(statusSegment(info.status));
92
+ segs.push({ text: info.scope, code: STYLE.dim });
93
+
94
+ return segs;
95
+ }
96
+
97
+ /** Join segments left-to-right within `columns`, dropping whole segments that
98
+ * don't fit (never cuts mid-escape). Returns the painted line. */
99
+ function assemble(segs: ISegment[], columns: number, color: boolean): string {
100
+ const sep = " ";
101
+ let painted = "";
102
+ let width = 1; // leading space
103
+
104
+ for (const seg of segs) {
105
+ const add = (painted.length > 0 ? sep.length : 0) + seg.text.length;
106
+
107
+ if (width + add > columns) {
108
+ break;
109
+ }
110
+
111
+ painted +=
112
+ (painted.length > 0 ? sep : "") + paint(seg.text, seg.code, color);
113
+ width += add;
114
+ }
115
+
116
+ return ` ${painted}`;
117
+ }
118
+
119
+ /** A dim rule with a leading corner tick, spanning the width. */
120
+ function topBorder(columns: number, color: boolean): string {
121
+ return paint(`╶${"─".repeat(Math.max(0, columns - 3))}`, STYLE.dim, color);
122
+ }
123
+
124
+ /**
125
+ * The escape sequence that paints the boxed bar on the reserved bottom TWO rows
126
+ * WITHOUT moving the user's cursor: save → border row → segments row → restore.
127
+ * Pure and width-aware, so it can be asserted in tests with no terminal.
128
+ */
129
+ export function buildBarFrame(
130
+ info: IStatusInfo,
131
+ columns: number,
132
+ rows: number,
133
+ color: boolean
134
+ ): string {
135
+ const borderRow = rows - 1;
136
+ const segs = assemble(barSegments(info), columns, color);
137
+
138
+ return (
139
+ `${ESC}7` + // save cursor
140
+ `${ESC}[${borderRow};1H${ESC}[2K${topBorder(columns, color)}` +
141
+ `${ESC}[${rows};1H${ESC}[2K${segs}` +
142
+ `${ESC}8` // restore cursor
143
+ );
144
+ }
145
+
146
+ /**
147
+ * An always-visible status bar pinned to the terminal's bottom via an ANSI scroll
148
+ * region (DECSTBM). Streaming output and readline input scroll in the region
149
+ * above it; the bar is repainted on state changes and resize. Inactive (no-op)
150
+ * whenever the output isn't a usable TTY — the CLI then prints the inline
151
+ * `renderStatus` line instead, so pipes and `--log` are unaffected.
152
+ */
153
+ export class StatusBar {
154
+ private installed = false;
155
+
156
+ constructor(
157
+ private readonly out: IStatusBarTerminal,
158
+ private readonly enabled = true,
159
+ private readonly color = true
160
+ ) {}
161
+
162
+ /** Whether the bar is currently pinned (false ⇒ caller uses the inline line). */
163
+ get active(): boolean {
164
+ return this.installed;
165
+ }
166
+
167
+ private canActivate(): boolean {
168
+ return (
169
+ this.enabled &&
170
+ this.out.isTTY === true &&
171
+ (this.out.rows ?? 0) >= MIN_ROWS
172
+ );
173
+ }
174
+
175
+ /** Reserve the bottom rows and draw the bar. No-op if it can't activate. */
176
+ install(info: IStatusInfo): void {
177
+ if (this.installed || !this.canActivate()) {
178
+ return;
179
+ }
180
+
181
+ const rows = this.out.rows ?? 0;
182
+
183
+ this.out.write("\n".repeat(RESERVED_ROWS)); // make room for the bar
184
+ this.out.write(`${ESC}[1;${rows - RESERVED_ROWS}r`); // confine scrolling
185
+ this.out.write(`${ESC}[${rows - RESERVED_ROWS};1H`); // cursor back in-region
186
+ this.installed = true;
187
+ this.update(info);
188
+ }
189
+
190
+ /** Repaint the bar with the latest state. */
191
+ update(info: IStatusInfo): void {
192
+ if (!this.installed) {
193
+ return;
194
+ }
195
+
196
+ this.out.write(
197
+ buildBarFrame(
198
+ info,
199
+ this.out.columns ?? 80,
200
+ this.out.rows ?? 0,
201
+ this.color
202
+ )
203
+ );
204
+ }
205
+
206
+ /** Re-apply the scroll region after a terminal resize, then repaint. */
207
+ resize(info: IStatusInfo): void {
208
+ if (!this.installed) {
209
+ return;
210
+ }
211
+
212
+ this.out.write(`${ESC}[1;${(this.out.rows ?? 0) - RESERVED_ROWS}r`);
213
+ this.update(info);
214
+ }
215
+
216
+ /** Reset the scroll region, clear the bar rows, and show the cursor. Idempotent. */
217
+ teardown(): void {
218
+ if (!this.installed) {
219
+ return;
220
+ }
221
+
222
+ const rows = this.out.rows ?? 0;
223
+
224
+ this.out.write(`${ESC}[r`); // reset scroll region to full screen
225
+ this.out.write(`${ESC}[${rows - 1};1H${ESC}[2K`); // clear border row
226
+ this.out.write(`${ESC}[${rows};1H${ESC}[2K`); // clear segments row
227
+ this.out.write(`${ESC}[?25h`); // ensure the cursor is visible
228
+ this.installed = false;
229
+ }
230
+ }