@brimveyn/aimux 1.6.2 → 1.7.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 (62) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-attach-runtime.ts +6 -0
  4. package/src/app-runtime/backend-runtime-events.ts +21 -21
  5. package/src/app-runtime/side-effects.ts +24 -12
  6. package/src/app-runtime/use-backend-runtime.ts +2 -20
  7. package/src/app-runtime/use-directory-search.ts +6 -1
  8. package/src/app.tsx +2 -1
  9. package/src/config.ts +9 -0
  10. package/src/daemon/daemon.ts +197 -15
  11. package/src/daemon/session-manager.ts +9 -0
  12. package/src/daemon/session-registry.ts +5 -5
  13. package/src/index.tsx +10 -2
  14. package/src/input/keymap/help-entries.ts +5 -5
  15. package/src/input/modes/bridge.ts +5 -8
  16. package/src/input/modes/transitions.ts +15 -23
  17. package/src/input/modes/types.ts +2 -5
  18. package/src/ipc/manager-protocol.ts +2 -2
  19. package/src/ipc/protocol.ts +49 -5
  20. package/src/platform/project-search.ts +45 -12
  21. package/src/pty/assistant-status-detection-loop.ts +192 -0
  22. package/src/pty/assistant-status-detector.ts +226 -0
  23. package/src/pty/pty-manager.ts +3 -37
  24. package/src/session-backend/bootstrap.ts +25 -2
  25. package/src/session-backend/local-session-backend.ts +43 -56
  26. package/src/session-backend/remote-session-backend.ts +15 -0
  27. package/src/session-backend/types.ts +10 -1
  28. package/src/state/reducers/modal-state.ts +91 -134
  29. package/src/state/reducers/session-state.ts +13 -6
  30. package/src/state/reducers/tab-state.ts +0 -10
  31. package/src/state/selectors.ts +12 -0
  32. package/src/state/session-persistence.ts +20 -15
  33. package/src/state/store.ts +11 -3
  34. package/src/state/types.ts +29 -13
  35. package/src/ui/breaking-update-screen.tsx +31 -0
  36. package/src/ui/components/bare-input.tsx +44 -0
  37. package/src/ui/components/create-session-modal.tsx +16 -8
  38. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  39. package/src/ui/components/diff-renderer/split-view.tsx +11 -17
  40. package/src/ui/components/diff-renderer/stacked-view.tsx +7 -14
  41. package/src/ui/components/git-panel.tsx +10 -3
  42. package/src/ui/components/git-view.tsx +4 -8
  43. package/src/ui/components/help-modal.tsx +45 -160
  44. package/src/ui/components/input-field.tsx +6 -2
  45. package/src/ui/components/list-item.tsx +36 -28
  46. package/src/ui/components/modal-shell.tsx +51 -13
  47. package/src/ui/components/new-tab-modal.tsx +78 -45
  48. package/src/ui/components/picker.tsx +179 -0
  49. package/src/ui/components/session-bar.tsx +47 -21
  50. package/src/ui/components/session-picker-modal.tsx +58 -56
  51. package/src/ui/components/sidebar.tsx +49 -22
  52. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  53. package/src/ui/components/status-bar.tsx +3 -2
  54. package/src/ui/components/surface.tsx +11 -8
  55. package/src/ui/components/tab-item.tsx +38 -22
  56. package/src/ui/components/terminal-pane.tsx +8 -8
  57. package/src/ui/components/theme-picker-modal.tsx +51 -91
  58. package/src/ui/root.tsx +14 -23
  59. package/src/ui/status-bar-model.ts +5 -5
  60. package/src/ui/theme-store.ts +26 -2
  61. package/src/ui/theme.ts +9 -1
  62. package/src/ui/components/modal-filter-bar.tsx +0 -19
package/src/index.tsx CHANGED
@@ -12,6 +12,7 @@ import { runRestartDaemon } from './restart-daemon'
12
12
  import { runRestartTerminalManager } from './restart-terminal-manager'
13
13
  import { createSessionBackend } from './session-backend/bootstrap'
14
14
  import { runTerminalManager } from './terminal-manager/terminal-manager'
15
+ import { BreakingUpdateScreen } from './ui/breaking-update-screen'
15
16
  import { runUpdate } from './update'
16
17
 
17
18
  const command = process.argv[2]
@@ -64,7 +65,14 @@ const renderer = await createCliRenderer({
64
65
  useMouse: true,
65
66
  })
66
67
 
67
- const backend = await createSessionBackend()
68
+ const root = createRoot(renderer)
69
+
70
+ const backend = await createSessionBackend({
71
+ onBreakingUpdateRequired: () =>
72
+ new Promise<void>((resolve) => {
73
+ root.render(<BreakingUpdateScreen onConfirm={resolve} />)
74
+ }),
75
+ })
68
76
  logDebug('index.backendReady', { backend: backend.constructor.name, runtimeProfile })
69
77
 
70
78
  const resolvedConfig = await loadUserConfig()
@@ -73,4 +81,4 @@ logDebug('index.userConfigLoaded', {
73
81
  modeCount: resolvedConfig.keymaps.modes.size,
74
82
  })
75
83
 
76
- createRoot(renderer).render(<App backend={backend} resolvedConfig={resolvedConfig} />)
84
+ root.render(<App backend={backend} resolvedConfig={resolvedConfig} />)
@@ -12,19 +12,19 @@ export const HELP_MODE_LABELS: { modeId: ModeId; label: string }[] = [
12
12
  { label: 'Terminal input', modeId: 'terminal-input' },
13
13
  { label: 'Git mode', modeId: 'git-mode' },
14
14
  { label: 'Git commit', modeId: 'modal.git-commit' },
15
- { label: 'New tab', modeId: 'modal.new-tab' },
15
+ { label: 'New tab', modeId: 'modal.new-tab.command-edit' },
16
16
  { label: 'New tab — command', modeId: 'modal.new-tab.command-edit' },
17
- { label: 'Session picker', modeId: 'modal.session-picker' },
17
+ { label: 'Session picker', modeId: 'modal.session-picker.filtering' },
18
18
  { label: 'Session picker — filter', modeId: 'modal.session-picker.filtering' },
19
19
  { label: 'Session name', modeId: 'modal.session-name' },
20
20
  { label: 'Create session', modeId: 'modal.create-session' },
21
21
  { label: 'Rename tab', modeId: 'modal.rename-tab' },
22
- { label: 'Snippet picker', modeId: 'modal.snippet-picker' },
22
+ { label: 'Snippet picker', modeId: 'modal.snippet-picker.filtering' },
23
23
  { label: 'Snippet picker — filter', modeId: 'modal.snippet-picker.filtering' },
24
24
  { label: 'Snippet editor', modeId: 'modal.snippet-editor' },
25
- { label: 'Theme picker', modeId: 'modal.theme-picker' },
25
+ { label: 'Theme picker', modeId: 'modal.theme-picker.filtering' },
26
26
  { label: 'Split picker', modeId: 'modal.split-picker' },
27
- { label: 'Help', modeId: 'modal.help' },
27
+ { label: 'Help', modeId: 'modal.help.filtering' },
28
28
  { label: 'Help — filter', modeId: 'modal.help.filtering' },
29
29
  { label: 'Update available', modeId: 'modal.update-available' },
30
30
  ]
@@ -23,21 +23,15 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
23
23
  }
24
24
 
25
25
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
26
- 'help': 'modal.help',
27
- 'new-tab': 'modal.new-tab',
28
- 'session-picker': 'modal.session-picker',
29
- 'snippet-picker': 'modal.snippet-picker',
30
26
  'split-picker': 'modal.split-picker',
31
- 'theme-picker': 'modal.theme-picker',
32
27
  'update-available': 'modal.update-available',
33
28
  }
34
29
 
35
30
  export function deriveModeId(state: AppState): ModeId {
36
31
  // Help renders as an overlay on top of git/navigation without flipping
37
- // focusMode, so it needs modal-first dispatch. Filter sub-mode is tracked
38
- // by editBuffer presence, not focusMode.
32
+ // focusMode, so it needs modal-first dispatch. Always in filter mode.
39
33
  if (state.modal.type === 'help') {
40
- return state.modal.editBuffer !== null ? 'modal.help.filtering' : 'modal.help'
34
+ return 'modal.help.filtering'
41
35
  }
42
36
 
43
37
  const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
@@ -46,6 +40,9 @@ export function deriveModeId(state: AppState): ModeId {
46
40
  }
47
41
 
48
42
  if (state.focusMode === 'command-edit') {
43
+ if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
44
+ return 'modal.new-tab.editing-command'
45
+ }
49
46
  const modalType = state.modal.type
50
47
  const commandEditMode = modalType ? COMMAND_EDIT_MODE_IDS[modalType] : undefined
51
48
  if (commandEditMode) {
@@ -2,35 +2,27 @@ import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
4
  'git-mode': ['navigation', 'modal.git-commit'],
5
- 'modal.create-session': ['navigation', 'modal.session-picker'],
5
+ 'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
6
6
  'modal.git-commit': ['git-mode'],
7
- 'modal.help': ['navigation', 'modal.help.filtering'],
8
- 'modal.help.filtering': ['modal.help'],
9
- 'modal.new-tab': ['navigation', 'modal.new-tab.command-edit'],
10
- 'modal.new-tab.command-edit': ['modal.new-tab'],
7
+ 'modal.help.filtering': ['navigation'],
8
+ 'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
9
+ 'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
11
10
  'modal.rename-tab': ['navigation'],
12
- 'modal.session-name': ['modal.session-picker', 'navigation'],
13
- 'modal.session-picker': [
14
- 'navigation',
15
- 'modal.session-picker.filtering',
16
- 'modal.session-name',
17
- 'modal.create-session',
18
- ],
19
- 'modal.session-picker.filtering': ['modal.session-picker'],
20
- 'modal.snippet-editor': ['navigation', 'modal.snippet-picker'],
21
- 'modal.snippet-picker': ['navigation', 'modal.snippet-picker.filtering', 'modal.snippet-editor'],
22
- 'modal.snippet-picker.filtering': ['modal.snippet-picker'],
11
+ 'modal.session-name': ['modal.session-picker.filtering', 'navigation'],
12
+ 'modal.session-picker.filtering': ['navigation', 'modal.session-name', 'modal.create-session'],
13
+ 'modal.snippet-editor': ['navigation', 'modal.snippet-picker.filtering'],
14
+ 'modal.snippet-picker.filtering': ['navigation', 'modal.snippet-editor'],
23
15
  'modal.split-picker': ['navigation', 'terminal-input'],
24
- 'modal.theme-picker': ['navigation', 'modal.theme-picker.filtering'],
25
- 'modal.theme-picker.filtering': ['modal.theme-picker'],
16
+ 'modal.theme-picker.filtering': ['navigation'],
26
17
  'modal.update-available': ['navigation'],
27
18
  'navigation': [
28
19
  'terminal-input',
29
- 'modal.new-tab',
30
- 'modal.session-picker',
31
- 'modal.help',
32
- 'modal.snippet-picker',
33
- 'modal.theme-picker',
20
+ 'modal.new-tab.command-edit',
21
+ 'modal.new-tab.editing-command',
22
+ 'modal.session-picker.filtering',
23
+ 'modal.help.filtering',
24
+ 'modal.snippet-picker.filtering',
25
+ 'modal.theme-picker.filtering',
34
26
  'modal.rename-tab',
35
27
  'modal.update-available',
36
28
  'git-mode',
@@ -6,19 +6,15 @@ export type ModeId =
6
6
  | 'navigation'
7
7
  | 'terminal-input'
8
8
  | 'git-mode'
9
- | 'modal.new-tab'
10
9
  | 'modal.new-tab.command-edit'
11
- | 'modal.session-picker'
10
+ | 'modal.new-tab.editing-command'
12
11
  | 'modal.session-picker.filtering'
13
12
  | 'modal.session-name'
14
13
  | 'modal.create-session'
15
14
  | 'modal.rename-tab'
16
- | 'modal.snippet-picker'
17
15
  | 'modal.snippet-picker.filtering'
18
16
  | 'modal.snippet-editor'
19
- | 'modal.theme-picker'
20
17
  | 'modal.theme-picker.filtering'
21
- | 'modal.help'
22
18
  | 'modal.help.filtering'
23
19
  | 'modal.split-picker'
24
20
  | 'modal.git-commit'
@@ -58,6 +54,7 @@ export type SideEffect =
58
54
  | { type: 'git-push' }
59
55
  | { type: 'confirm-update-selection' }
60
56
  | { type: 'switch-session-by-index'; index: number }
57
+ | { type: 'toggle-transparent' }
61
58
 
62
59
  export interface KeyResult {
63
60
  actions: AppAction[]
@@ -14,8 +14,8 @@ import {
14
14
  negotiateProtocolVersion,
15
15
  } from './protocol'
16
16
 
17
- export const MANAGER_PROTOCOL_MIN_VERSION = 1
18
- export const MANAGER_PROTOCOL_VERSION = 1
17
+ export const MANAGER_PROTOCOL_MIN_VERSION = 2
18
+ export const MANAGER_PROTOCOL_VERSION = 2
19
19
 
20
20
  export interface ManagerHelloRequest {
21
21
  minVersion: number
@@ -1,5 +1,7 @@
1
1
  import type {
2
2
  ScrollIntent,
3
+ SessionStatus,
4
+ TabActivity,
3
5
  TabSession,
4
6
  TerminalModeState,
5
7
  TerminalSnapshot,
@@ -8,8 +10,14 @@ import type {
8
10
 
9
11
  import { isWorkspaceSnapshotV1 } from '../state/validation'
10
12
 
11
- export const IPC_PROTOCOL_MIN_VERSION = 2
12
- export const IPC_PROTOCOL_VERSION = 2
13
+ // v4 widens sessionStatus from a single status enum to independent
14
+ // {working, waiting} flags so a chip can show both at once.
15
+ // v5 folds initial tab activities and session statuses into attachResult so
16
+ // the client applies them atomically with tab creation — previously they
17
+ // arrived as separate events and could lose to the unknown-tab no-op in
18
+ // the reducer.
19
+ export const IPC_PROTOCOL_MIN_VERSION = 6
20
+ export const IPC_PROTOCOL_VERSION = 6
13
21
 
14
22
  export interface ProtocolHelloRequest {
15
23
  minVersion: number
@@ -35,6 +43,13 @@ export interface AttachResult {
35
43
  protocolVersion: number
36
44
  tabs: TabSession[]
37
45
  activeTabId: string | null
46
+ /**
47
+ * Snapshot of every known session's status at attach time. Applied by
48
+ * the client atomically with `hydrate-workspace` so chips render the
49
+ * right state immediately, without the per-event race where a separate
50
+ * `sessionStatus` event could arrive before the session was in state.
51
+ */
52
+ initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
38
53
  }
39
54
 
40
55
  export type ClientRequest =
@@ -90,6 +105,8 @@ export type ServerEvent =
90
105
  }
91
106
  | { type: 'tabExit'; payload: { tabId: string; exitCode: number } }
92
107
  | { type: 'tabError'; payload: { tabId: string; message: string } }
108
+ | { type: 'tabStatus'; payload: { sessionId: string; tabId: string; status: TabActivity } }
109
+ | { type: 'sessionStatus'; payload: { sessionId: string; status: SessionStatus } }
93
110
 
94
111
  export type IpcMessage = ClientRequest | ServerResponse | ServerEvent
95
112
 
@@ -130,6 +147,18 @@ function isStringArray(value: unknown): value is string[] {
130
147
  return Array.isArray(value) && value.every(isString)
131
148
  }
132
149
 
150
+ function isTabActivity(value: unknown): value is TabActivity {
151
+ return value === 'working' || value === 'waiting-input' || value === 'idle'
152
+ }
153
+
154
+ function isSessionStatus(value: unknown): value is SessionStatus {
155
+ return (
156
+ isObjectRecord(value) &&
157
+ typeof value.working === 'boolean' &&
158
+ typeof value.waiting === 'boolean'
159
+ )
160
+ }
161
+
133
162
  function isTerminalSpan(value: unknown): boolean {
134
163
  return (
135
164
  isObjectRecord(value) &&
@@ -209,9 +238,11 @@ function isAttachResult(value: unknown): value is AttachResult {
209
238
  (tab.status === 'starting' ||
210
239
  tab.status === 'running' ||
211
240
  tab.status === 'disconnected' ||
212
- tab.status === 'exited' ||
213
241
  tab.status === 'error') &&
214
- (tab.activity === undefined || tab.activity === 'busy' || tab.activity === 'idle') &&
242
+ (tab.activity === undefined ||
243
+ tab.activity === 'working' ||
244
+ tab.activity === 'waiting-input' ||
245
+ tab.activity === 'idle') &&
215
246
  isString(tab.buffer) &&
216
247
  isTerminalModeState(tab.terminalModes) &&
217
248
  isString(tab.command) &&
@@ -219,7 +250,11 @@ function isAttachResult(value: unknown): value is AttachResult {
219
250
  (tab.errorMessage === undefined || isString(tab.errorMessage)) &&
220
251
  (tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
221
252
  ) &&
222
- isNullableString(value.activeTabId)
253
+ isNullableString(value.activeTabId) &&
254
+ Array.isArray(value.initialSessionStatuses) &&
255
+ value.initialSessionStatuses.every(
256
+ (entry) => isObjectRecord(entry) && isString(entry.sessionId) && isSessionStatus(entry.status)
257
+ )
223
258
  )
224
259
  }
225
260
 
@@ -375,6 +410,15 @@ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent
375
410
  assert(isString(value.payload.tabId), 'tabError.tabId must be a string')
376
411
  assert(isString(value.payload.message), 'tabError.message must be a string')
377
412
  return value as ServerEvent
413
+ case 'tabStatus':
414
+ assert(isString(value.payload.sessionId), 'tabStatus.sessionId must be a string')
415
+ assert(isString(value.payload.tabId), 'tabStatus.tabId must be a string')
416
+ assert(isTabActivity(value.payload.status), 'tabStatus.status is invalid')
417
+ return value as ServerEvent
418
+ case 'sessionStatus':
419
+ assert(isString(value.payload.sessionId), 'sessionStatus.sessionId must be a string')
420
+ assert(isSessionStatus(value.payload.status), 'sessionStatus.status is invalid')
421
+ return value as ServerEvent
378
422
  default:
379
423
  throw new IpcProtocolError(`Unknown IPC response type: ${String(value.type)}`)
380
424
  }
@@ -7,15 +7,18 @@ import type { DirectoryResult } from '../state/types'
7
7
 
8
8
  import { logDebug } from '../debug/input-log'
9
9
 
10
- export async function searchProjectDirectories(query: string): Promise<DirectoryResult[]> {
11
- if (!query.trim()) {
12
- return []
13
- }
10
+ interface DirectoryCache {
11
+ repoPaths: string[]
12
+ workspaceSet: Set<string>
13
+ cachedAt: number
14
+ }
14
15
 
15
- const home = homedir()
16
+ const CACHE_TTL_MS = 60_000
17
+ let directoryCache: DirectoryCache | null = null
16
18
 
19
+ async function buildCache(): Promise<DirectoryCache> {
20
+ const home = homedir()
17
21
  try {
18
- // Step 1: Find all git repos (nothrow: find exits 1 on macOS permission errors)
19
22
  const gitResult =
20
23
  await $`find ${home} -maxdepth 4 -name .git -not -path '*/node_modules/*' -not -path '*/target/*' -not -path '*/dist/*' 2>/dev/null`
21
24
  .quiet()
@@ -27,7 +30,6 @@ export async function searchProjectDirectories(query: string): Promise<Directory
27
30
  .filter((line) => line.length > 0)
28
31
  .map((p) => p.replace(/\/\.git$/, ''))
29
32
 
30
- // Step 2: Find workspace parents (dirs with 2+ child repos that aren't repos themselves)
31
33
  const repoSet = new Set(repoPaths)
32
34
  const parentCount = new Map<string, number>()
33
35
  for (const repo of repoPaths) {
@@ -39,10 +41,37 @@ export async function searchProjectDirectories(query: string): Promise<Directory
39
41
  const workspacePaths = [...parentCount.entries()]
40
42
  .filter(([, count]) => count >= 2)
41
43
  .map(([p]) => p)
42
- const workspaceSet = new Set(workspacePaths)
43
44
 
44
- // Step 3: Combine and fuzzy filter
45
- const allPaths = [...repoPaths, ...workspacePaths].join('\n')
45
+ return { cachedAt: Date.now(), repoPaths, workspaceSet: new Set(workspacePaths) }
46
+ } catch (error) {
47
+ logDebug('platform.projectSearch.buildCache.error', {
48
+ error: error instanceof Error ? error.message : String(error),
49
+ })
50
+ return { cachedAt: Date.now(), repoPaths: [], workspaceSet: new Set() }
51
+ }
52
+ }
53
+
54
+ async function getOrBuildCache(): Promise<DirectoryCache> {
55
+ if (directoryCache && Date.now() - directoryCache.cachedAt < CACHE_TTL_MS) {
56
+ return directoryCache
57
+ }
58
+ directoryCache = await buildCache()
59
+ return directoryCache
60
+ }
61
+
62
+ export async function warmDirectoryCache(): Promise<void> {
63
+ await getOrBuildCache()
64
+ }
65
+
66
+ export async function searchProjectDirectories(query: string): Promise<DirectoryResult[]> {
67
+ if (!query.trim()) {
68
+ return []
69
+ }
70
+
71
+ try {
72
+ const cache = await getOrBuildCache()
73
+ const allPaths = [...cache.repoPaths, ...cache.workspaceSet].join('\n')
74
+
46
75
  const filtered =
47
76
  await $`printf '%s' ${allPaths} | fzf --filter=${query} --no-sort | head -50`.quiet()
48
77
  const resultPaths = filtered
@@ -51,12 +80,16 @@ export async function searchProjectDirectories(query: string): Promise<Directory
51
80
  .split('\n')
52
81
  .filter((line) => line.length > 0)
53
82
 
54
- // Step 4: Classify each result (shortest paths first, limit to 10)
55
83
  resultPaths.sort((a, b) => a.length - b.length)
56
84
  resultPaths.splice(10)
85
+
86
+ if (resultPaths.length === 0 && query.trim().startsWith('/')) {
87
+ return [{ path: query.trim(), type: 'git-repo' }]
88
+ }
89
+
57
90
  return Promise.all(
58
91
  resultPaths.map(async (path) => {
59
- if (workspaceSet.has(path)) {
92
+ if (cache.workspaceSet.has(path)) {
60
93
  return { path, type: 'workspace' as const }
61
94
  }
62
95
  const gitPath = join(path, '.git')
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Continuous status detection loop.
3
+ *
4
+ * Backends drive this loop to monitor every known terminal at a fixed cadence:
5
+ * every tick we pull each tab's current viewport, run the detector on the last
6
+ * ~10 non-blank lines, and report per-tab + per-session statuses to the
7
+ * backend's transport. The loop is the single source of truth so that idle
8
+ * tabs can't erase a sibling's waiting-input, and so that clients receive
9
+ * status for every session the backend owns, not just the attached one.
10
+ *
11
+ * Per-session status is a pair of independent booleans:
12
+ * - `working`: at least one tab is working.
13
+ * - `waiting`: at least one tab is waiting for user input.
14
+ * Both can be true at the same time — the chip renders one glyph per flag.
15
+ */
16
+ import type { AssistantId, SessionStatus, TabActivity, TerminalSnapshot } from '../state/types'
17
+
18
+ import { logDebug } from '../debug/input-log'
19
+ import { getLineText } from '../input/terminal-text-extraction'
20
+ import { AssistantStatusDetector } from './assistant-status-detector'
21
+
22
+ function tailPreview(viewport: TerminalSnapshot | undefined): string {
23
+ if (!viewport) return '<no-viewport>'
24
+ const lines = viewport.lines
25
+ for (let i = lines.length - 1; i >= 0; i--) {
26
+ const line = lines[i]
27
+ if (!line) continue
28
+ const text = getLineText(line).trim()
29
+ if (text.length > 0) return text.slice(0, 80)
30
+ }
31
+ return '<blank>'
32
+ }
33
+
34
+ /** Default polling interval. Cheap — detector is a handful of substring checks. */
35
+ const DEFAULT_TICK_MS = 500
36
+
37
+ export interface LoopTabView {
38
+ id: string
39
+ assistant: AssistantId
40
+ command: string
41
+ viewport?: TerminalSnapshot
42
+ }
43
+
44
+ export interface StatusDetectionLoopOptions {
45
+ listSessions: () => string[]
46
+ listTabs: (sessionId: string) => LoopTabView[]
47
+ /** Emitted when a tab's status changes. */
48
+ onTabStatus: (tabId: string, status: TabActivity, sessionId: string) => void
49
+ /** Emitted when either flag on a session changes. */
50
+ onSessionStatus: (sessionId: string, status: SessionStatus) => void
51
+ tickMs?: number
52
+ }
53
+
54
+ export interface StatusDetectionLoopHandle {
55
+ stop: () => void
56
+ /** Last classified status for a tab, for on-attach replay. */
57
+ getTabStatus: (tabId: string) => TabActivity | undefined
58
+ /** Last session flags, for on-attach replay. */
59
+ getSessionStatus: (sessionId: string) => SessionStatus | undefined
60
+ /** Snapshot of every known session's flags. */
61
+ snapshotSessions: () => Array<{ sessionId: string; status: SessionStatus }>
62
+ /** Snapshot of every known tab's status plus its session. */
63
+ snapshotTabs: () => Array<{ tabId: string; sessionId: string; status: TabActivity }>
64
+ /**
65
+ * Synchronously run classification for a single session. Used on client
66
+ * attach so the replay snapshot is populated *before* the client reads it,
67
+ * rather than relying on the next scheduled tick.
68
+ */
69
+ classifyNow: (sessionId: string, tabs: LoopTabView[]) => void
70
+ }
71
+
72
+ export function runStatusDetectionLoop(
73
+ options: StatusDetectionLoopOptions
74
+ ): StatusDetectionLoopHandle {
75
+ const tickMs = options.tickMs ?? DEFAULT_TICK_MS
76
+ const detector = new AssistantStatusDetector()
77
+ const lastTabStatus = new Map<string, { status: TabActivity; sessionId: string }>()
78
+ const lastSessionStatus = new Map<string, SessionStatus>()
79
+
80
+ const timer = setInterval(() => {
81
+ try {
82
+ tick()
83
+ } catch (error) {
84
+ logDebug('statusLoop.tickError', {
85
+ error: error instanceof Error ? error.message : String(error),
86
+ })
87
+ }
88
+ }, tickMs)
89
+ timer.unref?.()
90
+
91
+ function classifySession(
92
+ sessionId: string,
93
+ tabs: LoopTabView[],
94
+ now: number,
95
+ source: 'tick' | 'classifyNow',
96
+ seenTabs?: Set<string>
97
+ ): void {
98
+ let working = false
99
+ let waiting = false
100
+ for (const tab of tabs) {
101
+ seenTabs?.add(tab.id)
102
+ const status = detector.classify({
103
+ assistant: tab.assistant,
104
+ command: tab.command,
105
+ now,
106
+ tabId: tab.id,
107
+ viewport: tab.viewport,
108
+ })
109
+ if (status === 'working') working = true
110
+ if (status === 'waiting-input') waiting = true
111
+ const prev = lastTabStatus.get(tab.id)
112
+ const changed = !prev || prev.status !== status || prev.sessionId !== sessionId
113
+ logDebug('statusLoop.classify', {
114
+ assistant: tab.assistant,
115
+ changed,
116
+ prevSessionId: prev?.sessionId,
117
+ prevStatus: prev?.status,
118
+ sessionId,
119
+ source,
120
+ status,
121
+ tabId: tab.id,
122
+ tailPreview: tailPreview(tab.viewport),
123
+ })
124
+ if (changed) {
125
+ lastTabStatus.set(tab.id, { sessionId, status })
126
+ options.onTabStatus(tab.id, status, sessionId)
127
+ }
128
+ }
129
+ const next: SessionStatus = { waiting, working }
130
+ const prevSession = lastSessionStatus.get(sessionId)
131
+ const sessionChanged =
132
+ !prevSession || prevSession.working !== working || prevSession.waiting !== waiting
133
+ logDebug('statusLoop.classifySession', {
134
+ prev: prevSession,
135
+ sessionChanged,
136
+ sessionId,
137
+ source,
138
+ status: next,
139
+ tabCount: tabs.length,
140
+ })
141
+ if (sessionChanged) {
142
+ lastSessionStatus.set(sessionId, next)
143
+ options.onSessionStatus(sessionId, next)
144
+ }
145
+ }
146
+
147
+ function tick(): void {
148
+ const now = Date.now()
149
+ const sessionIds = options.listSessions()
150
+ const seenSessions = new Set<string>()
151
+ const seenTabs = new Set<string>()
152
+
153
+ for (const sessionId of sessionIds) {
154
+ seenSessions.add(sessionId)
155
+ classifySession(sessionId, options.listTabs(sessionId), now, 'tick', seenTabs)
156
+ }
157
+
158
+ for (const tabId of lastTabStatus.keys()) {
159
+ if (!seenTabs.has(tabId)) {
160
+ detector.forget(tabId)
161
+ lastTabStatus.delete(tabId)
162
+ }
163
+ }
164
+ for (const sessionId of lastSessionStatus.keys()) {
165
+ if (!seenSessions.has(sessionId)) {
166
+ lastSessionStatus.delete(sessionId)
167
+ }
168
+ }
169
+ }
170
+
171
+ return {
172
+ classifyNow: (sessionId, tabs) => {
173
+ classifySession(sessionId, tabs, Date.now(), 'classifyNow')
174
+ },
175
+ getSessionStatus: (sessionId) => lastSessionStatus.get(sessionId),
176
+ getTabStatus: (tabId) => lastTabStatus.get(tabId)?.status,
177
+ snapshotSessions: () =>
178
+ [...lastSessionStatus.entries()].map(([sessionId, status]) => ({ sessionId, status })),
179
+ snapshotTabs: () =>
180
+ [...lastTabStatus.entries()].map(([tabId, entry]) => ({
181
+ sessionId: entry.sessionId,
182
+ status: entry.status,
183
+ tabId,
184
+ })),
185
+ stop: () => {
186
+ clearInterval(timer)
187
+ detector.clear()
188
+ lastTabStatus.clear()
189
+ lastSessionStatus.clear()
190
+ },
191
+ }
192
+ }