terminalos 0.4.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.
@@ -0,0 +1,241 @@
1
+ import { app, BrowserWindow, ipcMain, dialog, shell, protocol, net } from 'electron'
2
+ import path from 'path'
3
+ import { WindowState } from './window-state'
4
+ import { PtyManager } from './pty-manager'
5
+ import { FsWatcher } from './fs-watcher'
6
+ import { VersionsManager } from './versions-manager'
7
+
8
+ // Must be called before app.ready
9
+ protocol.registerSchemesAsPrivileged([
10
+ { scheme: 'localfile', privileges: { secure: true, supportFetchAPI: true, stream: true } },
11
+ ])
12
+
13
+ const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
14
+
15
+ let mainWindow: BrowserWindow | null = null
16
+ let ptyManager: PtyManager
17
+ let fsWatcher: FsWatcher
18
+ let versionsManager: VersionsManager
19
+
20
+ function semverGt(a: string, b: string): boolean {
21
+ const pa = a.split('.').map(Number)
22
+ const pb = b.split('.').map(Number)
23
+ for (let i = 0; i < 3; i++) {
24
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true
25
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false
26
+ }
27
+ return false
28
+ }
29
+
30
+ function createWindow() {
31
+ protocol.handle('localfile', (request) => {
32
+ const filePath = new URL(request.url).pathname
33
+ return net.fetch('file://' + filePath)
34
+ })
35
+
36
+ const windowState = new WindowState()
37
+ const bounds = windowState.get()
38
+
39
+ mainWindow = new BrowserWindow({
40
+ x: bounds.x,
41
+ y: bounds.y,
42
+ width: bounds.width,
43
+ height: bounds.height,
44
+ minWidth: 640,
45
+ minHeight: 400,
46
+ frame: false,
47
+ titleBarStyle: 'hidden',
48
+ titleBarOverlay: false,
49
+ trafficLightPosition: { x: 12, y: 9 },
50
+ backgroundColor: '#090909',
51
+ show: false,
52
+ webPreferences: {
53
+ preload: path.join(__dirname, 'preload.js'),
54
+ contextIsolation: true,
55
+ nodeIntegration: false,
56
+ sandbox: false,
57
+ spellcheck: false,
58
+ backgroundThrottling: false,
59
+ },
60
+ })
61
+
62
+ ptyManager = new PtyManager(mainWindow)
63
+ fsWatcher = new FsWatcher(mainWindow)
64
+ versionsManager = new VersionsManager()
65
+
66
+ if (isDev) {
67
+ mainWindow.loadURL('http://localhost:5173')
68
+ mainWindow.webContents.openDevTools()
69
+ } else {
70
+ mainWindow.loadFile(path.join(__dirname, '../build/index.html'))
71
+ }
72
+
73
+ mainWindow.once('ready-to-show', () => {
74
+ mainWindow?.show()
75
+ })
76
+
77
+ mainWindow.on('resize', () => {
78
+ windowState.save(mainWindow!)
79
+ })
80
+
81
+ mainWindow.on('move', () => {
82
+ windowState.save(mainWindow!)
83
+ })
84
+
85
+ mainWindow.on('closed', () => {
86
+ mainWindow = null
87
+ })
88
+
89
+ setupIpcHandlers()
90
+ }
91
+
92
+ function setupIpcHandlers() {
93
+ // PTY handlers
94
+ ipcMain.handle('pty:create', async (_, opts: { cwd?: string; env?: Record<string, string> }) => {
95
+ return ptyManager.create(opts)
96
+ })
97
+
98
+ ipcMain.on('pty:write', (_, sessionId: string, data: string) => {
99
+ ptyManager.write(sessionId, data)
100
+ })
101
+
102
+ ipcMain.on('pty:resize', (_, sessionId: string, cols: number, rows: number) => {
103
+ ptyManager.resize(sessionId, cols, rows)
104
+ })
105
+
106
+ ipcMain.handle('pty:kill', async (_, sessionId: string) => {
107
+ return ptyManager.kill(sessionId)
108
+ })
109
+
110
+ // FS handlers
111
+ ipcMain.handle('fs:openFolder', async () => {
112
+ const result = await dialog.showOpenDialog(mainWindow!, {
113
+ properties: ['openDirectory'],
114
+ })
115
+ if (result.canceled || result.filePaths.length === 0) return null
116
+ return result.filePaths[0]
117
+ })
118
+
119
+ ipcMain.handle('fs:readDir', async (_, dirPath: string) => {
120
+ return fsWatcher.readDir(dirPath)
121
+ })
122
+
123
+ ipcMain.handle('fs:readFile', async (_, filePath: string) => {
124
+ return fsWatcher.readFile(filePath)
125
+ })
126
+
127
+ ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => {
128
+ return fsWatcher.writeFile(filePath, content)
129
+ })
130
+
131
+ ipcMain.handle('fs:mkdir', async (_, dirPath: string) => {
132
+ return fsWatcher.mkdir(dirPath)
133
+ })
134
+
135
+ ipcMain.handle('fs:rename', async (_, src: string, dest: string) => {
136
+ return fsWatcher.rename(src, dest)
137
+ })
138
+
139
+ ipcMain.handle('fs:copyExternal', async (_, srcPath: string, destDir: string) => {
140
+ return fsWatcher.copyExternal(srcPath, destDir)
141
+ })
142
+
143
+ ipcMain.handle('fs:delete', async (_, targetPath: string) => {
144
+ return fsWatcher.delete(targetPath)
145
+ })
146
+
147
+ ipcMain.handle('fs:writeBinaryFile', async (_, filePath: string, data: ArrayBuffer) => {
148
+ const { promises: fs } = await import('fs')
149
+ await fs.writeFile(filePath, Buffer.from(data))
150
+ })
151
+
152
+ ipcMain.on('fs:setWatchRoot', (_, rootPath: string) => {
153
+ fsWatcher.setWatchRoot(rootPath)
154
+ })
155
+
156
+ // App handlers
157
+ ipcMain.handle('app:getVersion', async () => {
158
+ return app.getVersion()
159
+ })
160
+
161
+ ipcMain.handle('app:getGitBranch', async (_, cwd: string) => {
162
+ const { execSync } = await import('child_process')
163
+ try {
164
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd, encoding: 'utf8' }).trim()
165
+ return branch
166
+ } catch {
167
+ return null
168
+ }
169
+ })
170
+
171
+ ipcMain.handle('app:checkForUpdates', async () => {
172
+ try {
173
+ const res = await net.fetch('https://api.github.com/repos/vbfs/terminalOS/releases/latest', {
174
+ headers: { 'User-Agent': 'aiTerm-updater' },
175
+ })
176
+ if (!res.ok) return null
177
+ const data = await res.json() as { tag_name: string; html_url: string }
178
+ const latest = data.tag_name.replace(/^v/, '')
179
+ const current = app.getVersion()
180
+ return semverGt(latest, current) ? { version: latest, url: data.html_url } : null
181
+ } catch {
182
+ return null
183
+ }
184
+ })
185
+
186
+ // Window controls
187
+ ipcMain.on('window:minimize', () => mainWindow?.minimize())
188
+ ipcMain.on('window:maximize', () => {
189
+ if (mainWindow?.isMaximized()) {
190
+ mainWindow.unmaximize()
191
+ } else {
192
+ mainWindow?.maximize()
193
+ }
194
+ })
195
+ ipcMain.on('window:close', () => mainWindow?.close())
196
+
197
+ // Shell operations
198
+ ipcMain.on('shell:openPath', (_, filePath: string) => {
199
+ shell.showItemInFolder(filePath)
200
+ })
201
+
202
+ ipcMain.on('shell:openInFinder', (_, folderPath: string) => {
203
+ shell.openPath(folderPath)
204
+ })
205
+
206
+ ipcMain.on('shell:openExternal', (_, url: string) => {
207
+ shell.openExternal(url)
208
+ })
209
+
210
+ // Version history handlers
211
+ ipcMain.handle('fs:versions:save', async (_, filePath: string, content: string) => {
212
+ return versionsManager.saveVersion(filePath, content)
213
+ })
214
+
215
+ ipcMain.handle('fs:versions:list', async (_, filePath: string) => {
216
+ return versionsManager.listVersions(filePath)
217
+ })
218
+
219
+ ipcMain.handle('fs:versions:get', async (_, filePath: string, versionId: string) => {
220
+ return versionsManager.getVersion(filePath, versionId)
221
+ })
222
+ }
223
+
224
+ app.whenReady().then(createWindow)
225
+
226
+ app.on('before-quit', () => {
227
+ ptyManager?.killAll()
228
+ fsWatcher?.close()
229
+ })
230
+
231
+ app.on('window-all-closed', () => {
232
+ if (process.platform !== 'darwin') {
233
+ app.quit()
234
+ }
235
+ })
236
+
237
+ app.on('activate', () => {
238
+ if (BrowserWindow.getAllWindows().length === 0) {
239
+ createWindow()
240
+ }
241
+ })
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const electron_1 = require("electron");
4
+ const api = {
5
+ pty: {
6
+ create: (opts) => electron_1.ipcRenderer.invoke('pty:create', opts),
7
+ write: (sessionId, data) => electron_1.ipcRenderer.send('pty:write', sessionId, data),
8
+ resize: (sessionId, cols, rows) => electron_1.ipcRenderer.send('pty:resize', sessionId, cols, rows),
9
+ kill: (sessionId) => electron_1.ipcRenderer.invoke('pty:kill', sessionId),
10
+ onData: (cb) => {
11
+ const handler = (_, sessionId, data) => cb(sessionId, data);
12
+ electron_1.ipcRenderer.on('pty:data', handler);
13
+ return () => electron_1.ipcRenderer.removeListener('pty:data', handler);
14
+ },
15
+ onExit: (cb) => {
16
+ const handler = (_, sessionId, code) => cb(sessionId, code);
17
+ electron_1.ipcRenderer.on('pty:exit', handler);
18
+ return () => electron_1.ipcRenderer.removeListener('pty:exit', handler);
19
+ },
20
+ onAiDetected: (cb) => {
21
+ const handler = (_, sessionId, aiProcess) => cb(sessionId, aiProcess);
22
+ electron_1.ipcRenderer.on('pty:ai-detected', handler);
23
+ return () => electron_1.ipcRenderer.removeListener('pty:ai-detected', handler);
24
+ },
25
+ onAiExited: (cb) => {
26
+ const handler = (_, sessionId) => cb(sessionId);
27
+ electron_1.ipcRenderer.on('pty:ai-exited', handler);
28
+ return () => electron_1.ipcRenderer.removeListener('pty:ai-exited', handler);
29
+ },
30
+ },
31
+ fs: {
32
+ openFolder: () => electron_1.ipcRenderer.invoke('fs:openFolder'),
33
+ readDir: (dirPath) => electron_1.ipcRenderer.invoke('fs:readDir', dirPath),
34
+ readFile: (filePath) => electron_1.ipcRenderer.invoke('fs:readFile', filePath),
35
+ writeFile: (filePath, content) => electron_1.ipcRenderer.invoke('fs:writeFile', filePath, content),
36
+ writeBinaryFile: (filePath, data) => electron_1.ipcRenderer.invoke('fs:writeBinaryFile', filePath, data),
37
+ mkdir: (dirPath) => electron_1.ipcRenderer.invoke('fs:mkdir', dirPath),
38
+ rename: (src, dest) => electron_1.ipcRenderer.invoke('fs:rename', src, dest),
39
+ copyExternal: (srcPath, destDir) => electron_1.ipcRenderer.invoke('fs:copyExternal', srcPath, destDir),
40
+ delete: (targetPath) => electron_1.ipcRenderer.invoke('fs:delete', targetPath),
41
+ setWatchRoot: (rootPath) => electron_1.ipcRenderer.send('fs:setWatchRoot', rootPath),
42
+ onWatch: (cb) => {
43
+ const handler = (_, event) => cb(event);
44
+ electron_1.ipcRenderer.on('fs:watch', handler);
45
+ return () => electron_1.ipcRenderer.removeListener('fs:watch', handler);
46
+ },
47
+ versions: {
48
+ save: (filePath, content) => electron_1.ipcRenderer.invoke('fs:versions:save', filePath, content),
49
+ list: (filePath) => electron_1.ipcRenderer.invoke('fs:versions:list', filePath),
50
+ get: (filePath, versionId) => electron_1.ipcRenderer.invoke('fs:versions:get', filePath, versionId),
51
+ },
52
+ },
53
+ app: {
54
+ getVersion: () => electron_1.ipcRenderer.invoke('app:getVersion'),
55
+ getGitBranch: (cwd) => electron_1.ipcRenderer.invoke('app:getGitBranch', cwd),
56
+ checkForUpdates: () => electron_1.ipcRenderer.invoke('app:checkForUpdates'),
57
+ onFocus: (cb) => {
58
+ electron_1.ipcRenderer.on('app:focus', cb);
59
+ return () => electron_1.ipcRenderer.removeListener('app:focus', cb);
60
+ },
61
+ onBlur: (cb) => {
62
+ electron_1.ipcRenderer.on('app:blur', cb);
63
+ return () => electron_1.ipcRenderer.removeListener('app:blur', cb);
64
+ },
65
+ },
66
+ window: {
67
+ minimize: () => electron_1.ipcRenderer.send('window:minimize'),
68
+ maximize: () => electron_1.ipcRenderer.send('window:maximize'),
69
+ close: () => electron_1.ipcRenderer.send('window:close'),
70
+ },
71
+ shell: {
72
+ openPath: (filePath) => electron_1.ipcRenderer.send('shell:openPath', filePath),
73
+ openInFinder: (folderPath) => electron_1.ipcRenderer.send('shell:openInFinder', folderPath),
74
+ openExternal: (url) => electron_1.ipcRenderer.send('shell:openExternal', url),
75
+ },
76
+ };
77
+ electron_1.contextBridge.exposeInMainWorld('api', api);
@@ -0,0 +1,105 @@
1
+ import { contextBridge, ipcRenderer } from 'electron'
2
+
3
+ type Unsubscribe = () => void
4
+
5
+ const api = {
6
+ pty: {
7
+ create: (opts: { cwd?: string; env?: Record<string, string> }): Promise<string> =>
8
+ ipcRenderer.invoke('pty:create', opts),
9
+ write: (sessionId: string, data: string): void =>
10
+ ipcRenderer.send('pty:write', sessionId, data),
11
+ resize: (sessionId: string, cols: number, rows: number): void =>
12
+ ipcRenderer.send('pty:resize', sessionId, cols, rows),
13
+ kill: (sessionId: string): Promise<void> =>
14
+ ipcRenderer.invoke('pty:kill', sessionId),
15
+ onData: (cb: (sessionId: string, data: string) => void): Unsubscribe => {
16
+ const handler = (_: Electron.IpcRendererEvent, sessionId: string, data: string) =>
17
+ cb(sessionId, data)
18
+ ipcRenderer.on('pty:data', handler)
19
+ return () => ipcRenderer.removeListener('pty:data', handler)
20
+ },
21
+ onExit: (cb: (sessionId: string, code: number) => void): Unsubscribe => {
22
+ const handler = (_: Electron.IpcRendererEvent, sessionId: string, code: number) =>
23
+ cb(sessionId, code)
24
+ ipcRenderer.on('pty:exit', handler)
25
+ return () => ipcRenderer.removeListener('pty:exit', handler)
26
+ },
27
+ onAiDetected: (cb: (sessionId: string, aiProcess: { name: string; color: string }) => void): Unsubscribe => {
28
+ const handler = (_: Electron.IpcRendererEvent, sessionId: string, aiProcess: { name: string; color: string }) =>
29
+ cb(sessionId, aiProcess)
30
+ ipcRenderer.on('pty:ai-detected', handler)
31
+ return () => ipcRenderer.removeListener('pty:ai-detected', handler)
32
+ },
33
+ onAiExited: (cb: (sessionId: string) => void): Unsubscribe => {
34
+ const handler = (_: Electron.IpcRendererEvent, sessionId: string) => cb(sessionId)
35
+ ipcRenderer.on('pty:ai-exited', handler)
36
+ return () => ipcRenderer.removeListener('pty:ai-exited', handler)
37
+ },
38
+ },
39
+ fs: {
40
+ openFolder: (): Promise<string | null> =>
41
+ ipcRenderer.invoke('fs:openFolder'),
42
+ readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean; ext: string; size?: number }>> =>
43
+ ipcRenderer.invoke('fs:readDir', dirPath),
44
+ readFile: (filePath: string): Promise<string> =>
45
+ ipcRenderer.invoke('fs:readFile', filePath),
46
+ writeFile: (filePath: string, content: string): Promise<void> =>
47
+ ipcRenderer.invoke('fs:writeFile', filePath, content),
48
+ writeBinaryFile: (filePath: string, data: ArrayBuffer): Promise<void> =>
49
+ ipcRenderer.invoke('fs:writeBinaryFile', filePath, data),
50
+ mkdir: (dirPath: string): Promise<void> =>
51
+ ipcRenderer.invoke('fs:mkdir', dirPath),
52
+ rename: (src: string, dest: string): Promise<void> =>
53
+ ipcRenderer.invoke('fs:rename', src, dest),
54
+ copyExternal: (srcPath: string, destDir: string): Promise<void> =>
55
+ ipcRenderer.invoke('fs:copyExternal', srcPath, destDir),
56
+ delete: (targetPath: string): Promise<void> =>
57
+ ipcRenderer.invoke('fs:delete', targetPath),
58
+ setWatchRoot: (rootPath: string): void =>
59
+ ipcRenderer.send('fs:setWatchRoot', rootPath),
60
+ onWatch: (cb: (event: { type: string; path: string }) => void): Unsubscribe => {
61
+ const handler = (_: Electron.IpcRendererEvent, event: { type: string; path: string }) =>
62
+ cb(event)
63
+ ipcRenderer.on('fs:watch', handler)
64
+ return () => ipcRenderer.removeListener('fs:watch', handler)
65
+ },
66
+ versions: {
67
+ save: (filePath: string, content: string) =>
68
+ ipcRenderer.invoke('fs:versions:save', filePath, content),
69
+ list: (filePath: string) =>
70
+ ipcRenderer.invoke('fs:versions:list', filePath),
71
+ get: (filePath: string, versionId: string) =>
72
+ ipcRenderer.invoke('fs:versions:get', filePath, versionId),
73
+ },
74
+ },
75
+ app: {
76
+ getVersion: (): Promise<string> =>
77
+ ipcRenderer.invoke('app:getVersion'),
78
+ getGitBranch: (cwd: string): Promise<string | null> =>
79
+ ipcRenderer.invoke('app:getGitBranch', cwd),
80
+ checkForUpdates: (): Promise<{ version: string; url: string } | null> =>
81
+ ipcRenderer.invoke('app:checkForUpdates'),
82
+ onFocus: (cb: () => void): Unsubscribe => {
83
+ ipcRenderer.on('app:focus', cb)
84
+ return () => ipcRenderer.removeListener('app:focus', cb)
85
+ },
86
+ onBlur: (cb: () => void): Unsubscribe => {
87
+ ipcRenderer.on('app:blur', cb)
88
+ return () => ipcRenderer.removeListener('app:blur', cb)
89
+ },
90
+ },
91
+ window: {
92
+ minimize: (): void => ipcRenderer.send('window:minimize'),
93
+ maximize: (): void => ipcRenderer.send('window:maximize'),
94
+ close: (): void => ipcRenderer.send('window:close'),
95
+ },
96
+ shell: {
97
+ openPath: (filePath: string): void => ipcRenderer.send('shell:openPath', filePath),
98
+ openInFinder: (folderPath: string): void => ipcRenderer.send('shell:openInFinder', folderPath),
99
+ openExternal: (url: string): void => ipcRenderer.send('shell:openExternal', url),
100
+ },
101
+ }
102
+
103
+ contextBridge.exposeInMainWorld('api', api)
104
+
105
+ export type ApiType = typeof api
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProcessDetector = void 0;
4
+ const AI_SIGNATURES = [
5
+ { pattern: /claude\s+code/i, name: 'claude code', color: '#D4A27F' },
6
+ { pattern: /opencode/i, name: 'opencode', color: '#7FB5D4' },
7
+ { pattern: /aider/i, name: 'aider', color: '#A27FD4' },
8
+ { pattern: /continue/i, name: 'continue', color: '#7FD4A2' },
9
+ { pattern: /\$\s*claude\b/, name: 'claude code', color: '#D4A27F' },
10
+ ];
11
+ // Strip ANSI escape sequences before testing for shell prompt
12
+ const ANSI_RE = /\x1b\[[0-9;]*[mGKHFABCDJsu]|\x1b\][^\x07]*\x07|\x1b[()][AB012]/g;
13
+ // Prompt character must be at the start of a line (with only optional surrounding whitespace).
14
+ // This prevents false positives from `>` in code examples, `$` in cost strings, etc.
15
+ const SHELL_PROMPT_PATTERN = /(?:^|\n)\s{0,6}[$%❯>]\s{0,2}$/;
16
+ class ProcessDetector {
17
+ slidingWindow = '';
18
+ windowSize = 2048;
19
+ currentAI = null;
20
+ hasAI = false;
21
+ detectedAt = 0;
22
+ // Grace period after detection before checking for exit. Prevents AI TUI input
23
+ // prompts (e.g. opencode's "> " or "❯ ") from being misread as shell prompts.
24
+ gracePeriodMs = 3000;
25
+ detect(data) {
26
+ this.slidingWindow = (this.slidingWindow + data).slice(-this.windowSize);
27
+ if (!this.hasAI) {
28
+ for (const sig of AI_SIGNATURES) {
29
+ if (sig.pattern.test(this.slidingWindow)) {
30
+ this.currentAI = { name: sig.name, color: sig.color };
31
+ this.hasAI = true;
32
+ this.detectedAt = Date.now();
33
+ return 'detected';
34
+ }
35
+ }
36
+ }
37
+ else {
38
+ // Don't check for exit too soon after detection — AI TUI apps render their
39
+ // own input prompts ("> ", "❯ ") immediately on startup, which would otherwise
40
+ // trigger a false exit and cause a re-detect → token reset cycle.
41
+ if (Date.now() - this.detectedAt < this.gracePeriodMs)
42
+ return null;
43
+ // Check if returned to shell prompt — strip ANSI first to avoid false matches
44
+ const plain = this.slidingWindow.replace(ANSI_RE, '');
45
+ const lastLines = plain.split('\n').slice(-3).join('\n');
46
+ if (SHELL_PROMPT_PATTERN.test(lastLines)) {
47
+ this.currentAI = null;
48
+ this.hasAI = false;
49
+ return 'exited';
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ getCurrentAI() {
55
+ return this.currentAI;
56
+ }
57
+ reset() {
58
+ this.slidingWindow = '';
59
+ this.currentAI = null;
60
+ this.hasAI = false;
61
+ }
62
+ }
63
+ exports.ProcessDetector = ProcessDetector;
@@ -0,0 +1,70 @@
1
+ interface AIProcess {
2
+ name: string
3
+ color: string
4
+ }
5
+
6
+ const AI_SIGNATURES: Array<{ pattern: RegExp; name: string; color: string }> = [
7
+ { pattern: /claude\s+code/i, name: 'claude code', color: '#D4A27F' },
8
+ { pattern: /opencode/i, name: 'opencode', color: '#7FB5D4' },
9
+ { pattern: /aider/i, name: 'aider', color: '#A27FD4' },
10
+ { pattern: /continue/i, name: 'continue', color: '#7FD4A2' },
11
+ { pattern: /\$\s*claude\b/, name: 'claude code', color: '#D4A27F' },
12
+ ]
13
+
14
+ // Strip ANSI escape sequences before testing for shell prompt
15
+ const ANSI_RE = /\x1b\[[0-9;]*[mGKHFABCDJsu]|\x1b\][^\x07]*\x07|\x1b[()][AB012]/g
16
+
17
+ // Prompt character must be at the start of a line (with only optional surrounding whitespace).
18
+ // This prevents false positives from `>` in code examples, `$` in cost strings, etc.
19
+ const SHELL_PROMPT_PATTERN = /(?:^|\n)\s{0,6}[$%❯>]\s{0,2}$/
20
+
21
+ export class ProcessDetector {
22
+ private slidingWindow = ''
23
+ private readonly windowSize = 2048
24
+ private currentAI: AIProcess | null = null
25
+ private hasAI = false
26
+ private detectedAt: number = 0
27
+ // Grace period after detection before checking for exit. Prevents AI TUI input
28
+ // prompts (e.g. opencode's "> " or "❯ ") from being misread as shell prompts.
29
+ private readonly gracePeriodMs = 3000
30
+
31
+ detect(data: string): 'detected' | 'exited' | null {
32
+ this.slidingWindow = (this.slidingWindow + data).slice(-this.windowSize)
33
+
34
+ if (!this.hasAI) {
35
+ for (const sig of AI_SIGNATURES) {
36
+ if (sig.pattern.test(this.slidingWindow)) {
37
+ this.currentAI = { name: sig.name, color: sig.color }
38
+ this.hasAI = true
39
+ this.detectedAt = Date.now()
40
+ return 'detected'
41
+ }
42
+ }
43
+ } else {
44
+ // Don't check for exit too soon after detection — AI TUI apps render their
45
+ // own input prompts ("> ", "❯ ") immediately on startup, which would otherwise
46
+ // trigger a false exit and cause a re-detect → token reset cycle.
47
+ if (Date.now() - this.detectedAt < this.gracePeriodMs) return null
48
+ // Check if returned to shell prompt — strip ANSI first to avoid false matches
49
+ const plain = this.slidingWindow.replace(ANSI_RE, '')
50
+ const lastLines = plain.split('\n').slice(-3).join('\n')
51
+ if (SHELL_PROMPT_PATTERN.test(lastLines)) {
52
+ this.currentAI = null
53
+ this.hasAI = false
54
+ return 'exited'
55
+ }
56
+ }
57
+
58
+ return null
59
+ }
60
+
61
+ getCurrentAI(): AIProcess | null {
62
+ return this.currentAI
63
+ }
64
+
65
+ reset(): void {
66
+ this.slidingWindow = ''
67
+ this.currentAI = null
68
+ this.hasAI = false
69
+ }
70
+ }