@agjs/tsforge 0.1.0

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.
Files changed (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,289 @@
1
+ import type { IRenderOptions, IStatusInfo } from "./render.types";
2
+ import type { ILoopEvent } from "../loop";
3
+ import type { IChatMessage } from "../inference";
4
+ import { STYLE, paint } from "./style";
5
+ import { box, GLYPH } from "./box";
6
+ import { renderMarkdown, highlightCode } from "./markdown";
7
+ import { StreamingMarkdown } from "./stream-markdown";
8
+
9
+ /** Split highlighted/plain text into the body-line array a box expects. */
10
+ function bodyLines(text: string): string[] {
11
+ return text.replace(/\n$/, "").split("\n");
12
+ }
13
+
14
+ /** A single glyph-prefixed line — the compact form for events with no body. */
15
+ function glyphLine(
16
+ glyph: string,
17
+ text: string,
18
+ accent: string,
19
+ color: boolean
20
+ ): string {
21
+ return `\n ${paint(`${glyph} ${text}`, `${accent}${STYLE.bold}`, color)}\n`;
22
+ }
23
+
24
+ /** Compact token count: 1234 → "1.2k", 14000 → "14k". */
25
+ function humanCount(n: number): string {
26
+ if (n < 1000) {
27
+ return String(n);
28
+ }
29
+
30
+ const k = n / 1000;
31
+
32
+ return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`;
33
+ }
34
+
35
+ /** Compact duration: 9000 → "9s", 84000 → "1m24s". */
36
+ function humanDuration(ms: number): string {
37
+ const total = Math.round(ms / 1000);
38
+
39
+ if (total < 60) {
40
+ return `${total}s`;
41
+ }
42
+
43
+ return `${Math.floor(total / 60)}m${String(total % 60).padStart(2, "0")}s`;
44
+ }
45
+
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.
50
+ */
51
+ export function renderStatus(
52
+ info: IStatusInfo,
53
+ opts: IRenderOptions = {}
54
+ ): string {
55
+ const color = opts.color ?? true;
56
+ const pct =
57
+ info.contextWindow > 0
58
+ ? Math.round((info.contextTokens / info.contextWindow) * 100)
59
+ : 0;
60
+ const bits = [
61
+ info.model,
62
+ `ctx ~${humanCount(info.contextTokens)}/${humanCount(info.contextWindow)} ${pct}%`,
63
+ ];
64
+
65
+ // Only show turn/elapsed once a turn has actually run (skip the "0 turns · 0s"
66
+ // noise on the very first prompt).
67
+ if (info.turns > 0) {
68
+ bits.push(
69
+ `${info.turns} turn${info.turns === 1 ? "" : "s"}`,
70
+ humanDuration(info.elapsedMs)
71
+ );
72
+ }
73
+
74
+ bits.push(info.status, info.scope);
75
+
76
+ return `${paint(` ⎯ ${bits.join(" · ")}`, STYLE.dim, color)}\n`;
77
+ }
78
+
79
+ /**
80
+ * Replay one stored conversation message — used to show the prior transcript on
81
+ * `--continue`. User turns are echoed at the prompt marker, assistant answers
82
+ * get markdown/code highlighting, tool calls collapse to a one-line summary, and
83
+ * the system prompt + raw tool output are omitted (context, not conversation).
84
+ */
85
+ export function renderMessage(
86
+ message: IChatMessage,
87
+ opts: IRenderOptions = {}
88
+ ): string {
89
+ const color = opts.color ?? true;
90
+
91
+ if (message.role === "system" || message.role === "tool") {
92
+ return "";
93
+ }
94
+
95
+ if (message.role === "user") {
96
+ return `\n${paint("›", STYLE.brand + STYLE.bold, color)} ${message.content}\n`;
97
+ }
98
+
99
+ const parts: string[] = [];
100
+
101
+ if (message.content.length > 0) {
102
+ parts.push(renderMarkdown(message.content, color));
103
+ }
104
+
105
+ if (message.toolCalls !== undefined && message.toolCalls.length > 0) {
106
+ const names = message.toolCalls.map((c) => c.name).join(", ");
107
+
108
+ parts.push(paint(` · used ${names}`, STYLE.dim, color));
109
+ }
110
+
111
+ return parts.length > 0 ? `\n${parts.join("\n")}\n` : "";
112
+ }
113
+
114
+ function highlightTs(code: string, color: boolean): string {
115
+ return highlightCode(code, "typescript", color);
116
+ }
117
+
118
+ function diff(oldString: string, newString: string, color: boolean): string {
119
+ const minus = oldString
120
+ .split("\n")
121
+ .map((l) => paint(`- ${l}`, STYLE.red, color))
122
+ .join("\n");
123
+ const plus = newString
124
+ .split("\n")
125
+ .map((l) => paint(`+ ${l}`, STYLE.green, color))
126
+ .join("\n");
127
+
128
+ return `${minus}\n${plus}`;
129
+ }
130
+
131
+ /**
132
+ * Format a loop event for a terminal (ANSI) or a plain log (`color: false`).
133
+ * The library emits structured events; this renderer is the only place that
134
+ * knows about colors/layout — a web UI could render the same events differently.
135
+ */
136
+ /** Latch so a run of `reasoning` tokens collapses to ONE "thinking…" line
137
+ * instead of streaming the model's full chain-of-thought. Reset by any other
138
+ * event (a tool marker, gate output, a message), so the next thinking burst
139
+ * re-announces. The raw reasoning is still written verbatim to the --log. */
140
+ let thinkingShown = false;
141
+
142
+ /** Live answer stream — content tokens render incrementally through this; the
143
+ * settled `message` event then skips its duplicate full render (sawContent). */
144
+ const stream = new StreamingMarkdown();
145
+
146
+ /** Render a streamed token. The answer (channel `content`) streams live through
147
+ * the incremental markdown renderer; the chain-of-thought (`reasoning`)
148
+ * collapses to a compact indicator (on a TTY the CLI spinner owns it); tool
149
+ * markers (✎) and gate output print normally. */
150
+ function renderToken(event: ILoopEvent, color: boolean): string {
151
+ if (event.channel === "reasoning") {
152
+ // On a live TTY the CLI's animated spinner is the thinking indicator; the
153
+ // static one-time line is for piped color output and plain logs.
154
+ if (color && process.stdout.isTTY) {
155
+ return "";
156
+ }
157
+
158
+ if (thinkingShown) {
159
+ return "";
160
+ }
161
+
162
+ thinkingShown = true;
163
+
164
+ return `\n ${paint("⋯ thinking", STYLE.dim, color)}`;
165
+ }
166
+
167
+ if (event.channel === "content") {
168
+ // Plain/log mode stays quiet here — the consolidated `message` event is
169
+ // the log's record, so agent.log keeps its exact pre-streaming shape.
170
+ return color ? stream.push(event.message, true) : "";
171
+ }
172
+
173
+ return paint(event.message, STYLE.dim, color);
174
+ }
175
+
176
+ /** A shell-command event as a box — exit status drives the accent + glyph (a
177
+ * non-zero exit goes red ✗); no output → a one-liner. */
178
+ function renderRun(event: ILoopEvent, color: boolean): string {
179
+ const ok = event.exitCode === undefined || event.exitCode === 0;
180
+ const title =
181
+ event.exitCode === undefined
182
+ ? event.message
183
+ : `${event.message} (exit ${event.exitCode})`;
184
+ const accent = ok ? STYLE.yellow : STYLE.red;
185
+ const glyph = ok ? GLYPH.run : GLYPH.fail;
186
+
187
+ if (event.output === undefined || event.output.length === 0) {
188
+ return glyphLine(glyph, title, accent, color);
189
+ }
190
+
191
+ return `\n${box(title, bodyLines(event.output), { glyph, accent, color })}\n`;
192
+ }
193
+
194
+ export function renderEvent(
195
+ event: ILoopEvent,
196
+ opts: IRenderOptions = {}
197
+ ): string {
198
+ const color = opts.color ?? true;
199
+
200
+ // Any event that is NOT a reasoning token ends the current thinking burst.
201
+ if (event.kind !== "token" || event.channel !== "reasoning") {
202
+ thinkingShown = false;
203
+ }
204
+
205
+ // Any event that is NOT a content token first flushes the live answer stream
206
+ // (the closing table/fence, or a partial line on abort) so nothing is lost.
207
+ const isContentToken = event.kind === "token" && event.channel === "content";
208
+ const pending = color && !isContentToken ? stream.flush(true) : "";
209
+
210
+ return pending + renderEventBody(event, color);
211
+ }
212
+
213
+ function renderEventBody(event: ILoopEvent, color: boolean): string {
214
+ switch (event.kind) {
215
+ case "token":
216
+ return renderToken(event, color);
217
+
218
+ case "message":
219
+ // The model's actual answer. When it already streamed live (content
220
+ // tokens), emit just a closing separator — the text is on screen; the
221
+ // full render here would print it twice. Without streamed content
222
+ // (non-streaming provider, replayed events, plain logs) render in full.
223
+ if (color && stream.sawContent) {
224
+ stream.reset();
225
+
226
+ return "\n";
227
+ }
228
+
229
+ return event.message.length > 0
230
+ ? `\n${renderMarkdown(event.message, color)}\n`
231
+ : "";
232
+
233
+ case "start":
234
+ case "fix":
235
+ return `\n${paint(event.message, STYLE.dim, color)}\n`;
236
+
237
+ case "cycle":
238
+ // On screen the turn divider is just noise (the status line carries the
239
+ // count); keep a minimal boundary only in the plain log for `tail -f`.
240
+ return color
241
+ ? ""
242
+ : `\n── ${event.message.replace(/:?\s*asking model\s*$/i, "")} ──\n`;
243
+
244
+ case "create":
245
+ return event.content === undefined
246
+ ? glyphLine(GLYPH.create, event.message, STYLE.green, color)
247
+ : `\n${box(event.message, bodyLines(highlightTs(event.content, color)), { glyph: GLYPH.create, accent: STYLE.green, color })}\n`;
248
+
249
+ case "edit": {
250
+ if (event.oldString === undefined || event.newString === undefined) {
251
+ return glyphLine(GLYPH.edit, event.message, STYLE.brand, color);
252
+ }
253
+
254
+ const body = bodyLines(diff(event.oldString, event.newString, color));
255
+
256
+ return `\n${box(event.message, body, { glyph: GLYPH.edit, accent: STYLE.brand, color })}\n`;
257
+ }
258
+
259
+ case "red":
260
+ case "stuck":
261
+ return `\n${paint(`${GLYPH.fail} ${event.message}`, STYLE.red + STYLE.bold, color)}\n`;
262
+
263
+ case "validated":
264
+ return event.passed === true
265
+ ? `${paint(` ${GLYPH.done} ${event.message}`, STYLE.green, color)}\n`
266
+ : `${paint(` ${GLYPH.bullet} ${event.message}`, STYLE.yellow, color)}\n`;
267
+
268
+ case "done":
269
+ return `\n${paint(`${GLYPH.done} ${event.message}`, STYLE.green + STYLE.bold, color)}\n`;
270
+
271
+ case "run":
272
+ return renderRun(event, color);
273
+
274
+ case "usage":
275
+ // Logged for the metrics analyzer, but not shown — the status line already
276
+ // surfaces context usage on screen.
277
+ return "";
278
+
279
+ case "tool":
280
+ return ` ${paint(event.message, STYLE.dim, color)}\n`;
281
+
282
+ case "timing":
283
+ // Noise on screen (the status line shows turns + elapsed); log only.
284
+ return color ? "" : ` ${event.message}\n`;
285
+
286
+ default:
287
+ return `\n${event.message}\n`;
288
+ }
289
+ }
@@ -0,0 +1,113 @@
1
+ import { STYLE, paint } from "./style";
2
+
3
+ /**
4
+ * Welcome banner for the interactive CLI — solid forge emblem, wordmark,
5
+ * model/endpoint. Centering uses visible (un-painted) length.
6
+ */
7
+ export interface IBannerInfo {
8
+ model: string;
9
+ endpoint: string;
10
+ color?: boolean;
11
+ }
12
+
13
+ /** Chars between the two vertical borders. */
14
+ const INNER = 58;
15
+
16
+ interface ISegment {
17
+ text: string;
18
+ code?: string;
19
+ }
20
+
21
+ interface ILine {
22
+ text?: string;
23
+ code?: string;
24
+ segments?: readonly ISegment[];
25
+ }
26
+
27
+ const BLANK: ILine = { text: "" };
28
+
29
+ /** Compact solid anvil — filled blocks, horn + face + base (~9 cols). */
30
+ const EMBLEM: readonly ILine[] = [
31
+ { text: "· ✦ ✦ ·", code: STYLE.brandLight },
32
+ { text: "▄▀▀▀▄", code: STYLE.brandLight + STYLE.bold },
33
+ { text: "███████", code: STYLE.brand + STYLE.bold },
34
+ { text: "▀▀▀▀▀▀▀▀▀", code: STYLE.brandDark + STYLE.bold },
35
+ ];
36
+
37
+ /** Split wordmark under the emblem. */
38
+ const WORDMARK: ILine = {
39
+ segments: [
40
+ { text: "ts", code: STYLE.brandLight + STYLE.bold },
41
+ { text: "forge", code: STYLE.brand + STYLE.bold },
42
+ ],
43
+ };
44
+
45
+ export function welcomeBanner(info: IBannerInfo): string {
46
+ const color = info.color ?? true;
47
+
48
+ const lines: ILine[] = [
49
+ BLANK,
50
+ ...EMBLEM,
51
+ BLANK,
52
+ WORDMARK,
53
+ BLANK,
54
+ { text: "strict TypeScript, gate-driven", code: STYLE.dim },
55
+ BLANK,
56
+ { text: info.model, code: STYLE.brand + STYLE.bold },
57
+ { text: info.endpoint, code: STYLE.dim },
58
+ BLANK,
59
+ ];
60
+
61
+ const body = lines.map((line) => boxLine(line, color)).join("\n");
62
+
63
+ return `${topBorder(color)}\n${body}\n${bottomBorder()}\n`;
64
+ }
65
+
66
+ function topBorder(color: boolean): string {
67
+ const label = "─── tsforge ";
68
+ const fill = "─".repeat(Math.max(0, INNER - label.length));
69
+ const frame = `╭${label}${fill}╮`;
70
+
71
+ return color
72
+ ? paint("╭", STYLE.dim, color) +
73
+ paint(label, STYLE.brandDark, color) +
74
+ paint(fill + "╮", STYLE.dim, color)
75
+ : frame;
76
+ }
77
+
78
+ function bottomBorder(): string {
79
+ return `╰${"─".repeat(INNER)}╯`;
80
+ }
81
+
82
+ function visibleText(line: ILine): string {
83
+ if (line.segments !== undefined) {
84
+ return line.segments.map((s) => s.text).join("");
85
+ }
86
+
87
+ return line.text ?? "";
88
+ }
89
+
90
+ function renderContent(line: ILine, color: boolean): string {
91
+ if (line.segments !== undefined) {
92
+ return line.segments
93
+ .map((s) =>
94
+ s.code === undefined ? s.text : paint(s.text, s.code, color)
95
+ )
96
+ .join("");
97
+ }
98
+
99
+ const text = line.text ?? "";
100
+
101
+ return line.code === undefined ? text : paint(text, line.code, color);
102
+ }
103
+
104
+ /** Center `line` within INNER and frame it with the vertical borders. */
105
+ function boxLine(line: ILine, color: boolean): string {
106
+ const visible = visibleText(line);
107
+ const pad = Math.max(0, INNER - visible.length);
108
+ const left = Math.floor(pad / 2);
109
+ const right = pad - left;
110
+ const content = renderContent(line, color);
111
+
112
+ return `│${" ".repeat(left)}${content}${" ".repeat(right)}│`;
113
+ }
@@ -0,0 +1,134 @@
1
+ import { STYLE, paint } from "./style";
2
+
3
+ /**
4
+ * Terminal drawing primitives — title-tabbed boxes and box-drawn tables, the look
5
+ * a modern coding CLI uses to make tool output legible. Pure string→string (no
6
+ * cursor control, no streaming, no readline interaction), so they render discrete
7
+ * events ONLY and can never disturb input. `color: false` (logs / non-TTY)
8
+ * degrades to plain indented text.
9
+ */
10
+
11
+ /** One source of truth for status → glyph, so every event renders consistently. */
12
+ export const GLYPH = {
13
+ done: "✓",
14
+ fail: "✗",
15
+ warn: "⚠",
16
+ info: "●",
17
+ run: "→",
18
+ create: "✚",
19
+ edit: "✎",
20
+ bullet: "•",
21
+ } as const;
22
+
23
+ const DEFAULT_WIDTH = 80;
24
+ const MIN_WIDTH = 48;
25
+ const MAX_WIDTH = 100;
26
+
27
+ /** Terminal width, clamped to a sane band — a stable default off a TTY. */
28
+ function termWidth(): number {
29
+ const cols = process.stdout.columns;
30
+
31
+ return Number.isFinite(cols)
32
+ ? Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, cols))
33
+ : DEFAULT_WIDTH;
34
+ }
35
+
36
+ export interface IBoxOptions {
37
+ glyph?: string;
38
+ accent?: string;
39
+ color?: boolean;
40
+ width?: number;
41
+ }
42
+
43
+ /**
44
+ * A title-tabbed block: a top rule that opens with `┌─ <glyph> <title> ` and runs
45
+ * to the terminal width, each body line on a `│ ` gutter, closed by a `└` rule.
46
+ * Body lines may already contain ANSI — the right edge is never padded, so no
47
+ * fragile visible-width math is needed.
48
+ */
49
+ export function box(
50
+ title: string,
51
+ bodyLines: readonly string[],
52
+ opts: IBoxOptions = {}
53
+ ): string {
54
+ const {
55
+ glyph = "",
56
+ accent = STYLE.brand,
57
+ color = true,
58
+ width = termWidth(),
59
+ } = opts;
60
+ const head = glyph.length > 0 ? `${glyph} ${title}` : title;
61
+
62
+ if (!color) {
63
+ const body = bodyLines.map((l) => ` ${l}`).join("\n");
64
+
65
+ return bodyLines.length > 0 ? ` ${head}\n${body}` : ` ${head}`;
66
+ }
67
+
68
+ const opener = "┌─ ";
69
+ const rule = "─".repeat(Math.max(0, width - opener.length - head.length - 1));
70
+ const top = `${paint(opener, STYLE.dim, color)}${paint(head, `${accent}${STYLE.bold}`, color)} ${paint(rule, STYLE.dim, color)}`;
71
+ const bar = paint("│", STYLE.dim, color);
72
+ const bottom = paint(
73
+ `└${"─".repeat(Math.max(0, width - 1))}`,
74
+ STYLE.dim,
75
+ color
76
+ );
77
+
78
+ if (bodyLines.length === 0) {
79
+ return `${top}\n${bottom}`;
80
+ }
81
+
82
+ const body = bodyLines.map((l) => `${bar} ${l}`).join("\n");
83
+
84
+ return `${top}\n${body}\n${bottom}`;
85
+ }
86
+
87
+ /**
88
+ * Render rows as a real box-drawn table — `rows[0]` is the header. Columns auto-
89
+ * size to their widest cell; the header is accented. The model answers with GFM
90
+ * markdown tables constantly, which print as raw `|` soup otherwise.
91
+ */
92
+ export function table(
93
+ rows: readonly (readonly string[])[],
94
+ color = true
95
+ ): string {
96
+ if (rows.length === 0) {
97
+ return "";
98
+ }
99
+
100
+ const cols = Math.max(...rows.map((r) => r.length));
101
+ const widths = Array.from({ length: cols }, (_, c) =>
102
+ Math.max(1, ...rows.map((r) => (r[c] ?? "").length))
103
+ );
104
+ const bar = paint("│", STYLE.dim, color);
105
+
106
+ const rule = (left: string, mid: string, right: string): string =>
107
+ paint(
108
+ `${left}${widths.map((w) => "─".repeat(w + 2)).join(mid)}${right}`,
109
+ STYLE.dim,
110
+ color
111
+ );
112
+
113
+ const renderRow = (cells: readonly string[], header: boolean): string => {
114
+ const inner = widths
115
+ .map((w, c) => {
116
+ const cell = (cells[c] ?? "").padEnd(w);
117
+
118
+ return ` ${header ? paint(cell, `${STYLE.brand}${STYLE.bold}`, color) : cell} `;
119
+ })
120
+ .join(bar);
121
+
122
+ return `${bar}${inner}${bar}`;
123
+ };
124
+
125
+ const [header, ...body] = rows;
126
+
127
+ return [
128
+ rule("┌", "┬", "┐"),
129
+ renderRow(header ?? [], true),
130
+ rule("├", "┼", "┤"),
131
+ ...body.map((r) => renderRow(r, false)),
132
+ rule("└", "┴", "┘"),
133
+ ].join("\n");
134
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./render.types";
2
+ export { renderEvent, renderMessage, renderStatus } from "./ansi";
3
+ export { welcomeBanner, type IBannerInfo } from "./banner";
4
+ export { box, table, GLYPH } from "./box";
5
+ export { renderMarkdown, formatTables, highlightCode } from "./markdown";
6
+ export { StreamingMarkdown } from "./stream-markdown";
7
+ export { STYLE, RESET, paint } from "./style";
@@ -0,0 +1,123 @@
1
+ import { highlight } from "cli-highlight";
2
+ import { STYLE, paint } from "./style";
3
+ import { table } from "./box";
4
+
5
+ /**
6
+ * Markdown → terminal formatting, shared by the settled-message renderer
7
+ * (ansi.ts) and the live streaming renderer (stream-markdown.ts) so a streamed
8
+ * answer and a re-rendered one look identical.
9
+ */
10
+
11
+ /**
12
+ * Render an assistant message: fenced ```code``` blocks syntax-highlighted, and
13
+ * GitHub-flavored markdown TABLES drawn as real box tables (the model answers with
14
+ * `| a | b |` tables constantly — raw they're unreadable pipe soup). Other prose
15
+ * passes through.
16
+ */
17
+ export function renderMarkdown(text: string, color: boolean): string {
18
+ return text
19
+ .split(/(```[\s\S]*?```)/g)
20
+ .map((part) => {
21
+ const fence = /^```([\w-]*)\n?([\s\S]*?)\n?```$/.exec(part);
22
+
23
+ if (fence === null) {
24
+ return formatTables(part, color);
25
+ }
26
+
27
+ const lang =
28
+ fence[1] !== undefined && fence[1].length > 0 ? fence[1] : "typescript";
29
+
30
+ return highlightCode(fence[2] ?? "", lang, color);
31
+ })
32
+ .join("");
33
+ }
34
+
35
+ /** A markdown table separator row, e.g. `|----|:--:|---|`. */
36
+ function isTableSeparator(line: string | undefined): boolean {
37
+ return (
38
+ line !== undefined &&
39
+ line.includes("|") &&
40
+ line.includes("-") &&
41
+ /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line)
42
+ );
43
+ }
44
+
45
+ /** Split a `| a | b |` row into trimmed cells (tolerates missing edge pipes). */
46
+ function tableCells(line: string): string[] {
47
+ return line
48
+ .trim()
49
+ .replace(/^\|/, "")
50
+ .replace(/\|$/, "")
51
+ .split("|")
52
+ .map((c) => c.trim());
53
+ }
54
+
55
+ /** Replace each GFM table block in `text` with a box-drawn table; leave the rest. */
56
+ export function formatTables(text: string, color: boolean): string {
57
+ const lines = text.split("\n");
58
+ const out: string[] = [];
59
+
60
+ for (let i = 0; i < lines.length; ) {
61
+ const header = lines[i];
62
+
63
+ if (
64
+ header !== undefined &&
65
+ header.includes("|") &&
66
+ isTableSeparator(lines[i + 1])
67
+ ) {
68
+ const rows: string[][] = [tableCells(header)];
69
+ let j = i + 2;
70
+
71
+ while (j < lines.length && (lines[j]?.includes("|") ?? false)) {
72
+ rows.push(tableCells(lines[j] ?? ""));
73
+ j += 1;
74
+ }
75
+
76
+ out.push(table(rows, color));
77
+ i = j;
78
+ } else {
79
+ out.push(header ?? "");
80
+ i += 1;
81
+ }
82
+ }
83
+
84
+ return out.join("\n");
85
+ }
86
+
87
+ export function highlightCode(
88
+ code: string,
89
+ lang: string,
90
+ color: boolean
91
+ ): string {
92
+ if (!color) {
93
+ return code;
94
+ }
95
+
96
+ try {
97
+ return highlight(code, { language: lang, ignoreIllegals: true });
98
+ } catch {
99
+ return code;
100
+ }
101
+ }
102
+
103
+ /** Cheap inline styling for one streamed prose line — `#` headings and
104
+ * `**bold**` brighten, `code` spans use brand light. No-op without color. */
105
+ export function styleInline(line: string, color: boolean): string {
106
+ if (!color) {
107
+ return line;
108
+ }
109
+
110
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
111
+
112
+ if (heading !== null) {
113
+ return paint(heading[1] ?? "", STYLE.bold, color);
114
+ }
115
+
116
+ return line
117
+ .replace(/\*\*([^*]+)\*\*/g, (_m: string, t: string) =>
118
+ paint(t, STYLE.bold, color)
119
+ )
120
+ .replace(/`([^`]+)`/g, (_m: string, t: string) =>
121
+ paint(t, STYLE.brandLight, color)
122
+ );
123
+ }
@@ -0,0 +1,21 @@
1
+ export interface IRenderOptions {
2
+ /** Emit ANSI color codes (terminal) vs plain text (log files). Default true. */
3
+ color?: boolean;
4
+ }
5
+
6
+ /** A compact post-turn status line — the "where am I" summary modern CLIs show. */
7
+ export interface IStatusInfo {
8
+ model: string;
9
+ /** Estimated tokens of conversation context currently held. */
10
+ contextTokens: number;
11
+ /** The model's context window (for the used/total ratio). */
12
+ contextWindow: number;
13
+ /** Turns the last send took. */
14
+ turns: number;
15
+ /** Wall-clock of the last send, in ms. */
16
+ elapsedMs: number;
17
+ /** Outcome of the last send (responded / done / stuck / interrupted). */
18
+ status: string;
19
+ /** Editable scope label. */
20
+ scope: string;
21
+ }