@agjs/tsforge 0.1.12 → 0.1.13

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.13",
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";
@@ -1220,22 +1222,44 @@ async function repl(args: ICliArgs): Promise<number> {
1220
1222
  return false;
1221
1223
  };
1222
1224
 
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.
1225
+ // Current state as the status surface sees it shared by the pinned bar and
1226
+ // the inline fallback so both show identical content.
1227
+ const statusInfo = (): IStatusInfo => ({
1228
+ model: modelInfo(provider.config).model,
1229
+ contextTokens: session.contextTokens,
1230
+ contextWindow,
1231
+ turns: lastTurns,
1232
+ elapsedMs: lastElapsedMs,
1233
+ status: lastStatus,
1234
+ scope: scopeLabel(session.scope) + (planMode ? " · PLAN" : ""),
1235
+ tokensPerSecond: session.metrics.lastTokensPerSecond,
1236
+ });
1237
+
1238
+ // Pinned bottom status bar when we're on a real terminal; otherwise the bar is
1239
+ // inactive and `prompt()` falls back to the inline status line (pipes, --log).
1240
+ const statusBar = new StatusBar(process.stdout, true, true);
1241
+
1242
+ process.stdout.on("resize", () => {
1243
+ statusBar.resize(statusInfo());
1244
+ });
1245
+
1246
+ // Restore the terminal even on an unexpected exit (teardown is idempotent).
1247
+ process.on("exit", () => {
1248
+ statusBar.teardown();
1249
+ });
1250
+
1251
+ // The prompt. With the bar pinned it repaints the bar and shows only the
1252
+ // input marker; otherwise it prints the inline status line above the marker.
1225
1253
  const prompt = (): void => {
1254
+ if (statusBar.active) {
1255
+ statusBar.update(statusInfo());
1256
+ process.stdout.write("\n› ");
1257
+
1258
+ return;
1259
+ }
1260
+
1226
1261
  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
- );
1262
+ process.stdout.write(renderStatus(statusInfo()));
1239
1263
  process.stdout.write("› ");
1240
1264
  };
1241
1265
 
@@ -1316,9 +1340,13 @@ async function repl(args: ICliArgs): Promise<number> {
1316
1340
 
1317
1341
  rl.on("close", () => {
1318
1342
  closed = true;
1343
+ statusBar.teardown();
1319
1344
  maybeFinish();
1320
1345
  });
1321
1346
 
1347
+ // Pin the bar before the first turn so it's visible while that turn streams.
1348
+ statusBar.install(statusInfo());
1349
+
1322
1350
  if (args.task.length > 0) {
1323
1351
  void runLine(args.task); // sent as the first message; prompts when done
1324
1352
  } else {
@@ -1326,6 +1354,8 @@ async function repl(args: ICliArgs): Promise<number> {
1326
1354
  }
1327
1355
  });
1328
1356
 
1357
+ statusBar.teardown(); // belt-and-suspenders: restore the terminal on loop exit
1358
+
1329
1359
  return 0;
1330
1360
  }
1331
1361
 
@@ -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
+ }