brosh 0.2.2

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 (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/brosh_brandmark.svg +3 -0
  4. package/brosh_logo.svg +27 -0
  5. package/cli_icon.svg +52 -0
  6. package/dist/client.d.ts +5 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +138 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +618 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/lib.d.ts +25 -0
  15. package/dist/lib.d.ts.map +1 -0
  16. package/dist/lib.js +28 -0
  17. package/dist/lib.js.map +1 -0
  18. package/dist/mode-selector.d.ts +7 -0
  19. package/dist/mode-selector.d.ts.map +1 -0
  20. package/dist/mode-selector.js +138 -0
  21. package/dist/mode-selector.js.map +1 -0
  22. package/dist/prompts/index.d.ts +3 -0
  23. package/dist/prompts/index.d.ts.map +1 -0
  24. package/dist/prompts/index.js +79 -0
  25. package/dist/prompts/index.js.map +1 -0
  26. package/dist/recording/index.d.ts +4 -0
  27. package/dist/recording/index.d.ts.map +1 -0
  28. package/dist/recording/index.js +3 -0
  29. package/dist/recording/index.js.map +1 -0
  30. package/dist/recording/manager.d.ts +62 -0
  31. package/dist/recording/manager.d.ts.map +1 -0
  32. package/dist/recording/manager.js +123 -0
  33. package/dist/recording/manager.js.map +1 -0
  34. package/dist/recording/recorder.d.ts +95 -0
  35. package/dist/recording/recorder.d.ts.map +1 -0
  36. package/dist/recording/recorder.js +330 -0
  37. package/dist/recording/recorder.js.map +1 -0
  38. package/dist/recording/types.d.ts +65 -0
  39. package/dist/recording/types.d.ts.map +1 -0
  40. package/dist/recording/types.js +2 -0
  41. package/dist/recording/types.js.map +1 -0
  42. package/dist/sandbox/ModeSelector.d.ts +2 -0
  43. package/dist/sandbox/ModeSelector.d.ts.map +1 -0
  44. package/dist/sandbox/ModeSelector.js +2 -0
  45. package/dist/sandbox/ModeSelector.js.map +1 -0
  46. package/dist/sandbox/config.d.ts +46 -0
  47. package/dist/sandbox/config.d.ts.map +1 -0
  48. package/dist/sandbox/config.js +144 -0
  49. package/dist/sandbox/config.js.map +1 -0
  50. package/dist/sandbox/controller.d.ts +72 -0
  51. package/dist/sandbox/controller.d.ts.map +1 -0
  52. package/dist/sandbox/controller.js +208 -0
  53. package/dist/sandbox/controller.js.map +1 -0
  54. package/dist/sandbox/index.d.ts +6 -0
  55. package/dist/sandbox/index.d.ts.map +1 -0
  56. package/dist/sandbox/index.js +4 -0
  57. package/dist/sandbox/index.js.map +1 -0
  58. package/dist/sandbox/mode-prompt.d.ts +10 -0
  59. package/dist/sandbox/mode-prompt.d.ts.map +1 -0
  60. package/dist/sandbox/mode-prompt.js +130 -0
  61. package/dist/sandbox/mode-prompt.js.map +1 -0
  62. package/dist/sandbox/prompt.d.ts +10 -0
  63. package/dist/sandbox/prompt.d.ts.map +1 -0
  64. package/dist/sandbox/prompt.js +434 -0
  65. package/dist/sandbox/prompt.js.map +1 -0
  66. package/dist/server.d.ts +28 -0
  67. package/dist/server.d.ts.map +1 -0
  68. package/dist/server.js +59 -0
  69. package/dist/server.js.map +1 -0
  70. package/dist/terminal/index.d.ts +5 -0
  71. package/dist/terminal/index.d.ts.map +1 -0
  72. package/dist/terminal/index.js +3 -0
  73. package/dist/terminal/index.js.map +1 -0
  74. package/dist/terminal/manager.d.ts +153 -0
  75. package/dist/terminal/manager.d.ts.map +1 -0
  76. package/dist/terminal/manager.js +276 -0
  77. package/dist/terminal/manager.js.map +1 -0
  78. package/dist/terminal/session.d.ts +137 -0
  79. package/dist/terminal/session.d.ts.map +1 -0
  80. package/dist/terminal/session.js +752 -0
  81. package/dist/terminal/session.js.map +1 -0
  82. package/dist/tools/definitions.d.ts +18 -0
  83. package/dist/tools/definitions.d.ts.map +1 -0
  84. package/dist/tools/definitions.js +114 -0
  85. package/dist/tools/definitions.js.map +1 -0
  86. package/dist/tools/getContent.d.ts +32 -0
  87. package/dist/tools/getContent.d.ts.map +1 -0
  88. package/dist/tools/getContent.js +38 -0
  89. package/dist/tools/getContent.js.map +1 -0
  90. package/dist/tools/index.d.ts +4 -0
  91. package/dist/tools/index.d.ts.map +1 -0
  92. package/dist/tools/index.js +49 -0
  93. package/dist/tools/index.js.map +1 -0
  94. package/dist/tools/screenshot.d.ts +20 -0
  95. package/dist/tools/screenshot.d.ts.map +1 -0
  96. package/dist/tools/screenshot.js +28 -0
  97. package/dist/tools/screenshot.js.map +1 -0
  98. package/dist/tools/sendKey.d.ts +31 -0
  99. package/dist/tools/sendKey.d.ts.map +1 -0
  100. package/dist/tools/sendKey.js +38 -0
  101. package/dist/tools/sendKey.js.map +1 -0
  102. package/dist/tools/startRecording.d.ts +68 -0
  103. package/dist/tools/startRecording.d.ts.map +1 -0
  104. package/dist/tools/startRecording.js +111 -0
  105. package/dist/tools/startRecording.js.map +1 -0
  106. package/dist/tools/stopRecording.d.ts +31 -0
  107. package/dist/tools/stopRecording.d.ts.map +1 -0
  108. package/dist/tools/stopRecording.js +76 -0
  109. package/dist/tools/stopRecording.js.map +1 -0
  110. package/dist/tools/type.d.ts +31 -0
  111. package/dist/tools/type.d.ts.map +1 -0
  112. package/dist/tools/type.js +31 -0
  113. package/dist/tools/type.js.map +1 -0
  114. package/dist/transport/gui-protocol.d.ts +163 -0
  115. package/dist/transport/gui-protocol.d.ts.map +1 -0
  116. package/dist/transport/gui-protocol.js +68 -0
  117. package/dist/transport/gui-protocol.js.map +1 -0
  118. package/dist/transport/gui-stream.d.ts +139 -0
  119. package/dist/transport/gui-stream.d.ts.map +1 -0
  120. package/dist/transport/gui-stream.js +440 -0
  121. package/dist/transport/gui-stream.js.map +1 -0
  122. package/dist/transport/index.d.ts +6 -0
  123. package/dist/transport/index.d.ts.map +1 -0
  124. package/dist/transport/index.js +6 -0
  125. package/dist/transport/index.js.map +1 -0
  126. package/dist/transport/socket.d.ts +46 -0
  127. package/dist/transport/socket.d.ts.map +1 -0
  128. package/dist/transport/socket.js +310 -0
  129. package/dist/transport/socket.js.map +1 -0
  130. package/dist/types/mcp-client-info.d.ts +226 -0
  131. package/dist/types/mcp-client-info.d.ts.map +1 -0
  132. package/dist/types/mcp-client-info.js +62 -0
  133. package/dist/types/mcp-client-info.js.map +1 -0
  134. package/dist/ui/index.d.ts +12 -0
  135. package/dist/ui/index.d.ts.map +1 -0
  136. package/dist/ui/index.js +84 -0
  137. package/dist/ui/index.js.map +1 -0
  138. package/dist/utils/env.d.ts +17 -0
  139. package/dist/utils/env.d.ts.map +1 -0
  140. package/dist/utils/env.js +35 -0
  141. package/dist/utils/env.js.map +1 -0
  142. package/dist/utils/keys.d.ts +16 -0
  143. package/dist/utils/keys.d.ts.map +1 -0
  144. package/dist/utils/keys.js +155 -0
  145. package/dist/utils/keys.js.map +1 -0
  146. package/dist/utils/platform.d.ts +16 -0
  147. package/dist/utils/platform.d.ts.map +1 -0
  148. package/dist/utils/platform.js +41 -0
  149. package/dist/utils/platform.js.map +1 -0
  150. package/dist/utils/session-logger.d.ts +31 -0
  151. package/dist/utils/session-logger.d.ts.map +1 -0
  152. package/dist/utils/session-logger.js +125 -0
  153. package/dist/utils/session-logger.js.map +1 -0
  154. package/dist/utils/stats.d.ts +46 -0
  155. package/dist/utils/stats.d.ts.map +1 -0
  156. package/dist/utils/stats.js +89 -0
  157. package/dist/utils/stats.js.map +1 -0
  158. package/dist/utils/version.d.ts +2 -0
  159. package/dist/utils/version.d.ts.map +1 -0
  160. package/dist/utils/version.js +9 -0
  161. package/dist/utils/version.js.map +1 -0
  162. package/logo.png +0 -0
  163. package/package.json +61 -0
  164. package/packages/desktop-electron/THIRD-PARTY-NOTICES +56 -0
  165. package/packages/desktop-electron/build/afterPack.cjs +147 -0
  166. package/packages/desktop-electron/package-lock.json +10071 -0
  167. package/packages/desktop-electron/package.json +170 -0
  168. package/packages/desktop-electron/resources/icons/mac/icon.icns +0 -0
  169. package/packages/desktop-electron/resources/icons/png/1024x1024.png +0 -0
  170. package/packages/desktop-electron/resources/icons/png/128x128.png +0 -0
  171. package/packages/desktop-electron/resources/icons/png/16x16.png +0 -0
  172. package/packages/desktop-electron/resources/icons/png/24x24.png +0 -0
  173. package/packages/desktop-electron/resources/icons/png/256x256.png +0 -0
  174. package/packages/desktop-electron/resources/icons/png/32x32.png +0 -0
  175. package/packages/desktop-electron/resources/icons/png/48x48.png +0 -0
  176. package/packages/desktop-electron/resources/icons/png/512x512.png +0 -0
  177. package/packages/desktop-electron/resources/icons/png/64x64.png +0 -0
  178. package/packages/desktop-electron/resources/icons/win/icon.ico +0 -0
  179. package/packages/desktop-electron/scripts/download-models.js +97 -0
  180. package/packages/desktop-electron/scripts/prepare-sandbox-bins.js +186 -0
  181. package/packages/desktop-electron/tests/main/ai-detection/additionalFunctions.test.ts +224 -0
  182. package/packages/desktop-electron/tests/main/ai-detection/checkOverridePrefix.test.ts +162 -0
  183. package/packages/desktop-electron/tests/main/ai-detection/classifyInput.test.ts +132 -0
  184. package/packages/desktop-electron/tests/main/ai-detection/detectTypos.test.ts +342 -0
  185. package/packages/desktop-electron/tests/main/ai-detection/fixtures/commands.ts +134 -0
  186. package/packages/desktop-electron/tests/main/ai-detection/fixtures/natural-language.ts +133 -0
  187. package/packages/desktop-electron/tests/main/ai-detection/fixtures/typos.ts +123 -0
  188. package/packages/desktop-electron/tests/main/ai-detection/hasValidSubcommand.test.ts +218 -0
  189. package/packages/desktop-electron/tests/main/ai-detection/isCommandNotFound.test.ts +117 -0
  190. package/packages/desktop-electron/tests/main/error-triage/buildTriagePrompt.test.ts +133 -0
  191. package/packages/desktop-electron/tests/main/error-triage/parseTriageResponse.test.ts +123 -0
  192. package/packages/desktop-electron/tests/main/terminal-bridge/battery-optimization.test.ts +243 -0
  193. package/packages/desktop-electron/tests/main/terminal-bridge/command-fast-track.test.ts +292 -0
  194. package/packages/desktop-electron/tests/main/terminal-bridge/default-cwd.test.ts +70 -0
  195. package/packages/desktop-electron/tests/setup.ts +274 -0
  196. package/packages/desktop-electron/tsconfig.json +18 -0
  197. package/packages/desktop-electron/tsconfig.main.json +20 -0
  198. package/packages/desktop-electron/vite.config.ts +19 -0
  199. package/packages/desktop-electron/vitest.config.ts +18 -0
  200. package/tsconfig.json +19 -0
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ /**
4
+ * Tests for triage response parsing.
5
+ *
6
+ * parseTriageResponse is not exported directly, so we test it
7
+ * through the module's behavior by examining the expected parsing patterns.
8
+ */
9
+
10
+ // Since parseTriageResponse is private, test the expected parsing patterns
11
+ describe('Triage Response Parsing Patterns', () => {
12
+ describe('envelope format (claude --output-format json)', () => {
13
+ it('should parse standard envelope with result string', () => {
14
+ const envelope = {
15
+ result: '{"shouldNotify": true, "message": "Module not found: express"}',
16
+ };
17
+ const innerJson = envelope.result;
18
+ const parsed = JSON.parse(innerJson);
19
+
20
+ expect(parsed.shouldNotify).toBe(true);
21
+ expect(parsed.message).toBe('Module not found: express');
22
+ });
23
+
24
+ it('should handle direct format (no envelope)', () => {
25
+ const direct = {
26
+ shouldNotify: false,
27
+ message: '',
28
+ };
29
+
30
+ expect(typeof direct.shouldNotify).toBe('boolean');
31
+ expect(direct.shouldNotify).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe('inner JSON parsing', () => {
36
+ it('should parse clean JSON', () => {
37
+ const json = '{"shouldNotify": true, "message": "Permission denied on /etc/config"}';
38
+ const parsed = JSON.parse(json);
39
+
40
+ expect(parsed.shouldNotify).toBe(true);
41
+ expect(parsed.message).toContain('Permission denied');
42
+ });
43
+
44
+ it('should handle markdown code fence wrapping', () => {
45
+ const wrapped = '```json\n{"shouldNotify": true, "message": "Syntax error"}\n```';
46
+ const cleaned = wrapped
47
+ .replace(/^```(?:json)?\s*\n?/, '')
48
+ .replace(/\n?```\s*$/, '');
49
+ const parsed = JSON.parse(cleaned);
50
+
51
+ expect(parsed.shouldNotify).toBe(true);
52
+ expect(parsed.message).toBe('Syntax error');
53
+ });
54
+
55
+ it('should handle code fence without language tag', () => {
56
+ const wrapped = '```\n{"shouldNotify": false, "message": ""}\n```';
57
+ const cleaned = wrapped
58
+ .replace(/^```(?:json)?\s*\n?/, '')
59
+ .replace(/\n?```\s*$/, '');
60
+ const parsed = JSON.parse(cleaned);
61
+
62
+ expect(parsed.shouldNotify).toBe(false);
63
+ });
64
+
65
+ it('should reject missing shouldNotify field', () => {
66
+ const json = '{"message": "some error"}';
67
+ const parsed = JSON.parse(json);
68
+
69
+ expect(typeof parsed.shouldNotify).not.toBe('boolean');
70
+ });
71
+
72
+ it('should handle empty message for shouldNotify=false', () => {
73
+ const json = '{"shouldNotify": false, "message": ""}';
74
+ const parsed = JSON.parse(json);
75
+
76
+ expect(parsed.shouldNotify).toBe(false);
77
+ expect(parsed.message).toBe('');
78
+ });
79
+ });
80
+
81
+ describe('error scenarios', () => {
82
+ it('should handle empty stdout', () => {
83
+ const trimmed = ''.trim();
84
+ expect(trimmed).toBe('');
85
+ // parseTriageResponse returns null for empty input
86
+ });
87
+
88
+ it('should handle malformed JSON', () => {
89
+ const malformed = '{"shouldNotify": true, message: broken}';
90
+ expect(() => JSON.parse(malformed)).toThrow();
91
+ });
92
+
93
+ it('should handle non-JSON responses', () => {
94
+ const text = 'The command failed because the module was not found.';
95
+ expect(() => JSON.parse(text)).toThrow();
96
+ });
97
+ });
98
+
99
+ describe('TriageResult contract', () => {
100
+ it('should always have boolean shouldNotify', () => {
101
+ const results = [
102
+ { shouldNotify: true, message: 'Error occurred' },
103
+ { shouldNotify: false, message: '' },
104
+ ];
105
+
106
+ for (const result of results) {
107
+ expect(typeof result.shouldNotify).toBe('boolean');
108
+ expect(typeof result.message).toBe('string');
109
+ }
110
+ });
111
+
112
+ it('should coerce message to string', () => {
113
+ // The actual code does String(parsed.message || "")
114
+ const coerce = (val: unknown) => String(val || '');
115
+
116
+ expect(coerce('hello')).toBe('hello');
117
+ expect(coerce(undefined)).toBe('');
118
+ expect(coerce(null)).toBe('');
119
+ expect(coerce('')).toBe('');
120
+ expect(coerce(42)).toBe('42');
121
+ });
122
+ });
123
+ });
@@ -0,0 +1,243 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ /**
4
+ * Tests for battery optimization features in terminal-bridge.
5
+ *
6
+ * These tests verify the state management logic for:
7
+ * - Window focus/blur handling
8
+ * - System suspend/resume handling
9
+ * - Spinner throttling when window is not focused
10
+ */
11
+
12
+ describe('Battery Optimization', () => {
13
+ // Since TerminalBridge is tightly coupled to Electron's BrowserWindow,
14
+ // we test the state management logic by creating a minimal implementation
15
+ // that mirrors the actual behavior.
16
+
17
+ // Mock state that mirrors TerminalBridge internals
18
+ let windowFocused: boolean;
19
+ let systemSuspended: boolean;
20
+ let sessionAISpinner: Map<string, { timer: ReturnType<typeof setInterval>; frameIdx: number }>;
21
+ let sessionAIActive: Map<string, { cancel: () => void }>;
22
+ let startLoadingAnimationCalls: string[];
23
+ let clearedIntervals: ReturnType<typeof setInterval>[];
24
+
25
+ // Mock functions that mirror TerminalBridge methods
26
+ function setWindowFocused(focused: boolean): void {
27
+ windowFocused = focused;
28
+ if (!focused) {
29
+ // Pause AI spinners when window loses focus to save CPU
30
+ for (const spinner of sessionAISpinner.values()) {
31
+ clearInterval(spinner.timer);
32
+ clearedIntervals.push(spinner.timer);
33
+ }
34
+ } else if (!systemSuspended) {
35
+ // Resume spinners for active AI sessions when window regains focus
36
+ for (const sessionId of sessionAIActive.keys()) {
37
+ if (!sessionAISpinner.has(sessionId)) {
38
+ startLoadingAnimationCalls.push(sessionId);
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ function handleSystemSuspend(): void {
45
+ systemSuspended = true;
46
+ // Stop all AI spinners during system sleep
47
+ for (const spinner of sessionAISpinner.values()) {
48
+ clearInterval(spinner.timer);
49
+ clearedIntervals.push(spinner.timer);
50
+ }
51
+ }
52
+
53
+ function handleSystemResume(): void {
54
+ systemSuspended = false;
55
+ if (windowFocused) {
56
+ // Resume spinners for active AI sessions
57
+ for (const sessionId of sessionAIActive.keys()) {
58
+ if (!sessionAISpinner.has(sessionId)) {
59
+ startLoadingAnimationCalls.push(sessionId);
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ beforeEach(() => {
66
+ windowFocused = true;
67
+ systemSuspended = false;
68
+ sessionAISpinner = new Map();
69
+ sessionAIActive = new Map();
70
+ startLoadingAnimationCalls = [];
71
+ clearedIntervals = [];
72
+ });
73
+
74
+ describe('setWindowFocused', () => {
75
+ it('should update windowFocused state to true', () => {
76
+ windowFocused = false;
77
+ setWindowFocused(true);
78
+ expect(windowFocused).toBe(true);
79
+ });
80
+
81
+ it('should update windowFocused state to false', () => {
82
+ windowFocused = true;
83
+ setWindowFocused(false);
84
+ expect(windowFocused).toBe(false);
85
+ });
86
+
87
+ it('should clear all spinner intervals when window loses focus', () => {
88
+ // Set up active spinners
89
+ const timer1 = setInterval(() => {}, 1000);
90
+ const timer2 = setInterval(() => {}, 1000);
91
+ sessionAISpinner.set('session-1', { timer: timer1, frameIdx: 0 });
92
+ sessionAISpinner.set('session-2', { timer: timer2, frameIdx: 0 });
93
+
94
+ setWindowFocused(false);
95
+
96
+ expect(clearedIntervals).toContain(timer1);
97
+ expect(clearedIntervals).toContain(timer2);
98
+
99
+ // Clean up
100
+ clearInterval(timer1);
101
+ clearInterval(timer2);
102
+ });
103
+
104
+ it('should restart spinners for active AI sessions when window regains focus', () => {
105
+ windowFocused = false;
106
+ sessionAIActive.set('session-1', { cancel: () => {} });
107
+ sessionAIActive.set('session-2', { cancel: () => {} });
108
+
109
+ setWindowFocused(true);
110
+
111
+ expect(startLoadingAnimationCalls).toContain('session-1');
112
+ expect(startLoadingAnimationCalls).toContain('session-2');
113
+ });
114
+
115
+ it('should not restart spinners if system is suspended', () => {
116
+ windowFocused = false;
117
+ systemSuspended = true;
118
+ sessionAIActive.set('session-1', { cancel: () => {} });
119
+
120
+ setWindowFocused(true);
121
+
122
+ expect(startLoadingAnimationCalls).toHaveLength(0);
123
+ });
124
+
125
+ it('should not restart spinners that already exist', () => {
126
+ windowFocused = false;
127
+ sessionAIActive.set('session-1', { cancel: () => {} });
128
+ const timer = setInterval(() => {}, 1000);
129
+ sessionAISpinner.set('session-1', { timer, frameIdx: 0 });
130
+
131
+ setWindowFocused(true);
132
+
133
+ expect(startLoadingAnimationCalls).not.toContain('session-1');
134
+
135
+ // Clean up
136
+ clearInterval(timer);
137
+ });
138
+ });
139
+
140
+ describe('handleSystemSuspend', () => {
141
+ it('should set systemSuspended to true', () => {
142
+ expect(systemSuspended).toBe(false);
143
+ handleSystemSuspend();
144
+ expect(systemSuspended).toBe(true);
145
+ });
146
+
147
+ it('should clear all spinner intervals', () => {
148
+ const timer1 = setInterval(() => {}, 1000);
149
+ const timer2 = setInterval(() => {}, 1000);
150
+ sessionAISpinner.set('session-1', { timer: timer1, frameIdx: 0 });
151
+ sessionAISpinner.set('session-2', { timer: timer2, frameIdx: 0 });
152
+
153
+ handleSystemSuspend();
154
+
155
+ expect(clearedIntervals).toContain(timer1);
156
+ expect(clearedIntervals).toContain(timer2);
157
+
158
+ // Clean up
159
+ clearInterval(timer1);
160
+ clearInterval(timer2);
161
+ });
162
+ });
163
+
164
+ describe('handleSystemResume', () => {
165
+ it('should set systemSuspended to false', () => {
166
+ systemSuspended = true;
167
+ handleSystemResume();
168
+ expect(systemSuspended).toBe(false);
169
+ });
170
+
171
+ it('should restart spinners for active sessions when window is focused', () => {
172
+ systemSuspended = true;
173
+ windowFocused = true;
174
+ sessionAIActive.set('session-1', { cancel: () => {} });
175
+ sessionAIActive.set('session-2', { cancel: () => {} });
176
+
177
+ handleSystemResume();
178
+
179
+ expect(startLoadingAnimationCalls).toContain('session-1');
180
+ expect(startLoadingAnimationCalls).toContain('session-2');
181
+ });
182
+
183
+ it('should not restart spinners when window is not focused', () => {
184
+ systemSuspended = true;
185
+ windowFocused = false;
186
+ sessionAIActive.set('session-1', { cancel: () => {} });
187
+
188
+ handleSystemResume();
189
+
190
+ expect(startLoadingAnimationCalls).toHaveLength(0);
191
+ });
192
+
193
+ it('should not restart spinners that already exist', () => {
194
+ systemSuspended = true;
195
+ windowFocused = true;
196
+ sessionAIActive.set('session-1', { cancel: () => {} });
197
+ const timer = setInterval(() => {}, 1000);
198
+ sessionAISpinner.set('session-1', { timer, frameIdx: 0 });
199
+
200
+ handleSystemResume();
201
+
202
+ expect(startLoadingAnimationCalls).not.toContain('session-1');
203
+
204
+ // Clean up
205
+ clearInterval(timer);
206
+ });
207
+ });
208
+
209
+ describe('interaction between focus and suspend states', () => {
210
+ it('should not restart spinners when resuming from sleep if window was blurred', () => {
211
+ // Simulate: window blurred -> system sleeps -> system wakes
212
+ windowFocused = true;
213
+ sessionAIActive.set('session-1', { cancel: () => {} });
214
+
215
+ // Window loses focus
216
+ setWindowFocused(false);
217
+ startLoadingAnimationCalls = [];
218
+
219
+ // System goes to sleep
220
+ handleSystemSuspend();
221
+
222
+ // System wakes up (window still not focused)
223
+ handleSystemResume();
224
+
225
+ expect(startLoadingAnimationCalls).toHaveLength(0);
226
+ });
227
+
228
+ it('should restart spinners when window is focused after wake', () => {
229
+ // Simulate: system sleeps -> system wakes -> window focused
230
+ systemSuspended = true;
231
+ windowFocused = false;
232
+ sessionAIActive.set('session-1', { cancel: () => {} });
233
+
234
+ // System wakes up
235
+ handleSystemResume();
236
+ expect(startLoadingAnimationCalls).toHaveLength(0);
237
+
238
+ // Window gets focus
239
+ setWindowFocused(true);
240
+ expect(startLoadingAnimationCalls).toContain('session-1');
241
+ });
242
+ });
243
+ });
@@ -0,0 +1,292 @@
1
+ import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
2
+ import {
3
+ classifyInput,
4
+ isKnownCommand,
5
+ initializeDetection,
6
+ } from '../../../src/main/ai-detection.js';
7
+
8
+ /**
9
+ * Tests for command-first input classification and retroactive NL detection.
10
+ *
11
+ * The fast-track flow:
12
+ * Enter → denylist → isKnownCommand? → YES → shell (skip ML)
13
+ * → NO → ML classifier
14
+ *
15
+ * When a fast-tracked command fails (non-zero exit):
16
+ * Re-classify with ML → NL? → invoke AI retroactively
17
+ * → CMD? → normal error triage
18
+ */
19
+
20
+ describe('Command Fast-Track Classification', () => {
21
+ beforeAll(async () => {
22
+ await initializeDetection({ preloadML: true });
23
+ });
24
+
25
+ describe('isKnownCommand', () => {
26
+ it('should recognize common Unix commands', () => {
27
+ const knownCommands = ['ls', 'cd', 'cat', 'grep', 'find', 'mkdir', 'rm', 'cp', 'mv', 'chmod'];
28
+ for (const cmd of knownCommands) {
29
+ expect(isKnownCommand(cmd)).toBe(true);
30
+ }
31
+ });
32
+
33
+ it('should recognize package managers', () => {
34
+ const packageManagers = ['npm', 'yarn', 'pip', 'brew', 'cargo'];
35
+ for (const cmd of packageManagers) {
36
+ expect(isKnownCommand(cmd)).toBe(true);
37
+ }
38
+ });
39
+
40
+ it('should recognize language runtimes', () => {
41
+ const runtimes = ['node', 'python', 'ruby', 'java', 'go'];
42
+ for (const cmd of runtimes) {
43
+ expect(isKnownCommand(cmd)).toBe(true);
44
+ }
45
+ });
46
+
47
+ it('should recognize version control tools', () => {
48
+ expect(isKnownCommand('git')).toBe(true);
49
+ });
50
+
51
+ it('should recognize container tools', () => {
52
+ const containers = ['docker', 'kubectl'];
53
+ for (const cmd of containers) {
54
+ expect(isKnownCommand(cmd)).toBe(true);
55
+ }
56
+ });
57
+
58
+ it('should NOT recognize natural language words', () => {
59
+ const nlWords = ['how', 'what', 'list', 'show', 'explain', 'help', 'create', 'delete'];
60
+ for (const word of nlWords) {
61
+ // Some of these might happen to be commands (like 'help')
62
+ // but most NL starter words should not be known commands
63
+ if (word === 'help') continue; // shell builtin
64
+ expect(isKnownCommand(word)).toBe(false);
65
+ }
66
+ });
67
+
68
+ it('should NOT recognize gibberish', () => {
69
+ expect(isKnownCommand('asdfgh')).toBe(false);
70
+ expect(isKnownCommand('xyzzy')).toBe(false);
71
+ expect(isKnownCommand('foobar123')).toBe(false);
72
+ });
73
+
74
+ it('should be case-sensitive (commands are lowercase)', () => {
75
+ // isKnownCommand expects lowercase input (caller normalizes)
76
+ expect(isKnownCommand('ls')).toBe(true);
77
+ expect(isKnownCommand('node')).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe('fast-track decision logic', () => {
82
+ // Mirrors the logic in terminal-bridge.ts handleInputWithAIDetection:
83
+ // 1. Extract firstWord from trimmedLine
84
+ // 2. If isKnownCommand(firstWord) → skip ML, send to shell
85
+ // 3. Else → run ML classifyInput
86
+
87
+ function shouldFastTrack(input: string): boolean {
88
+ const trimmed = input.trim();
89
+ if (!trimmed) return false;
90
+ const firstWord = trimmed.split(/\s+/)[0].toLowerCase();
91
+ return isKnownCommand(firstWord);
92
+ }
93
+
94
+ it('should fast-track simple commands', () => {
95
+ expect(shouldFastTrack('ls')).toBe(true);
96
+ expect(shouldFastTrack('ls -la')).toBe(true);
97
+ expect(shouldFastTrack('git status')).toBe(true);
98
+ expect(shouldFastTrack('npm install')).toBe(true);
99
+ expect(shouldFastTrack('docker ps')).toBe(true);
100
+ });
101
+
102
+ it('should fast-track commands where first word is a known command', () => {
103
+ // Even if the rest looks like NL, fast-track based on first word
104
+ expect(shouldFastTrack('node has how many letters')).toBe(true);
105
+ expect(shouldFastTrack('python is a great language')).toBe(true);
106
+ expect(shouldFastTrack('git is version control')).toBe(true);
107
+ });
108
+
109
+ it('should NOT fast-track natural language', () => {
110
+ expect(shouldFastTrack('list all files')).toBe(false);
111
+ expect(shouldFastTrack('show me the logs')).toBe(false);
112
+ expect(shouldFastTrack('how do I install node')).toBe(false);
113
+ expect(shouldFastTrack('what is my ip address')).toBe(false);
114
+ });
115
+
116
+ it('should NOT fast-track unknown first words', () => {
117
+ expect(shouldFastTrack('thisis a prompt')).toBe(false);
118
+ expect(shouldFastTrack('asdfgh')).toBe(false);
119
+ expect(shouldFastTrack('please run the tests')).toBe(false);
120
+ });
121
+
122
+ it('should NOT fast-track empty input', () => {
123
+ expect(shouldFastTrack('')).toBe(false);
124
+ expect(shouldFastTrack(' ')).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe('retroactive ML re-classification', () => {
129
+ // When a fast-tracked command fails, we re-classify with ML.
130
+ // If ML says NL with high confidence → invoke AI
131
+ // If ML says CMD → normal error handling
132
+
133
+ it('should re-classify "node has how many letters" as NL', async () => {
134
+ // First word is known (node), so it gets fast-tracked
135
+ expect(isKnownCommand('node')).toBe(true);
136
+
137
+ // But ML should classify the full input as NL
138
+ const result = await classifyInput('node has how many letters');
139
+ // The mock ML classifier should see this as a question-like statement
140
+ // The actual ML model classifies this as NL with high confidence
141
+ // With the mock, this depends on the heuristics in setup.ts
142
+ expect(result).toHaveProperty('classification');
143
+ expect(result).toHaveProperty('confidence');
144
+ });
145
+
146
+ it('should re-classify "node -e require(missing)" as CMD', async () => {
147
+ expect(isKnownCommand('node')).toBe(true);
148
+
149
+ // This is a real command that happened to fail
150
+ const result = await classifyInput('node -e "require(\'missing\')"');
151
+ expect(result.classification).toBe('COMMAND');
152
+ });
153
+
154
+ it('should re-classify "git is version control" as NL', async () => {
155
+ expect(isKnownCommand('git')).toBe(true);
156
+
157
+ // NL starting with a known command
158
+ const result = await classifyInput('git is version control');
159
+ // This should be classified based on ML heuristics
160
+ expect(result).toHaveProperty('classification');
161
+ });
162
+
163
+ it('should re-classify real commands with flags as CMD', async () => {
164
+ const realCommands = [
165
+ 'node --version',
166
+ 'node -e "console.log(1)"',
167
+ 'python -m pytest',
168
+ 'git log --oneline',
169
+ 'npm run build',
170
+ ];
171
+ for (const cmd of realCommands) {
172
+ const result = await classifyInput(cmd);
173
+ expect(result.classification).toBe('COMMAND');
174
+ }
175
+ });
176
+ });
177
+
178
+ describe('fast-track state management', () => {
179
+ // Mirrors sessionFastTracked / sessionFastTrackLines Map behavior
180
+
181
+ it('should track fast-track state per session', () => {
182
+ const sessionFastTracked = new Map<string, boolean>();
183
+ const sessionFastTrackLines = new Map<string, number>();
184
+
185
+ // Session 1: fast-tracked command
186
+ sessionFastTracked.set('session-1', true);
187
+ sessionFastTrackLines.set('session-1', 0);
188
+
189
+ // Session 2: ML-classified command
190
+ sessionFastTracked.set('session-2', false);
191
+
192
+ expect(sessionFastTracked.get('session-1')).toBe(true);
193
+ expect(sessionFastTracked.get('session-2')).toBe(false);
194
+ expect(sessionFastTrackLines.get('session-1')).toBe(0);
195
+ });
196
+
197
+ it('should count output lines for fast-tracked commands', () => {
198
+ const sessionFastTrackLines = new Map<string, number>();
199
+ sessionFastTrackLines.set('session-1', 0);
200
+
201
+ // Simulate PTY output chunks with newlines
202
+ function countNewlines(data: string, sessionId: string) {
203
+ const lines = sessionFastTrackLines.get(sessionId) || 0;
204
+ let newlines = 0;
205
+ for (let i = 0; i < data.length; i++) {
206
+ if (data[i] === '\n') newlines++;
207
+ }
208
+ if (newlines > 0) {
209
+ sessionFastTrackLines.set(sessionId, lines + newlines);
210
+ }
211
+ }
212
+
213
+ countNewlines('\r\n', 'session-1'); // Enter echo
214
+ countNewlines('error line 1\r\n', 'session-1');
215
+ countNewlines('error line 2\r\n', 'session-1');
216
+ countNewlines('error line 3\r\n', 'session-1');
217
+
218
+ expect(sessionFastTrackLines.get('session-1')).toBe(4);
219
+ });
220
+
221
+ it('should calculate correct erase line count', () => {
222
+ // outputLines includes the Enter-echo \n, so -1 to preserve original prompt
223
+ const outputLines = 19; // e.g., 1 (enter echo) + 18 (error output)
224
+ const linesToErase = outputLines - 1;
225
+ expect(linesToErase).toBe(18);
226
+
227
+ // Edge case: only Enter echo, no error output
228
+ const minOutputLines = 1;
229
+ const minErase = minOutputLines - 1;
230
+ expect(minErase).toBe(0);
231
+ });
232
+
233
+ it('should not erase when outputLines <= 1', () => {
234
+ // Guard condition from handleFailedFastTrack
235
+ const shouldErase = (outputLines: number) => outputLines > 1;
236
+
237
+ expect(shouldErase(0)).toBe(false);
238
+ expect(shouldErase(1)).toBe(false);
239
+ expect(shouldErase(2)).toBe(true);
240
+ expect(shouldErase(19)).toBe(true);
241
+ });
242
+
243
+ it('should generate correct escape sequence for erasure', () => {
244
+ const outputLines = 19;
245
+ const linesToErase = outputLines - 1;
246
+ const escapeSeq = `\r\x1b[${linesToErase}A\x1b[J`;
247
+
248
+ // \r moves to column 0 (fixes partial line remnant)
249
+ expect(escapeSeq).toContain('\r');
250
+ // \x1b[18A moves cursor up 18 lines
251
+ expect(escapeSeq).toContain('\x1b[18A');
252
+ // \x1b[J clears from cursor to end of screen
253
+ expect(escapeSeq).toContain('\x1b[J');
254
+ });
255
+
256
+ it('should clean up state on session close', () => {
257
+ const sessionFastTracked = new Map<string, boolean>();
258
+ const sessionFastTrackLines = new Map<string, number>();
259
+
260
+ sessionFastTracked.set('session-1', true);
261
+ sessionFastTrackLines.set('session-1', 15);
262
+
263
+ // Simulate closeSession cleanup
264
+ sessionFastTracked.delete('session-1');
265
+ sessionFastTrackLines.delete('session-1');
266
+
267
+ expect(sessionFastTracked.has('session-1')).toBe(false);
268
+ expect(sessionFastTrackLines.has('session-1')).toBe(false);
269
+ });
270
+ });
271
+
272
+ describe('edge cases from plan', () => {
273
+ // Test cases from the implementation plan's edge case table
274
+
275
+ it('clear → fast-tracked, exits 0, no retroactive check', () => {
276
+ expect(isKnownCommand('clear')).toBe(true);
277
+ // exit 0 → no retroactive ML check needed
278
+ });
279
+
280
+ it('node --help → fast-tracked, exits 0', () => {
281
+ expect(isKnownCommand('node')).toBe(true);
282
+ });
283
+
284
+ it('"list all files" → NOT fast-tracked, goes to ML', () => {
285
+ expect(isKnownCommand('list')).toBe(false);
286
+ });
287
+
288
+ it('"thisis a prompt" → NOT fast-tracked, goes to ML', () => {
289
+ expect(isKnownCommand('thisis')).toBe(false);
290
+ });
291
+ });
292
+ });