rafcode 2.1.1 → 2.2.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 (120) hide show
  1. package/.claude/settings.local.json +4 -1
  2. package/CLAUDE.md +59 -11
  3. package/RAF/ahslfe-config-wizard/decisions.md +34 -0
  4. package/RAF/ahslfe-config-wizard/input.md +1 -0
  5. package/RAF/ahslfe-config-wizard/outcomes/01-define-config-schema.md +38 -0
  6. package/RAF/ahslfe-config-wizard/outcomes/02-refactor-codebase-to-use-config.md +67 -0
  7. package/RAF/ahslfe-config-wizard/outcomes/03-create-config-documentation.md +37 -0
  8. package/RAF/ahslfe-config-wizard/outcomes/04-implement-raf-config-command.md +47 -0
  9. package/RAF/ahslfe-config-wizard/outcomes/05-update-claude-md.md +26 -0
  10. package/RAF/ahslfe-config-wizard/plans/01-define-config-schema.md +73 -0
  11. package/RAF/ahslfe-config-wizard/plans/02-refactor-codebase-to-use-config.md +74 -0
  12. package/RAF/ahslfe-config-wizard/plans/03-create-config-documentation.md +57 -0
  13. package/RAF/ahslfe-config-wizard/plans/04-implement-raf-config-command.md +66 -0
  14. package/RAF/ahslfe-config-wizard/plans/05-update-claude-md.md +60 -0
  15. package/RAF/ahstvo-token-tracker/decisions.md +44 -0
  16. package/RAF/ahstvo-token-tracker/input.md +3 -0
  17. package/RAF/ahstvo-token-tracker/outcomes/01-full-model-id-support.md +43 -0
  18. package/RAF/ahstvo-token-tracker/outcomes/02-name-generation-no-session.md +33 -0
  19. package/RAF/ahstvo-token-tracker/outcomes/03-unify-stream-json-execution.md +48 -0
  20. package/RAF/ahstvo-token-tracker/outcomes/04-token-tracking-cost-calculation.md +53 -0
  21. package/RAF/ahstvo-token-tracker/outcomes/05-token-cost-console-reporting.md +57 -0
  22. package/RAF/ahstvo-token-tracker/outcomes/06-runtime-verbose-toggle.md +53 -0
  23. package/RAF/ahstvo-token-tracker/outcomes/07-readme-config-docs.md +36 -0
  24. package/RAF/ahstvo-token-tracker/plans/01-full-model-id-support.md +35 -0
  25. package/RAF/ahstvo-token-tracker/plans/02-name-generation-no-session.md +36 -0
  26. package/RAF/ahstvo-token-tracker/plans/03-unify-stream-json-execution.md +44 -0
  27. package/RAF/ahstvo-token-tracker/plans/04-token-tracking-cost-calculation.md +56 -0
  28. package/RAF/ahstvo-token-tracker/plans/05-token-cost-console-reporting.md +55 -0
  29. package/RAF/ahstvo-token-tracker/plans/06-runtime-verbose-toggle.md +48 -0
  30. package/RAF/ahstvo-token-tracker/plans/07-readme-config-docs.md +44 -0
  31. package/README.md +34 -0
  32. package/dist/commands/config.d.ts +3 -0
  33. package/dist/commands/config.d.ts.map +1 -0
  34. package/dist/commands/config.js +173 -0
  35. package/dist/commands/config.js.map +1 -0
  36. package/dist/commands/do.d.ts.map +1 -1
  37. package/dist/commands/do.js +47 -6
  38. package/dist/commands/do.js.map +1 -1
  39. package/dist/commands/plan.d.ts.map +1 -1
  40. package/dist/commands/plan.js +3 -2
  41. package/dist/commands/plan.js.map +1 -1
  42. package/dist/core/claude-runner.d.ts +19 -2
  43. package/dist/core/claude-runner.d.ts.map +1 -1
  44. package/dist/core/claude-runner.js +43 -96
  45. package/dist/core/claude-runner.js.map +1 -1
  46. package/dist/core/failure-analyzer.d.ts.map +1 -1
  47. package/dist/core/failure-analyzer.js +6 -3
  48. package/dist/core/failure-analyzer.js.map +1 -1
  49. package/dist/core/git.d.ts.map +1 -1
  50. package/dist/core/git.js +10 -3
  51. package/dist/core/git.js.map +1 -1
  52. package/dist/core/pull-request.d.ts +1 -1
  53. package/dist/core/pull-request.d.ts.map +1 -1
  54. package/dist/core/pull-request.js +7 -4
  55. package/dist/core/pull-request.js.map +1 -1
  56. package/dist/index.js +2 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/parsers/stream-renderer.d.ts +16 -1
  59. package/dist/parsers/stream-renderer.d.ts.map +1 -1
  60. package/dist/parsers/stream-renderer.js +34 -4
  61. package/dist/parsers/stream-renderer.js.map +1 -1
  62. package/dist/prompts/execution.d.ts.map +1 -1
  63. package/dist/prompts/execution.js +11 -1
  64. package/dist/prompts/execution.js.map +1 -1
  65. package/dist/types/config.d.ts +95 -4
  66. package/dist/types/config.d.ts.map +1 -1
  67. package/dist/types/config.js +63 -3
  68. package/dist/types/config.js.map +1 -1
  69. package/dist/utils/config.d.ts +59 -7
  70. package/dist/utils/config.d.ts.map +1 -1
  71. package/dist/utils/config.js +276 -21
  72. package/dist/utils/config.js.map +1 -1
  73. package/dist/utils/name-generator.d.ts +3 -7
  74. package/dist/utils/name-generator.d.ts.map +1 -1
  75. package/dist/utils/name-generator.js +75 -61
  76. package/dist/utils/name-generator.js.map +1 -1
  77. package/dist/utils/terminal-symbols.d.ts +21 -0
  78. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  79. package/dist/utils/terminal-symbols.js +62 -0
  80. package/dist/utils/terminal-symbols.js.map +1 -1
  81. package/dist/utils/token-tracker.d.ts +45 -0
  82. package/dist/utils/token-tracker.d.ts.map +1 -0
  83. package/dist/utils/token-tracker.js +107 -0
  84. package/dist/utils/token-tracker.js.map +1 -0
  85. package/dist/utils/validation.d.ts +5 -5
  86. package/dist/utils/validation.d.ts.map +1 -1
  87. package/dist/utils/validation.js +10 -6
  88. package/dist/utils/validation.js.map +1 -1
  89. package/dist/utils/verbose-toggle.d.ts +33 -0
  90. package/dist/utils/verbose-toggle.d.ts.map +1 -0
  91. package/dist/utils/verbose-toggle.js +94 -0
  92. package/dist/utils/verbose-toggle.js.map +1 -0
  93. package/package.json +1 -1
  94. package/src/commands/config.ts +204 -0
  95. package/src/commands/do.ts +56 -5
  96. package/src/commands/plan.ts +3 -2
  97. package/src/core/claude-runner.ts +59 -115
  98. package/src/core/failure-analyzer.ts +6 -3
  99. package/src/core/git.ts +10 -3
  100. package/src/core/pull-request.ts +7 -4
  101. package/src/index.ts +2 -0
  102. package/src/parsers/stream-renderer.ts +54 -4
  103. package/src/prompts/config-docs.md +331 -0
  104. package/src/prompts/execution.ts +13 -1
  105. package/src/types/config.ts +156 -7
  106. package/src/utils/config.ts +335 -21
  107. package/src/utils/name-generator.ts +84 -71
  108. package/src/utils/terminal-symbols.ts +68 -0
  109. package/src/utils/token-tracker.ts +135 -0
  110. package/src/utils/validation.ts +15 -10
  111. package/src/utils/verbose-toggle.ts +103 -0
  112. package/tests/unit/claude-runner.test.ts +171 -7
  113. package/tests/unit/config-command.test.ts +163 -0
  114. package/tests/unit/config.test.ts +608 -30
  115. package/tests/unit/name-generator.test.ts +99 -75
  116. package/tests/unit/pull-request.test.ts +2 -0
  117. package/tests/unit/stream-renderer.test.ts +83 -0
  118. package/tests/unit/terminal-symbols.test.ts +157 -0
  119. package/tests/unit/token-tracker.test.ts +352 -0
  120. package/tests/unit/verbose-toggle.test.ts +204 -0
@@ -0,0 +1,352 @@
1
+ import { TokenTracker, CostBreakdown } from '../../src/utils/token-tracker.js';
2
+ import { UsageData, PricingConfig, DEFAULT_CONFIG } from '../../src/types/config.js';
3
+
4
+ function makeUsage(overrides: Partial<UsageData> = {}): UsageData {
5
+ return {
6
+ inputTokens: 0,
7
+ outputTokens: 0,
8
+ cacheReadInputTokens: 0,
9
+ cacheCreationInputTokens: 0,
10
+ modelUsage: {},
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ const testPricing: PricingConfig = DEFAULT_CONFIG.pricing;
16
+
17
+ describe('TokenTracker', () => {
18
+ describe('calculateCost', () => {
19
+ it('should calculate cost for opus model usage', () => {
20
+ const tracker = new TokenTracker(testPricing);
21
+ const usage = makeUsage({
22
+ inputTokens: 1_000_000,
23
+ outputTokens: 500_000,
24
+ cacheReadInputTokens: 200_000,
25
+ cacheCreationInputTokens: 100_000,
26
+ modelUsage: {
27
+ 'claude-opus-4-6': {
28
+ inputTokens: 1_000_000,
29
+ outputTokens: 500_000,
30
+ cacheReadInputTokens: 200_000,
31
+ cacheCreationInputTokens: 100_000,
32
+ },
33
+ },
34
+ });
35
+
36
+ const cost = tracker.calculateCost(usage);
37
+ expect(cost.inputCost).toBeCloseTo(15); // 1M * $15/MTok
38
+ expect(cost.outputCost).toBeCloseTo(37.5); // 0.5M * $75/MTok
39
+ expect(cost.cacheReadCost).toBeCloseTo(0.3); // 0.2M * $1.5/MTok
40
+ expect(cost.cacheCreateCost).toBeCloseTo(1.875); // 0.1M * $18.75/MTok
41
+ expect(cost.totalCost).toBeCloseTo(15 + 37.5 + 0.3 + 1.875);
42
+ });
43
+
44
+ it('should calculate cost for sonnet model usage', () => {
45
+ const tracker = new TokenTracker(testPricing);
46
+ const usage = makeUsage({
47
+ inputTokens: 1_000_000,
48
+ outputTokens: 1_000_000,
49
+ modelUsage: {
50
+ 'claude-sonnet-4-5-20250929': {
51
+ inputTokens: 1_000_000,
52
+ outputTokens: 1_000_000,
53
+ cacheReadInputTokens: 0,
54
+ cacheCreationInputTokens: 0,
55
+ },
56
+ },
57
+ });
58
+
59
+ const cost = tracker.calculateCost(usage);
60
+ expect(cost.inputCost).toBeCloseTo(3); // 1M * $3/MTok
61
+ expect(cost.outputCost).toBeCloseTo(15); // 1M * $15/MTok
62
+ expect(cost.totalCost).toBeCloseTo(18);
63
+ });
64
+
65
+ it('should calculate cost for haiku model usage', () => {
66
+ const tracker = new TokenTracker(testPricing);
67
+ const usage = makeUsage({
68
+ inputTokens: 2_000_000,
69
+ outputTokens: 1_000_000,
70
+ modelUsage: {
71
+ 'claude-haiku-4-5-20251001': {
72
+ inputTokens: 2_000_000,
73
+ outputTokens: 1_000_000,
74
+ cacheReadInputTokens: 0,
75
+ cacheCreationInputTokens: 0,
76
+ },
77
+ },
78
+ });
79
+
80
+ const cost = tracker.calculateCost(usage);
81
+ expect(cost.inputCost).toBeCloseTo(2); // 2M * $1/MTok
82
+ expect(cost.outputCost).toBeCloseTo(5); // 1M * $5/MTok
83
+ expect(cost.totalCost).toBeCloseTo(7);
84
+ });
85
+
86
+ it('should handle multi-model usage in a single task', () => {
87
+ const tracker = new TokenTracker(testPricing);
88
+ const usage = makeUsage({
89
+ inputTokens: 2_000_000,
90
+ outputTokens: 1_500_000,
91
+ modelUsage: {
92
+ 'claude-opus-4-6': {
93
+ inputTokens: 1_000_000,
94
+ outputTokens: 500_000,
95
+ cacheReadInputTokens: 0,
96
+ cacheCreationInputTokens: 0,
97
+ },
98
+ 'claude-haiku-4-5-20251001': {
99
+ inputTokens: 1_000_000,
100
+ outputTokens: 1_000_000,
101
+ cacheReadInputTokens: 0,
102
+ cacheCreationInputTokens: 0,
103
+ },
104
+ },
105
+ });
106
+
107
+ const cost = tracker.calculateCost(usage);
108
+ // Opus: 1M*$15 + 0.5M*$75 = $15 + $37.5
109
+ // Haiku: 1M*$1 + 1M*$5 = $1 + $5
110
+ expect(cost.inputCost).toBeCloseTo(16); // 15 + 1
111
+ expect(cost.outputCost).toBeCloseTo(42.5); // 37.5 + 5
112
+ expect(cost.totalCost).toBeCloseTo(58.5);
113
+ });
114
+
115
+ it('should fallback to sonnet pricing when no model breakdown', () => {
116
+ const tracker = new TokenTracker(testPricing);
117
+ const usage = makeUsage({
118
+ inputTokens: 1_000_000,
119
+ outputTokens: 1_000_000,
120
+ modelUsage: {},
121
+ });
122
+
123
+ const cost = tracker.calculateCost(usage);
124
+ expect(cost.inputCost).toBeCloseTo(3); // sonnet fallback
125
+ expect(cost.outputCost).toBeCloseTo(15);
126
+ expect(cost.totalCost).toBeCloseTo(18);
127
+ });
128
+
129
+ it('should fallback to sonnet pricing for unknown model families', () => {
130
+ const tracker = new TokenTracker(testPricing);
131
+ const usage = makeUsage({
132
+ inputTokens: 1_000_000,
133
+ outputTokens: 1_000_000,
134
+ modelUsage: {
135
+ 'claude-unknown-3-0': {
136
+ inputTokens: 1_000_000,
137
+ outputTokens: 1_000_000,
138
+ cacheReadInputTokens: 0,
139
+ cacheCreationInputTokens: 0,
140
+ },
141
+ },
142
+ });
143
+
144
+ const cost = tracker.calculateCost(usage);
145
+ expect(cost.inputCost).toBeCloseTo(3); // sonnet fallback
146
+ expect(cost.outputCost).toBeCloseTo(15);
147
+ });
148
+
149
+ it('should return zero cost for zero tokens', () => {
150
+ const tracker = new TokenTracker(testPricing);
151
+ const usage = makeUsage();
152
+ const cost = tracker.calculateCost(usage);
153
+ expect(cost.totalCost).toBe(0);
154
+ });
155
+
156
+ it('should apply cache read discount correctly', () => {
157
+ const tracker = new TokenTracker(testPricing);
158
+ const usage = makeUsage({
159
+ cacheReadInputTokens: 1_000_000,
160
+ modelUsage: {
161
+ 'claude-sonnet-4-5': {
162
+ inputTokens: 0,
163
+ outputTokens: 0,
164
+ cacheReadInputTokens: 1_000_000,
165
+ cacheCreationInputTokens: 0,
166
+ },
167
+ },
168
+ });
169
+
170
+ const cost = tracker.calculateCost(usage);
171
+ // Cache read: 1M * $0.30/MTok = $0.30 (90% off $3 input price)
172
+ expect(cost.cacheReadCost).toBeCloseTo(0.3);
173
+ expect(cost.totalCost).toBeCloseTo(0.3);
174
+ });
175
+ });
176
+
177
+ describe('addTask and accumulation', () => {
178
+ it('should accumulate usage across multiple tasks', () => {
179
+ const tracker = new TokenTracker(testPricing);
180
+
181
+ tracker.addTask('01', makeUsage({
182
+ inputTokens: 500_000,
183
+ outputTokens: 200_000,
184
+ modelUsage: {
185
+ 'claude-opus-4-6': {
186
+ inputTokens: 500_000,
187
+ outputTokens: 200_000,
188
+ cacheReadInputTokens: 0,
189
+ cacheCreationInputTokens: 0,
190
+ },
191
+ },
192
+ }));
193
+
194
+ tracker.addTask('02', makeUsage({
195
+ inputTokens: 300_000,
196
+ outputTokens: 100_000,
197
+ modelUsage: {
198
+ 'claude-opus-4-6': {
199
+ inputTokens: 300_000,
200
+ outputTokens: 100_000,
201
+ cacheReadInputTokens: 0,
202
+ cacheCreationInputTokens: 0,
203
+ },
204
+ },
205
+ }));
206
+
207
+ const totals = tracker.getTotals();
208
+ expect(totals.usage.inputTokens).toBe(800_000);
209
+ expect(totals.usage.outputTokens).toBe(300_000);
210
+ expect(totals.usage.modelUsage['claude-opus-4-6']?.inputTokens).toBe(800_000);
211
+ expect(totals.usage.modelUsage['claude-opus-4-6']?.outputTokens).toBe(300_000);
212
+ });
213
+
214
+ it('should accumulate costs across multiple tasks', () => {
215
+ const tracker = new TokenTracker(testPricing);
216
+
217
+ const entry1 = tracker.addTask('01', makeUsage({
218
+ inputTokens: 1_000_000,
219
+ outputTokens: 1_000_000,
220
+ modelUsage: {
221
+ 'claude-sonnet-4-5': {
222
+ inputTokens: 1_000_000,
223
+ outputTokens: 1_000_000,
224
+ cacheReadInputTokens: 0,
225
+ cacheCreationInputTokens: 0,
226
+ },
227
+ },
228
+ }));
229
+
230
+ const entry2 = tracker.addTask('02', makeUsage({
231
+ inputTokens: 1_000_000,
232
+ outputTokens: 1_000_000,
233
+ modelUsage: {
234
+ 'claude-sonnet-4-5': {
235
+ inputTokens: 1_000_000,
236
+ outputTokens: 1_000_000,
237
+ cacheReadInputTokens: 0,
238
+ cacheCreationInputTokens: 0,
239
+ },
240
+ },
241
+ }));
242
+
243
+ const totals = tracker.getTotals();
244
+ // Each task: $3 input + $15 output = $18
245
+ expect(entry1.cost.totalCost).toBeCloseTo(18);
246
+ expect(entry2.cost.totalCost).toBeCloseTo(18);
247
+ expect(totals.cost.totalCost).toBeCloseTo(36);
248
+ });
249
+
250
+ it('should accumulate multi-model usage across tasks', () => {
251
+ const tracker = new TokenTracker(testPricing);
252
+
253
+ tracker.addTask('01', makeUsage({
254
+ inputTokens: 1_000_000,
255
+ outputTokens: 500_000,
256
+ modelUsage: {
257
+ 'claude-opus-4-6': {
258
+ inputTokens: 1_000_000,
259
+ outputTokens: 500_000,
260
+ cacheReadInputTokens: 0,
261
+ cacheCreationInputTokens: 0,
262
+ },
263
+ },
264
+ }));
265
+
266
+ tracker.addTask('02', makeUsage({
267
+ inputTokens: 500_000,
268
+ outputTokens: 200_000,
269
+ modelUsage: {
270
+ 'claude-haiku-4-5-20251001': {
271
+ inputTokens: 500_000,
272
+ outputTokens: 200_000,
273
+ cacheReadInputTokens: 0,
274
+ cacheCreationInputTokens: 0,
275
+ },
276
+ },
277
+ }));
278
+
279
+ const totals = tracker.getTotals();
280
+ expect(totals.usage.modelUsage['claude-opus-4-6']?.inputTokens).toBe(1_000_000);
281
+ expect(totals.usage.modelUsage['claude-haiku-4-5-20251001']?.inputTokens).toBe(500_000);
282
+ });
283
+
284
+ it('should return empty totals when no tasks added', () => {
285
+ const tracker = new TokenTracker(testPricing);
286
+ const totals = tracker.getTotals();
287
+ expect(totals.usage.inputTokens).toBe(0);
288
+ expect(totals.usage.outputTokens).toBe(0);
289
+ expect(totals.cost.totalCost).toBe(0);
290
+ expect(Object.keys(totals.usage.modelUsage)).toHaveLength(0);
291
+ });
292
+
293
+ it('should return per-task entries', () => {
294
+ const tracker = new TokenTracker(testPricing);
295
+ tracker.addTask('01', makeUsage({ inputTokens: 100 }));
296
+ tracker.addTask('02', makeUsage({ inputTokens: 200 }));
297
+
298
+ const entries = tracker.getEntries();
299
+ expect(entries).toHaveLength(2);
300
+ expect(entries[0].taskId).toBe('01');
301
+ expect(entries[1].taskId).toBe('02');
302
+ });
303
+
304
+ it('addTask returns the entry with cost', () => {
305
+ const tracker = new TokenTracker(testPricing);
306
+ const entry = tracker.addTask('01', makeUsage({
307
+ inputTokens: 1_000_000,
308
+ modelUsage: {
309
+ 'claude-opus-4-6': {
310
+ inputTokens: 1_000_000,
311
+ outputTokens: 0,
312
+ cacheReadInputTokens: 0,
313
+ cacheCreationInputTokens: 0,
314
+ },
315
+ },
316
+ }));
317
+
318
+ expect(entry.taskId).toBe('01');
319
+ expect(entry.cost.inputCost).toBeCloseTo(15);
320
+ expect(entry.cost.totalCost).toBeCloseTo(15);
321
+ });
322
+ });
323
+
324
+ describe('custom pricing', () => {
325
+ it('should use custom pricing config', () => {
326
+ const customPricing: PricingConfig = {
327
+ opus: { inputPerMTok: 10, outputPerMTok: 50, cacheReadPerMTok: 1, cacheCreatePerMTok: 12.5 },
328
+ sonnet: { inputPerMTok: 2, outputPerMTok: 10, cacheReadPerMTok: 0.2, cacheCreatePerMTok: 2.5 },
329
+ haiku: { inputPerMTok: 0.5, outputPerMTok: 2.5, cacheReadPerMTok: 0.05, cacheCreatePerMTok: 0.625 },
330
+ };
331
+
332
+ const tracker = new TokenTracker(customPricing);
333
+ const usage = makeUsage({
334
+ inputTokens: 1_000_000,
335
+ outputTokens: 1_000_000,
336
+ modelUsage: {
337
+ 'claude-opus-4-6': {
338
+ inputTokens: 1_000_000,
339
+ outputTokens: 1_000_000,
340
+ cacheReadInputTokens: 0,
341
+ cacheCreationInputTokens: 0,
342
+ },
343
+ },
344
+ });
345
+
346
+ const cost = tracker.calculateCost(usage);
347
+ expect(cost.inputCost).toBeCloseTo(10); // 1M * $10/MTok
348
+ expect(cost.outputCost).toBeCloseTo(50); // 1M * $50/MTok
349
+ expect(cost.totalCost).toBeCloseTo(60);
350
+ });
351
+ });
352
+ });
@@ -0,0 +1,204 @@
1
+ import { jest } from '@jest/globals';
2
+ import { EventEmitter } from 'events';
3
+
4
+ // Mock logger to capture output
5
+ const mockDim = jest.fn();
6
+ jest.unstable_mockModule('../../src/utils/logger.js', () => ({
7
+ logger: {
8
+ dim: mockDim,
9
+ info: jest.fn(),
10
+ debug: jest.fn(),
11
+ warn: jest.fn(),
12
+ error: jest.fn(),
13
+ },
14
+ }));
15
+
16
+ const { VerboseToggle } = await import('../../src/utils/verbose-toggle.js');
17
+
18
+ describe('VerboseToggle', () => {
19
+ // Save original stdin properties
20
+ const originalIsTTY = process.stdin.isTTY;
21
+ const originalSetRawMode = process.stdin.setRawMode;
22
+ const originalResume = process.stdin.resume;
23
+ const originalPause = process.stdin.pause;
24
+
25
+ let mockSetRawMode: jest.Mock;
26
+
27
+ beforeEach(() => {
28
+ jest.clearAllMocks();
29
+ mockSetRawMode = jest.fn();
30
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
31
+ (process.stdin as any).setRawMode = mockSetRawMode;
32
+ (process.stdin as any).resume = jest.fn();
33
+ (process.stdin as any).pause = jest.fn();
34
+ });
35
+
36
+ afterEach(() => {
37
+ // Restore original stdin
38
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, writable: true, configurable: true });
39
+ if (originalSetRawMode) {
40
+ (process.stdin as any).setRawMode = originalSetRawMode;
41
+ }
42
+ (process.stdin as any).resume = originalResume;
43
+ (process.stdin as any).pause = originalPause;
44
+ });
45
+
46
+ it('initializes with the provided verbose state', () => {
47
+ const toggle = new VerboseToggle(true);
48
+ expect(toggle.isVerbose).toBe(true);
49
+
50
+ const toggle2 = new VerboseToggle(false);
51
+ expect(toggle2.isVerbose).toBe(false);
52
+ });
53
+
54
+ it('is not active before start()', () => {
55
+ const toggle = new VerboseToggle(false);
56
+ expect(toggle.isActive).toBe(false);
57
+ });
58
+
59
+ it('becomes active after start() on a TTY', () => {
60
+ const toggle = new VerboseToggle(false);
61
+ toggle.start();
62
+ expect(toggle.isActive).toBe(true);
63
+ expect(mockSetRawMode).toHaveBeenCalledWith(true);
64
+ toggle.stop();
65
+ });
66
+
67
+ it('shows toggle hint on start', () => {
68
+ const toggle = new VerboseToggle(false);
69
+ toggle.start();
70
+ expect(mockDim).toHaveBeenCalledWith(' Press Tab to toggle verbose mode');
71
+ toggle.stop();
72
+ });
73
+
74
+ it('skips start when stdin is not a TTY', () => {
75
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true, configurable: true });
76
+ const toggle = new VerboseToggle(false);
77
+ toggle.start();
78
+ expect(toggle.isActive).toBe(false);
79
+ expect(mockSetRawMode).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it('toggles verbose state on Tab keypress', () => {
83
+ const toggle = new VerboseToggle(false);
84
+ toggle.start();
85
+
86
+ // Simulate Tab key (0x09)
87
+ process.stdin.emit('data', Buffer.from([0x09]));
88
+ expect(toggle.isVerbose).toBe(true);
89
+ expect(mockDim).toHaveBeenCalledWith(' [verbose: on]');
90
+
91
+ // Toggle back
92
+ process.stdin.emit('data', Buffer.from([0x09]));
93
+ expect(toggle.isVerbose).toBe(false);
94
+ expect(mockDim).toHaveBeenCalledWith(' [verbose: off]');
95
+
96
+ toggle.stop();
97
+ });
98
+
99
+ it('emits SIGINT on Ctrl+C keypress', () => {
100
+ const toggle = new VerboseToggle(false);
101
+ toggle.start();
102
+
103
+ const sigintHandler = jest.fn();
104
+ process.once('SIGINT', sigintHandler);
105
+
106
+ // Simulate Ctrl+C (0x03)
107
+ process.stdin.emit('data', Buffer.from([0x03]));
108
+ expect(sigintHandler).toHaveBeenCalled();
109
+
110
+ toggle.stop();
111
+ });
112
+
113
+ it('ignores non-Tab, non-Ctrl+C keypresses', () => {
114
+ const toggle = new VerboseToggle(false);
115
+ toggle.start();
116
+
117
+ // Simulate 'a' keypress
118
+ process.stdin.emit('data', Buffer.from([0x61]));
119
+ expect(toggle.isVerbose).toBe(false);
120
+
121
+ toggle.stop();
122
+ });
123
+
124
+ it('handles multiple bytes in a single data event', () => {
125
+ const toggle = new VerboseToggle(false);
126
+ toggle.start();
127
+
128
+ // Two Tab keys in one buffer
129
+ process.stdin.emit('data', Buffer.from([0x09, 0x09]));
130
+ // Should toggle twice → back to false
131
+ expect(toggle.isVerbose).toBe(false);
132
+
133
+ toggle.stop();
134
+ });
135
+
136
+ it('restores stdin on stop()', () => {
137
+ const toggle = new VerboseToggle(false);
138
+ toggle.start();
139
+ toggle.stop();
140
+
141
+ expect(toggle.isActive).toBe(false);
142
+ expect(mockSetRawMode).toHaveBeenCalledWith(false);
143
+ expect(process.stdin.pause).toHaveBeenCalled();
144
+ });
145
+
146
+ it('is safe to call stop() multiple times', () => {
147
+ const toggle = new VerboseToggle(false);
148
+ toggle.start();
149
+ toggle.stop();
150
+ toggle.stop(); // should not throw
151
+ expect(toggle.isActive).toBe(false);
152
+ });
153
+
154
+ it('is safe to call start() multiple times', () => {
155
+ const toggle = new VerboseToggle(false);
156
+ toggle.start();
157
+ toggle.start(); // should not start again
158
+ expect(mockSetRawMode).toHaveBeenCalledTimes(1);
159
+ toggle.stop();
160
+ });
161
+
162
+ it('does not respond to keypress after stop()', () => {
163
+ const toggle = new VerboseToggle(false);
164
+ toggle.start();
165
+ toggle.stop();
166
+
167
+ // Clear mocks to check no new calls
168
+ mockDim.mockClear();
169
+
170
+ // This should not trigger any toggle
171
+ process.stdin.emit('data', Buffer.from([0x09]));
172
+ expect(toggle.isVerbose).toBe(false);
173
+ // Only the hint message was logged (already cleared by mockClear), no toggle messages
174
+ expect(mockDim).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('works correctly across multiple tasks (stop and restart)', () => {
178
+ const toggle = new VerboseToggle(false);
179
+
180
+ // First task
181
+ toggle.start();
182
+ process.stdin.emit('data', Buffer.from([0x09]));
183
+ expect(toggle.isVerbose).toBe(true);
184
+ toggle.stop();
185
+
186
+ // State persists after stop
187
+ expect(toggle.isVerbose).toBe(true);
188
+
189
+ // Restart for second task
190
+ toggle.start();
191
+ expect(toggle.isVerbose).toBe(true); // Still true from previous toggle
192
+ process.stdin.emit('data', Buffer.from([0x09]));
193
+ expect(toggle.isVerbose).toBe(false);
194
+ toggle.stop();
195
+ });
196
+
197
+ it('handles setRawMode throwing an error', () => {
198
+ mockSetRawMode.mockImplementation(() => { throw new Error('Cannot set raw mode'); });
199
+ const toggle = new VerboseToggle(false);
200
+ toggle.start();
201
+ // Should gracefully skip activation
202
+ expect(toggle.isActive).toBe(false);
203
+ });
204
+ });