@studio-foundation/cli 0.3.0-beta.1

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 (277) hide show
  1. package/ARCHITECTURE.md +40 -0
  2. package/LICENSE +663 -0
  3. package/README.md +165 -0
  4. package/dist/commands/api.d.ts +16 -0
  5. package/dist/commands/api.d.ts.map +1 -0
  6. package/dist/commands/api.js +118 -0
  7. package/dist/commands/api.js.map +1 -0
  8. package/dist/commands/config.d.ts +32 -0
  9. package/dist/commands/config.d.ts.map +1 -0
  10. package/dist/commands/config.js +390 -0
  11. package/dist/commands/config.js.map +1 -0
  12. package/dist/commands/init.d.ts +93 -0
  13. package/dist/commands/init.d.ts.map +1 -0
  14. package/dist/commands/init.js +728 -0
  15. package/dist/commands/init.js.map +1 -0
  16. package/dist/commands/install.d.ts +2 -0
  17. package/dist/commands/install.d.ts.map +1 -0
  18. package/dist/commands/install.js +22 -0
  19. package/dist/commands/install.js.map +1 -0
  20. package/dist/commands/integrations.d.ts +14 -0
  21. package/dist/commands/integrations.d.ts.map +1 -0
  22. package/dist/commands/integrations.js +282 -0
  23. package/dist/commands/integrations.js.map +1 -0
  24. package/dist/commands/list.d.ts +9 -0
  25. package/dist/commands/list.d.ts.map +1 -0
  26. package/dist/commands/list.js +161 -0
  27. package/dist/commands/list.js.map +1 -0
  28. package/dist/commands/logs.d.ts +7 -0
  29. package/dist/commands/logs.d.ts.map +1 -0
  30. package/dist/commands/logs.js +98 -0
  31. package/dist/commands/logs.js.map +1 -0
  32. package/dist/commands/ollama.d.ts +6 -0
  33. package/dist/commands/ollama.d.ts.map +1 -0
  34. package/dist/commands/ollama.js +161 -0
  35. package/dist/commands/ollama.js.map +1 -0
  36. package/dist/commands/project.d.ts +38 -0
  37. package/dist/commands/project.d.ts.map +1 -0
  38. package/dist/commands/project.js +137 -0
  39. package/dist/commands/project.js.map +1 -0
  40. package/dist/commands/registry/audit.d.ts +13 -0
  41. package/dist/commands/registry/audit.d.ts.map +1 -0
  42. package/dist/commands/registry/audit.js +65 -0
  43. package/dist/commands/registry/audit.js.map +1 -0
  44. package/dist/commands/registry/index.d.ts +3 -0
  45. package/dist/commands/registry/index.d.ts.map +1 -0
  46. package/dist/commands/registry/index.js +53 -0
  47. package/dist/commands/registry/index.js.map +1 -0
  48. package/dist/commands/registry/install.d.ts +17 -0
  49. package/dist/commands/registry/install.d.ts.map +1 -0
  50. package/dist/commands/registry/install.js +153 -0
  51. package/dist/commands/registry/install.js.map +1 -0
  52. package/dist/commands/registry/publish.d.ts +8 -0
  53. package/dist/commands/registry/publish.d.ts.map +1 -0
  54. package/dist/commands/registry/publish.js +94 -0
  55. package/dist/commands/registry/publish.js.map +1 -0
  56. package/dist/commands/registry/remove.d.ts +8 -0
  57. package/dist/commands/registry/remove.d.ts.map +1 -0
  58. package/dist/commands/registry/remove.js +82 -0
  59. package/dist/commands/registry/remove.js.map +1 -0
  60. package/dist/commands/registry/search.d.ts +9 -0
  61. package/dist/commands/registry/search.d.ts.map +1 -0
  62. package/dist/commands/registry/search.js +68 -0
  63. package/dist/commands/registry/search.js.map +1 -0
  64. package/dist/commands/registry/sync.d.ts +8 -0
  65. package/dist/commands/registry/sync.d.ts.map +1 -0
  66. package/dist/commands/registry/sync.js +19 -0
  67. package/dist/commands/registry/sync.js.map +1 -0
  68. package/dist/commands/registry/update.d.ts +15 -0
  69. package/dist/commands/registry/update.d.ts.map +1 -0
  70. package/dist/commands/registry/update.js +48 -0
  71. package/dist/commands/registry/update.js.map +1 -0
  72. package/dist/commands/replay.d.ts +36 -0
  73. package/dist/commands/replay.d.ts.map +1 -0
  74. package/dist/commands/replay.js +453 -0
  75. package/dist/commands/replay.js.map +1 -0
  76. package/dist/commands/run.d.ts +18 -0
  77. package/dist/commands/run.d.ts.map +1 -0
  78. package/dist/commands/run.js +406 -0
  79. package/dist/commands/run.js.map +1 -0
  80. package/dist/commands/status.d.ts +6 -0
  81. package/dist/commands/status.d.ts.map +1 -0
  82. package/dist/commands/status.js +144 -0
  83. package/dist/commands/status.js.map +1 -0
  84. package/dist/commands/template/index.d.ts +2 -0
  85. package/dist/commands/template/index.d.ts.map +1 -0
  86. package/dist/commands/template/index.js +57 -0
  87. package/dist/commands/template/index.js.map +1 -0
  88. package/dist/commands/template/validate.d.ts +8 -0
  89. package/dist/commands/template/validate.d.ts.map +1 -0
  90. package/dist/commands/template/validate.js +135 -0
  91. package/dist/commands/template/validate.js.map +1 -0
  92. package/dist/commands/templates.d.ts +14 -0
  93. package/dist/commands/templates.d.ts.map +1 -0
  94. package/dist/commands/templates.js +86 -0
  95. package/dist/commands/templates.js.map +1 -0
  96. package/dist/commands/tools.d.ts +12 -0
  97. package/dist/commands/tools.d.ts.map +1 -0
  98. package/dist/commands/tools.js +200 -0
  99. package/dist/commands/tools.js.map +1 -0
  100. package/dist/commands/users.d.ts +4 -0
  101. package/dist/commands/users.d.ts.map +1 -0
  102. package/dist/commands/users.js +123 -0
  103. package/dist/commands/users.js.map +1 -0
  104. package/dist/commands/validate.d.ts +2 -0
  105. package/dist/commands/validate.d.ts.map +1 -0
  106. package/dist/commands/validate.js +39 -0
  107. package/dist/commands/validate.js.map +1 -0
  108. package/dist/config.d.ts +36 -0
  109. package/dist/config.d.ts.map +1 -0
  110. package/dist/config.js +64 -0
  111. package/dist/config.js.map +1 -0
  112. package/dist/index.d.ts +3 -0
  113. package/dist/index.d.ts.map +1 -0
  114. package/dist/index.js +178 -0
  115. package/dist/index.js.map +1 -0
  116. package/dist/models-cache.d.ts +13 -0
  117. package/dist/models-cache.d.ts.map +1 -0
  118. package/dist/models-cache.js +106 -0
  119. package/dist/models-cache.js.map +1 -0
  120. package/dist/output/file-changes.d.ts +15 -0
  121. package/dist/output/file-changes.d.ts.map +1 -0
  122. package/dist/output/file-changes.js +77 -0
  123. package/dist/output/file-changes.js.map +1 -0
  124. package/dist/output/formatter.d.ts +6 -0
  125. package/dist/output/formatter.d.ts.map +1 -0
  126. package/dist/output/formatter.js +70 -0
  127. package/dist/output/formatter.js.map +1 -0
  128. package/dist/output/formatters.d.ts +42 -0
  129. package/dist/output/formatters.d.ts.map +1 -0
  130. package/dist/output/formatters.js +280 -0
  131. package/dist/output/formatters.js.map +1 -0
  132. package/dist/output/logger.d.ts +3 -0
  133. package/dist/output/logger.d.ts.map +1 -0
  134. package/dist/output/logger.js +15 -0
  135. package/dist/output/logger.js.map +1 -0
  136. package/dist/output/parallel-progress.d.ts +33 -0
  137. package/dist/output/parallel-progress.d.ts.map +1 -0
  138. package/dist/output/parallel-progress.js +85 -0
  139. package/dist/output/parallel-progress.js.map +1 -0
  140. package/dist/output/progress.d.ts +32 -0
  141. package/dist/output/progress.d.ts.map +1 -0
  142. package/dist/output/progress.js +393 -0
  143. package/dist/output/progress.js.map +1 -0
  144. package/dist/provider-validator.d.ts +15 -0
  145. package/dist/provider-validator.d.ts.map +1 -0
  146. package/dist/provider-validator.js +85 -0
  147. package/dist/provider-validator.js.map +1 -0
  148. package/dist/registry/cache.d.ts +10 -0
  149. package/dist/registry/cache.d.ts.map +1 -0
  150. package/dist/registry/cache.js +36 -0
  151. package/dist/registry/cache.js.map +1 -0
  152. package/dist/registry/client.d.ts +19 -0
  153. package/dist/registry/client.d.ts.map +1 -0
  154. package/dist/registry/client.js +63 -0
  155. package/dist/registry/client.js.map +1 -0
  156. package/dist/registry/lockfile.d.ts +17 -0
  157. package/dist/registry/lockfile.d.ts.map +1 -0
  158. package/dist/registry/lockfile.js +61 -0
  159. package/dist/registry/lockfile.js.map +1 -0
  160. package/dist/registry/resolver.d.ts +13 -0
  161. package/dist/registry/resolver.d.ts.map +1 -0
  162. package/dist/registry/resolver.js +79 -0
  163. package/dist/registry/resolver.js.map +1 -0
  164. package/dist/registry/types.d.ts +59 -0
  165. package/dist/registry/types.d.ts.map +1 -0
  166. package/dist/registry/types.js +14 -0
  167. package/dist/registry/types.js.map +1 -0
  168. package/dist/run-logger.d.ts +8 -0
  169. package/dist/run-logger.d.ts.map +1 -0
  170. package/dist/run-logger.js +53 -0
  171. package/dist/run-logger.js.map +1 -0
  172. package/dist/run-store-factory.d.ts +9 -0
  173. package/dist/run-store-factory.d.ts.map +1 -0
  174. package/dist/run-store-factory.js +26 -0
  175. package/dist/run-store-factory.js.map +1 -0
  176. package/dist/studio-dir.d.ts +7 -0
  177. package/dist/studio-dir.d.ts.map +1 -0
  178. package/dist/studio-dir.js +27 -0
  179. package/dist/studio-dir.js.map +1 -0
  180. package/dist/utils/input-wizard.d.ts +4 -0
  181. package/dist/utils/input-wizard.d.ts.map +1 -0
  182. package/dist/utils/input-wizard.js +56 -0
  183. package/dist/utils/input-wizard.js.map +1 -0
  184. package/dist/utils/placeholders.d.ts +6 -0
  185. package/dist/utils/placeholders.d.ts.map +1 -0
  186. package/dist/utils/placeholders.js +12 -0
  187. package/dist/utils/placeholders.js.map +1 -0
  188. package/package.json +43 -0
  189. package/src/commands/api.ts +126 -0
  190. package/src/commands/config.ts +433 -0
  191. package/src/commands/init.ts +879 -0
  192. package/src/commands/install.ts +23 -0
  193. package/src/commands/integrations.ts +332 -0
  194. package/src/commands/list.ts +197 -0
  195. package/src/commands/logs.ts +119 -0
  196. package/src/commands/ollama.ts +168 -0
  197. package/src/commands/project.ts +167 -0
  198. package/src/commands/registry/audit.ts +87 -0
  199. package/src/commands/registry/index.ts +63 -0
  200. package/src/commands/registry/install.ts +222 -0
  201. package/src/commands/registry/publish.ts +112 -0
  202. package/src/commands/registry/remove.ts +89 -0
  203. package/src/commands/registry/search.ts +93 -0
  204. package/src/commands/registry/sync.ts +24 -0
  205. package/src/commands/registry/update.ts +63 -0
  206. package/src/commands/replay.ts +559 -0
  207. package/src/commands/run.ts +454 -0
  208. package/src/commands/status.ts +163 -0
  209. package/src/commands/template/index.ts +59 -0
  210. package/src/commands/template/validate.ts +175 -0
  211. package/src/commands/templates.ts +99 -0
  212. package/src/commands/tools.ts +227 -0
  213. package/src/commands/users.ts +127 -0
  214. package/src/commands/validate.ts +46 -0
  215. package/src/config.ts +101 -0
  216. package/src/index.ts +201 -0
  217. package/src/models-cache.ts +127 -0
  218. package/src/output/file-changes.ts +97 -0
  219. package/src/output/formatter.ts +85 -0
  220. package/src/output/formatters.ts +303 -0
  221. package/src/output/logger.ts +18 -0
  222. package/src/output/parallel-progress.ts +103 -0
  223. package/src/output/progress.ts +414 -0
  224. package/src/provider-validator.ts +109 -0
  225. package/src/registry/cache.ts +46 -0
  226. package/src/registry/client.ts +84 -0
  227. package/src/registry/lockfile.ts +68 -0
  228. package/src/registry/resolver.ts +113 -0
  229. package/src/registry/types.ts +66 -0
  230. package/src/run-logger.ts +66 -0
  231. package/src/run-store-factory.ts +30 -0
  232. package/src/studio-dir.ts +28 -0
  233. package/src/utils/input-wizard.ts +73 -0
  234. package/src/utils/placeholders.ts +10 -0
  235. package/templates/.studiorc.yaml +16 -0
  236. package/templates/projects/blank/metadata.json +7 -0
  237. package/templates/studio-config.yaml +14 -0
  238. package/tests/__stubs__/studio-runner.ts +104 -0
  239. package/tests/commands/api.test.ts +110 -0
  240. package/tests/commands/config.test.ts +221 -0
  241. package/tests/commands/init.test.ts +919 -0
  242. package/tests/commands/install.test.ts +52 -0
  243. package/tests/commands/integrations.test.ts +158 -0
  244. package/tests/commands/ollama.test.ts +139 -0
  245. package/tests/commands/project.test.ts +179 -0
  246. package/tests/commands/registry/audit.test.ts +56 -0
  247. package/tests/commands/registry/install.test.ts +200 -0
  248. package/tests/commands/registry/publish.test.ts +56 -0
  249. package/tests/commands/registry/remove.test.ts +103 -0
  250. package/tests/commands/registry/search.test.ts +44 -0
  251. package/tests/commands/registry/sync.test.ts +37 -0
  252. package/tests/commands/registry/update.test.ts +62 -0
  253. package/tests/commands/replay.test.ts +283 -0
  254. package/tests/commands/template/validate.test.ts +150 -0
  255. package/tests/commands/templates.test.ts +42 -0
  256. package/tests/commands/tools.test.ts +106 -0
  257. package/tests/config.test.ts +142 -0
  258. package/tests/formatter.test.ts +158 -0
  259. package/tests/integration/sigint.test.ts +188 -0
  260. package/tests/models-cache.test.ts +250 -0
  261. package/tests/output/file-changes.test.ts +178 -0
  262. package/tests/output/formatters.test.ts +448 -0
  263. package/tests/output/progress-spinner.test.ts +232 -0
  264. package/tests/output/progress-timer.test.ts +230 -0
  265. package/tests/provider-validator.test.ts +182 -0
  266. package/tests/registry/cache.test.ts +66 -0
  267. package/tests/registry/client.test.ts +70 -0
  268. package/tests/registry/lockfile.test.ts +87 -0
  269. package/tests/registry/resolver.test.ts +122 -0
  270. package/tests/run-logger-events.test.ts +326 -0
  271. package/tests/run-logger.test.ts +64 -0
  272. package/tests/run-store-factory.test.ts +51 -0
  273. package/tests/studio-dir.test.ts +31 -0
  274. package/tests/utils/input-wizard.test.ts +153 -0
  275. package/tests/utils/placeholders.test.ts +36 -0
  276. package/tsconfig.json +24 -0
  277. package/vitest.config.ts +20 -0
@@ -0,0 +1,448 @@
1
+ // cli/tests/output/formatters.test.ts
2
+ import { describe, it, expect } from 'vitest';
3
+ import {
4
+ humanReadableStageName,
5
+ summarizeToolCalls,
6
+ getToolIcon,
7
+ summarizeToolParams,
8
+ summarizeToolResult,
9
+ formatStageOutput,
10
+ formatToolResult,
11
+ formatTokens,
12
+ formatStageLine,
13
+ countWriteFiles,
14
+ } from '../../src/output/formatters.js';
15
+ import type { ToolCallSummary } from '@studio-foundation/engine';
16
+
17
+ describe('humanReadableStageName', () => {
18
+ it('maps brief-analysis to Analyzing brief', () => {
19
+ expect(humanReadableStageName('brief-analysis')).toBe('Analyzing brief');
20
+ });
21
+
22
+ it('maps implementation-plan to Planning implementation', () => {
23
+ expect(humanReadableStageName('implementation-plan')).toBe('Planning implementation');
24
+ });
25
+
26
+ it('maps code-generation to Generating code', () => {
27
+ expect(humanReadableStageName('code-generation')).toBe('Generating code');
28
+ });
29
+
30
+ it('maps qa-review to Reviewing', () => {
31
+ expect(humanReadableStageName('qa-review')).toBe('Reviewing');
32
+ });
33
+
34
+ it('falls back to title-cased words for unknown names', () => {
35
+ expect(humanReadableStageName('custom-stage')).toBe('Custom Stage');
36
+ });
37
+
38
+ it('handles single-word stage names', () => {
39
+ expect(humanReadableStageName('analysis')).toBe('Analysis');
40
+ });
41
+ });
42
+
43
+ describe('summarizeToolCalls', () => {
44
+ it('returns empty string for empty array', () => {
45
+ expect(summarizeToolCalls([])).toBe('');
46
+ });
47
+
48
+ it('groups read_file calls', () => {
49
+ const calls: ToolCallSummary[] = [
50
+ { name: 'repo_manager-read_file', arguments_summary: 'src/a.ts' },
51
+ { name: 'repo_manager-read_file', arguments_summary: 'src/b.ts' },
52
+ { name: 'repo_manager-read_file', arguments_summary: 'src/c.ts' },
53
+ ];
54
+ expect(summarizeToolCalls(calls)).toBe('Read 3 files');
55
+ });
56
+
57
+ it('groups write_file calls', () => {
58
+ const calls: ToolCallSummary[] = [
59
+ { name: 'repo_manager-write_file', arguments_summary: 'src/a.ts' },
60
+ ];
61
+ expect(summarizeToolCalls(calls)).toBe('Wrote 1 file');
62
+ });
63
+
64
+ it('groups mixed tool calls', () => {
65
+ const calls: ToolCallSummary[] = [
66
+ { name: 'repo_manager-read_file', arguments_summary: 'a' },
67
+ { name: 'repo_manager-read_file', arguments_summary: 'b' },
68
+ { name: 'repo_manager-write_file', arguments_summary: 'c' },
69
+ { name: 'shell-run_command', arguments_summary: 'npm test' },
70
+ ];
71
+ expect(summarizeToolCalls(calls)).toBe('Read 2 files, wrote 1 file, ran 1 command');
72
+ });
73
+
74
+ it('groups list_files calls', () => {
75
+ const calls: ToolCallSummary[] = [
76
+ { name: 'repo_manager-list_files', arguments_summary: 'src/' },
77
+ { name: 'repo_manager-list_files', arguments_summary: 'tests/' },
78
+ ];
79
+ expect(summarizeToolCalls(calls)).toBe('Listed 2 directories');
80
+ });
81
+
82
+ it('handles unknown tool names with a generic label', () => {
83
+ const calls: ToolCallSummary[] = [
84
+ { name: 'custom-do_something', arguments_summary: '' },
85
+ ];
86
+ expect(summarizeToolCalls(calls)).toBe('1 tool call');
87
+ });
88
+
89
+ it('groups search calls', () => {
90
+ const calls: ToolCallSummary[] = [
91
+ { name: 'search-search_codebase', arguments_summary: 'useState' },
92
+ ];
93
+ expect(summarizeToolCalls(calls)).toBe('Searched 1 time');
94
+ });
95
+ });
96
+
97
+ describe('getToolIcon', () => {
98
+ it('returns 📖 for read_file tools', () => {
99
+ expect(getToolIcon('repo_manager-read_file')).toBe('📖');
100
+ });
101
+
102
+ it('returns ✏️ for write_file tools', () => {
103
+ expect(getToolIcon('repo_manager-write_file')).toBe('✏️');
104
+ });
105
+
106
+ it('returns 📁 for list_files tools', () => {
107
+ expect(getToolIcon('repo_manager-list_files')).toBe('📁');
108
+ });
109
+
110
+ it('returns 🔍 for search tools', () => {
111
+ expect(getToolIcon('search-search_codebase')).toBe('🔍');
112
+ });
113
+
114
+ it('returns ⚙️ for shell tools', () => {
115
+ expect(getToolIcon('shell-run_command')).toBe('⚙️');
116
+ });
117
+
118
+ it('returns 🔀 for git tools', () => {
119
+ expect(getToolIcon('git-commit')).toBe('🔀');
120
+ });
121
+
122
+ it('returns 🔧 for unknown tools', () => {
123
+ expect(getToolIcon('custom-unknown_tool')).toBe('🔧');
124
+ });
125
+ });
126
+
127
+ describe('summarizeToolParams', () => {
128
+ it('shows path for read_file', () => {
129
+ expect(summarizeToolParams('repo_manager-read_file', { path: 'src/app.ts' }))
130
+ .toBe('(src/app.ts)');
131
+ });
132
+
133
+ it('shows path for write_file', () => {
134
+ expect(summarizeToolParams('repo_manager-write_file', { path: 'src/new.ts', content: '...' }))
135
+ .toBe('(src/new.ts)');
136
+ });
137
+
138
+ it('shows path for list_files when present', () => {
139
+ expect(summarizeToolParams('repo_manager-list_files', { path: 'src/' }))
140
+ .toBe('(src/)');
141
+ });
142
+
143
+ it('returns empty string for list_files without path', () => {
144
+ expect(summarizeToolParams('repo_manager-list_files', {})).toBe('');
145
+ });
146
+
147
+ it('shows pattern for search tools', () => {
148
+ expect(summarizeToolParams('search-search_codebase', { pattern: 'useState' }))
149
+ .toBe('("useState")');
150
+ });
151
+
152
+ it('shows command for shell tools', () => {
153
+ expect(summarizeToolParams('shell-run_command', { command: 'npm test' }))
154
+ .toBe('("npm test")');
155
+ });
156
+
157
+ it('returns empty string for unknown tools', () => {
158
+ expect(summarizeToolParams('custom-do_thing', { foo: 'bar' })).toBe('');
159
+ });
160
+ });
161
+
162
+ describe('summarizeToolResult', () => {
163
+ it('returns error message when error is set', () => {
164
+ expect(summarizeToolResult(undefined, 'file not found')).toBe('file not found');
165
+ });
166
+
167
+ it('returns line count for multi-line strings', () => {
168
+ expect(summarizeToolResult('line1\nline2\nline3')).toBe('3 lines');
169
+ });
170
+
171
+ it('returns the string itself for single-line string under 60 chars', () => {
172
+ expect(summarizeToolResult('short result')).toBe('short result');
173
+ });
174
+
175
+ it('truncates long single-line strings to 60 chars', () => {
176
+ expect(summarizeToolResult('x'.repeat(80))).toHaveLength(60);
177
+ });
178
+
179
+ it('returns item count for arrays', () => {
180
+ expect(summarizeToolResult(['a', 'b', 'c'])).toBe('3 items');
181
+ });
182
+
183
+ it('returns Done for other types', () => {
184
+ expect(summarizeToolResult({ key: 'value' })).toBe('Done');
185
+ });
186
+
187
+ it('returns Done for null', () => {
188
+ expect(summarizeToolResult(null)).toBe('Done');
189
+ });
190
+
191
+ it('returns line count for read_file result object', () => {
192
+ const content = 'line1\nline2\nline3\nline4';
193
+ expect(summarizeToolResult({ path: 'src/app.ts', content })).toBe('4 lines');
194
+ });
195
+
196
+ it('returns file count for list_files result object', () => {
197
+ expect(summarizeToolResult({ path: 'src/', files: ['a.ts', 'b.ts', 'c.ts'], count: 3 })).toBe('3 files');
198
+ });
199
+
200
+ it('returns "written" for write_file result object', () => {
201
+ expect(summarizeToolResult({ path: 'src/new.ts', written: true })).toBe('written');
202
+ });
203
+ });
204
+
205
+ describe('formatStageOutput', () => {
206
+ it('renders short strings inline', () => {
207
+ const result = formatStageOutput({ status: 'approved' });
208
+ expect(result).toBe('Status : approved');
209
+ });
210
+
211
+ it('renders numbers and booleans inline', () => {
212
+ const result = formatStageOutput({ score: 42, passed: true });
213
+ expect(result).toContain('Score : 42');
214
+ expect(result).toContain('Passed : true');
215
+ });
216
+
217
+ it('renders null as dash', () => {
218
+ const result = formatStageOutput({ value: null });
219
+ expect(result).toBe('Value : —');
220
+ });
221
+
222
+ it('renders undefined as dash', () => {
223
+ const result = formatStageOutput({ value: undefined });
224
+ expect(result).toBe('Value : —');
225
+ });
226
+
227
+ it('aligns keys to the longest key name', () => {
228
+ const result = formatStageOutput({ status: 'ok', summary: 'done' });
229
+ const lines = result.split('\n');
230
+ // Both ':' should be at the same column
231
+ const col0 = lines[0].indexOf(':');
232
+ const col1 = lines[1].indexOf(':');
233
+ expect(col0).toBe(col1);
234
+ });
235
+
236
+ it('renders long strings on a new line with indent', () => {
237
+ const longStr = 'The implementation covers all the requested changes and follows the existing patterns in the codebase correctly.';
238
+ const result = formatStageOutput({ summary: longStr });
239
+ expect(result).toContain('Summary :');
240
+ expect(result).toContain('\n');
241
+ expect(result).toContain(` ${longStr}`);
242
+ });
243
+
244
+ it('renders short primitive arrays inline', () => {
245
+ const result = formatStageOutput({ tags: ['ui', 'css', 'dark-mode'] });
246
+ expect(result).toBe('Tags : ui, css, dark-mode');
247
+ });
248
+
249
+ it('renders long primitive arrays vertically', () => {
250
+ const items = Array.from({ length: 10 }, (_, i) => `very-long-tag-name-${i}`);
251
+ const result = formatStageOutput({ tags: items });
252
+ expect(result).toContain('Tags :');
253
+ expect(result).toContain(' • very-long-tag-name-0');
254
+ expect(result).toContain(' • very-long-tag-name-9');
255
+ });
256
+
257
+ it('renders arrays of objects with numbered indices', () => {
258
+ const result = formatStageOutput({
259
+ issues: [
260
+ { title: 'Missing component', suggestion: 'Add it' },
261
+ { title: 'Bad import', suggestion: 'Fix it' },
262
+ ],
263
+ });
264
+ expect(result).toContain('Issues :');
265
+ expect(result).toContain(' ① Missing component');
266
+ expect(result).toContain(' Suggestion : Add it');
267
+ expect(result).toContain(' ② Bad import');
268
+ expect(result).toContain(' Suggestion : Fix it');
269
+ });
270
+
271
+ it('uses fallback numbering beyond 10 items', () => {
272
+ const items = Array.from({ length: 11 }, (_, i) => ({ name: `item-${i}` }));
273
+ const result = formatStageOutput({ list: items });
274
+ expect(result).toContain('(11) item-10');
275
+ });
276
+
277
+ it('renders nested objects with indentation', () => {
278
+ const result = formatStageOutput({
279
+ details: { author: 'Alice', score: 95 },
280
+ });
281
+ expect(result).toContain('Details :');
282
+ expect(result).toContain(' Author : Alice');
283
+ expect(result).toContain(' Score : 95');
284
+ });
285
+
286
+ it('falls back to compact JSON at depth > 4', () => {
287
+ const deep = { a: { b: { c: { d: { e: 'deep' } } } } };
288
+ const result = formatStageOutput(deep);
289
+ // At depth 4, the innermost value should be JSON
290
+ expect(result).toContain('{"e":"deep"}');
291
+ });
292
+
293
+ it('handles empty object', () => {
294
+ expect(formatStageOutput({})).toBe('');
295
+ });
296
+
297
+ it('handles empty arrays', () => {
298
+ const result = formatStageOutput({ items: [] });
299
+ expect(result).toBe('Items : (empty)');
300
+ });
301
+
302
+ it('renders a realistic QA output', () => {
303
+ const output = {
304
+ status: 'approved_with_notes',
305
+ summary: 'The implementation is mostly complete.',
306
+ issues: [
307
+ { title: 'ThemeToggle not in layout', suggestion: 'Add <ThemeToggle /> to header' },
308
+ { title: 'localStorage not confirmed', suggestion: 'Implement retrieval logic' },
309
+ ],
310
+ };
311
+ const result = formatStageOutput(output);
312
+ expect(result).toContain('Status : approved_with_notes');
313
+ expect(result).toContain('Summary : The implementation is mostly complete.');
314
+ expect(result).toContain('Issues :');
315
+ expect(result).toContain(' ① ThemeToggle not in layout');
316
+ expect(result).toContain(' ② localStorage not confirmed');
317
+ });
318
+
319
+ it('renders arrays of objects that have a single string field as compact items', () => {
320
+ const result = formatStageOutput({
321
+ files_changed: [
322
+ { path: 'src/app.ts' },
323
+ { path: 'src/theme.ts' },
324
+ ],
325
+ });
326
+ expect(result).toContain('① src/app.ts');
327
+ expect(result).toContain('② src/theme.ts');
328
+ });
329
+ });
330
+
331
+ describe('formatTokens', () => {
332
+ it('returns raw number for values under 1000', () => {
333
+ expect(formatTokens(450)).toBe('450');
334
+ });
335
+
336
+ it('formats thousands with one decimal', () => {
337
+ expect(formatTokens(2100)).toBe('2.1k');
338
+ });
339
+
340
+ it('drops .0 for clean thousands', () => {
341
+ expect(formatTokens(3000)).toBe('3k');
342
+ });
343
+
344
+ it('formats large token counts', () => {
345
+ expect(formatTokens(17900)).toBe('17.9k');
346
+ });
347
+
348
+ it('formats millions', () => {
349
+ expect(formatTokens(1234567)).toBe('1.2M');
350
+ });
351
+
352
+ it('returns 0 for zero', () => {
353
+ expect(formatTokens(0)).toBe('0');
354
+ });
355
+ });
356
+
357
+ describe('formatStageLine', () => {
358
+ it('fills dots between name and suffix to fixed width', () => {
359
+ const line = formatStageLine('[1/4]', 'brief-analysis', '✓ done');
360
+ expect(line).toContain('[1/4] brief-analysis');
361
+ expect(line).toContain('✓ done');
362
+ expect(line).toMatch(/brief-analysis\s+\.{2,}\s+✓ done/);
363
+ });
364
+
365
+ it('produces consistent alignment regardless of name length', () => {
366
+ const short = formatStageLine('[1/4]', 'qa-review', '✓');
367
+ const long = formatStageLine('[2/4]', 'implementation-plan', '✓');
368
+ const shortDotEnd = short.indexOf('✓');
369
+ const longDotEnd = long.indexOf('✓');
370
+ expect(shortDotEnd).toBe(longDotEnd);
371
+ });
372
+
373
+ it('handles very long stage names by using minimum dots', () => {
374
+ const line = formatStageLine('[1/4]', 'a-very-long-stage-name-that-exceeds-normal', '✓');
375
+ expect(line).toMatch(/\.{2,}/);
376
+ });
377
+ });
378
+
379
+ describe('countWriteFiles', () => {
380
+ it('returns 0 when no tool calls', () => {
381
+ expect(countWriteFiles([])).toBe(0);
382
+ });
383
+
384
+ it('counts write_file tool calls', () => {
385
+ const calls: ToolCallSummary[] = [
386
+ { name: 'repo_manager-write_file', arguments_summary: 'a.ts' },
387
+ { name: 'repo_manager-read_file', arguments_summary: 'b.ts' },
388
+ { name: 'repo_manager-write_file', arguments_summary: 'c.ts' },
389
+ ];
390
+ expect(countWriteFiles(calls)).toBe(2);
391
+ });
392
+
393
+ it('counts apply_patch tool calls as file writes', () => {
394
+ const calls: ToolCallSummary[] = [
395
+ { name: 'repo_manager-apply_patch', arguments_summary: 'a.ts' },
396
+ ];
397
+ expect(countWriteFiles(calls)).toBe(1);
398
+ });
399
+ });
400
+
401
+ describe('formatToolResult', () => {
402
+ it('formats a plain string with indentation', () => {
403
+ const result = formatToolResult('line1\nline2\nline3');
404
+ expect(result).toBe(' line1\n line2\n line3');
405
+ });
406
+
407
+ it('formats a single-line string', () => {
408
+ const result = formatToolResult('short result');
409
+ expect(result).toBe(' short result');
410
+ });
411
+
412
+ it('extracts .content from read_file-style results', () => {
413
+ const result = formatToolResult({ content: 'file content\nline 2' });
414
+ expect(result).toBe(' file content\n line 2');
415
+ });
416
+
417
+ it('formats arrays as JSON', () => {
418
+ const result = formatToolResult(['a.ts', 'b.ts']);
419
+ expect(result).toContain('a.ts');
420
+ expect(result).toContain('b.ts');
421
+ });
422
+
423
+ it('formats objects without .content as indented JSON', () => {
424
+ const result = formatToolResult({ written: true, path: 'src/app.ts' });
425
+ expect(result).toContain('"written": true');
426
+ expect(result).toContain('"path": "src/app.ts"');
427
+ });
428
+
429
+ it('returns " (error)" for error strings', () => {
430
+ const result = formatToolResult(undefined, 'file not found');
431
+ expect(result).toBe(' (error) file not found');
432
+ });
433
+
434
+ it('returns " (empty)" for null', () => {
435
+ const result = formatToolResult(null);
436
+ expect(result).toBe(' (empty)');
437
+ });
438
+
439
+ it('returns " (empty)" for undefined without error', () => {
440
+ const result = formatToolResult(undefined);
441
+ expect(result).toBe(' (empty)');
442
+ });
443
+
444
+ it('formats object with .content as empty string', () => {
445
+ const result = formatToolResult({ content: '' });
446
+ expect(result).toBe(' (empty content)');
447
+ });
448
+ });
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock ora BEFORE importing progress.ts
4
+ const mockOraInstance = {
5
+ start: vi.fn().mockReturnThis(),
6
+ stop: vi.fn().mockReturnThis(),
7
+ succeed: vi.fn().mockReturnThis(),
8
+ fail: vi.fn().mockReturnThis(),
9
+ };
10
+ vi.mock('ora', () => ({ default: vi.fn(() => mockOraInstance) }));
11
+ import ora from 'ora';
12
+
13
+ // Now import the module under test
14
+ import { ProgressDisplay } from '../../src/output/progress.js';
15
+
16
+ function makeDisplay() {
17
+ return new ProgressDisplay(false, 'live');
18
+ }
19
+
20
+ function stageStartEvent(n = 1, total = 3) {
21
+ return { stage_name: 'code-generation', stage_index: n - 1, total_stages: total, max_attempts: 3 };
22
+ }
23
+
24
+ function stageCompleteEvent(status = 'success') {
25
+ return {
26
+ stage_name: 'code-generation', stage_index: 0, total_stages: 3,
27
+ status, attempts: 1, duration_ms: 1000,
28
+ };
29
+ }
30
+
31
+ function toolCallStartEvent() {
32
+ return { tool: 'repo_manager-read_file', params: { path: 'foo.ts' }, timestamp: Date.now() };
33
+ }
34
+
35
+ function toolCallCompleteEvent() {
36
+ return { tool: 'repo_manager-read_file', result: 'ok', duration_ms: 100, timestamp: Date.now() };
37
+ }
38
+
39
+ describe('ProgressDisplay — thinking spinner (live mode)', () => {
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ });
43
+
44
+ it('starts thinking spinner on onStageStart', () => {
45
+ const d = makeDisplay();
46
+ const events = d.getEvents();
47
+ events.onStageStart!(stageStartEvent());
48
+ expect(ora).toHaveBeenCalledWith(expect.objectContaining({ text: expect.stringContaining('Thinking') }));
49
+ expect(mockOraInstance.start).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it('stops thinking spinner before starting tool spinner on onToolCallStart', () => {
53
+ const d = makeDisplay();
54
+ const events = d.getEvents();
55
+ events.onStageStart!(stageStartEvent());
56
+ vi.clearAllMocks();
57
+ events.onToolCallStart!(toolCallStartEvent());
58
+ expect(mockOraInstance.stop).toHaveBeenCalled();
59
+ });
60
+
61
+ it('restarts thinking spinner after onToolCallComplete', () => {
62
+ const d = makeDisplay();
63
+ const events = d.getEvents();
64
+ events.onStageStart!(stageStartEvent());
65
+ events.onToolCallStart!(toolCallStartEvent());
66
+ vi.clearAllMocks();
67
+ events.onToolCallComplete!(toolCallCompleteEvent());
68
+ // After tool succeeds a new thinking spinner should start
69
+ expect(mockOraInstance.start).toHaveBeenCalled();
70
+ });
71
+
72
+ it('restarts thinking spinner after onToolCallComplete with error', () => {
73
+ const d = makeDisplay();
74
+ const events = d.getEvents();
75
+ events.onStageStart!(stageStartEvent());
76
+ events.onToolCallStart!(toolCallStartEvent());
77
+ vi.clearAllMocks();
78
+ events.onToolCallComplete!({ tool: 'repo_manager-read_file', result: undefined, error: 'file not found', duration_ms: 100, timestamp: Date.now() });
79
+ expect(mockOraInstance.start).toHaveBeenCalled();
80
+ });
81
+
82
+ it('stops thinking spinner on onStageComplete', () => {
83
+ const d = makeDisplay();
84
+ const events = d.getEvents();
85
+ events.onStageStart!(stageStartEvent());
86
+ vi.clearAllMocks();
87
+ events.onStageComplete!(stageCompleteEvent());
88
+ expect(mockOraInstance.stop).toHaveBeenCalled();
89
+ });
90
+
91
+ it('does NOT start thinking spinner in non-live mode', () => {
92
+ const d = new ProgressDisplay(false, 'quiet');
93
+ const events = d.getEvents();
94
+ events.onStageStart!(stageStartEvent());
95
+ expect(ora).not.toHaveBeenCalledWith(expect.objectContaining({ text: expect.stringContaining('Thinking') }));
96
+ });
97
+
98
+ it('stops thinking spinner on onTaskRetry', () => {
99
+ const d = makeDisplay();
100
+ const events = d.getEvents();
101
+ events.onStageStart!(stageStartEvent());
102
+ vi.clearAllMocks();
103
+ events.onTaskRetry!({ stage: 'code-generation', attempt: 2, max_attempts: 3, failures: ['validation failed'] });
104
+ expect(mockOraInstance.stop).toHaveBeenCalled();
105
+ });
106
+ });
107
+
108
+ describe('ProgressDisplay — token streaming (live mode)', () => {
109
+ beforeEach(() => {
110
+ vi.clearAllMocks();
111
+ });
112
+
113
+ it('stops thinking spinner when first token arrives', () => {
114
+ const d = makeDisplay();
115
+ const events = d.getEvents();
116
+ events.onStageStart!(stageStartEvent());
117
+ vi.clearAllMocks();
118
+ events.onAgentToken!({ token: 'Hello', stage: 'code-generation', timestamp: Date.now() });
119
+ expect(mockOraInstance.stop).toHaveBeenCalled();
120
+ });
121
+
122
+ it('does not print tokens in non-live mode', () => {
123
+ const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
124
+ const d = new ProgressDisplay(false, 'quiet');
125
+ const events = d.getEvents();
126
+ events.onAgentToken!({ token: 'Hello', stage: 'code-generation', timestamp: Date.now() });
127
+ expect(writeSpy).not.toHaveBeenCalled();
128
+ writeSpy.mockRestore();
129
+ });
130
+ });
131
+
132
+ describe('ProgressDisplay — constructor accepts live + verbose booleans', () => {
133
+ beforeEach(() => {
134
+ vi.clearAllMocks();
135
+ });
136
+
137
+ it('accepts {live: true, verbose: false} and behaves like live mode', () => {
138
+ const d = new ProgressDisplay(false, { live: true, verbose: false });
139
+ const events = d.getEvents();
140
+ events.onStageStart!(stageStartEvent());
141
+ expect(ora).toHaveBeenCalledWith(expect.objectContaining({ text: expect.stringContaining('Thinking') }));
142
+ });
143
+
144
+ it('accepts {live: false, verbose: false} and behaves like quiet mode', () => {
145
+ const d = new ProgressDisplay(false, { live: false, verbose: false });
146
+ const events = d.getEvents();
147
+ events.onStageStart!(stageStartEvent());
148
+ // quiet mode: uses regular spinner, no thinking spinner
149
+ expect(ora).not.toHaveBeenCalledWith(expect.objectContaining({ text: expect.stringContaining('Thinking') }));
150
+ });
151
+
152
+ it('prints full tool result in live+verbose mode', () => {
153
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
154
+ const d = new ProgressDisplay(false, { live: true, verbose: true });
155
+ const events = d.getEvents();
156
+ events.onStageStart!(stageStartEvent());
157
+ events.onToolCallStart!(toolCallStartEvent());
158
+ events.onToolCallComplete!({
159
+ tool: 'repo_manager-read_file',
160
+ result: { content: 'const x = 1;\nconst y = 2;' },
161
+ duration_ms: 50,
162
+ timestamp: Date.now(),
163
+ });
164
+ // Should print full content, not just "2 lines"
165
+ const allOutput = logSpy.mock.calls.map(c => c[0]).join('\n');
166
+ expect(allOutput).toContain('const x = 1;');
167
+ expect(allOutput).toContain('const y = 2;');
168
+ logSpy.mockRestore();
169
+ });
170
+
171
+ it('does NOT print full tool result in live-only mode', () => {
172
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
173
+ const d = new ProgressDisplay(false, { live: true, verbose: false });
174
+ const events = d.getEvents();
175
+ events.onStageStart!(stageStartEvent());
176
+ events.onToolCallStart!(toolCallStartEvent());
177
+ events.onToolCallComplete!({
178
+ tool: 'repo_manager-read_file',
179
+ result: { content: 'const x = 1;\nconst y = 2;' },
180
+ duration_ms: 50,
181
+ timestamp: Date.now(),
182
+ });
183
+ const allOutput = logSpy.mock.calls.map(c => c[0]).join('\n');
184
+ expect(allOutput).not.toContain('const x = 1;');
185
+ logSpy.mockRestore();
186
+ });
187
+
188
+ it('shows token breakdown in live+verbose mode', () => {
189
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
190
+ const d = new ProgressDisplay(false, { live: true, verbose: true });
191
+ const events = d.getEvents();
192
+ events.onStageStart!(stageStartEvent());
193
+ events.onStageComplete!({
194
+ ...stageCompleteEvent(),
195
+ token_usage: { prompt_tokens: 1000, completion_tokens: 500, total_tokens: 1500 },
196
+ });
197
+ const allOutput = logSpy.mock.calls.map(c => c[0]).join('\n');
198
+ expect(allOutput).toContain('Tokens:');
199
+ expect(allOutput).toContain('1500');
200
+ logSpy.mockRestore();
201
+ });
202
+
203
+ it('does NOT show token breakdown in live-only mode', () => {
204
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
205
+ const d = new ProgressDisplay(false, { live: true, verbose: false });
206
+ const events = d.getEvents();
207
+ events.onStageStart!(stageStartEvent());
208
+ events.onStageComplete!({
209
+ ...stageCompleteEvent(),
210
+ token_usage: { prompt_tokens: 1000, completion_tokens: 500, total_tokens: 1500 },
211
+ });
212
+ const allOutput = logSpy.mock.calls.map(c => c[0]).join('\n');
213
+ expect(allOutput).not.toContain('Tokens:');
214
+ logSpy.mockRestore();
215
+ });
216
+
217
+ it('passes maxDepth Infinity to formatStageOutput in live+verbose mode', () => {
218
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
219
+ const d = new ProgressDisplay(false, { live: true, verbose: true });
220
+ const events = d.getEvents();
221
+ events.onStageStart!(stageStartEvent());
222
+ events.onStageComplete!({
223
+ ...stageCompleteEvent(),
224
+ output: { a: { b: { c: { d: { e: 'deep value' } } } } },
225
+ });
226
+ const allOutput = logSpy.mock.calls.map(c => c[0]).join('\n');
227
+ // With Infinity depth, 'deep value' should appear as-is, not as JSON
228
+ expect(allOutput).toContain('deep value');
229
+ expect(allOutput).not.toContain('{"e":"deep value"}');
230
+ logSpy.mockRestore();
231
+ });
232
+ });