@brimveyn/aimux 1.6.1 → 1.7.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 (71) 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 +43 -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 +6 -1
  9. package/src/config.ts +28 -1
  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/git/diff-hash.ts +10 -0
  14. package/src/index.tsx +10 -2
  15. package/src/input/keymap/help-entries.ts +5 -5
  16. package/src/input/modes/bridge.ts +5 -8
  17. package/src/input/modes/transitions.ts +15 -23
  18. package/src/input/modes/types.ts +3 -5
  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 +4 -1
  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/git-tree.ts +42 -16
  29. package/src/state/reducers/diff-cache.ts +64 -0
  30. package/src/state/reducers/git-mode-state.ts +91 -33
  31. package/src/state/reducers/git-panel-state.ts +33 -2
  32. package/src/state/reducers/modal-state.ts +91 -134
  33. package/src/state/reducers/session-state.ts +13 -6
  34. package/src/state/reducers/tab-state.ts +0 -10
  35. package/src/state/selectors.ts +12 -0
  36. package/src/state/session-persistence.ts +20 -15
  37. package/src/state/store.ts +13 -3
  38. package/src/state/types.ts +87 -14
  39. package/src/ui/breaking-update-screen.tsx +31 -0
  40. package/src/ui/components/bare-input.tsx +44 -0
  41. package/src/ui/components/create-session-modal.tsx +16 -8
  42. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  43. package/src/ui/components/diff-renderer/pierre-diff.tsx +9 -31
  44. package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
  45. package/src/ui/components/diff-renderer/split-view.tsx +35 -16
  46. package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
  47. package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
  48. package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
  49. package/src/ui/components/git-pane-widget.tsx +2 -0
  50. package/src/ui/components/git-panel.tsx +27 -10
  51. package/src/ui/components/git-view.tsx +23 -9
  52. package/src/ui/components/help-modal.tsx +45 -160
  53. package/src/ui/components/input-field.tsx +6 -2
  54. package/src/ui/components/list-item.tsx +36 -28
  55. package/src/ui/components/modal-shell.tsx +51 -13
  56. package/src/ui/components/new-tab-modal.tsx +78 -45
  57. package/src/ui/components/picker.tsx +179 -0
  58. package/src/ui/components/session-bar.tsx +47 -21
  59. package/src/ui/components/session-picker-modal.tsx +58 -56
  60. package/src/ui/components/sidebar.tsx +49 -22
  61. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  62. package/src/ui/components/status-bar.tsx +3 -2
  63. package/src/ui/components/surface.tsx +11 -8
  64. package/src/ui/components/tab-item.tsx +38 -22
  65. package/src/ui/components/terminal-pane.tsx +8 -8
  66. package/src/ui/components/theme-picker-modal.tsx +51 -91
  67. package/src/ui/root.tsx +14 -23
  68. package/src/ui/status-bar-model.ts +5 -5
  69. package/src/ui/theme-store.ts +26 -2
  70. package/src/ui/theme.ts +9 -1
  71. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -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
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Assistant status detection.
3
+ *
4
+ * Per-CLI content heuristics are adapted from herdr by @ogulcancelik
5
+ * (https://github.com/ogulcancelik/herdr, MIT). Rule tables trace back to
6
+ * `src/detect.rs` in that repo.
7
+ *
8
+ * The detector classifies a terminal session as `working`, `waiting-input`,
9
+ * or `idle`. Built-in CLIs (claude, codex, opencode) use per-CLI substring
10
+ * tables. Custom CLIs fall back to a generic heuristic that (a) recognises
11
+ * common shells as always-idle and (b) uses pane-tail change velocity plus
12
+ * generic y/n / confirm prompt patterns.
13
+ */
14
+ import type { AssistantId, TabActivity, TerminalSnapshot } from '../state/types'
15
+
16
+ import { getLineText } from '../input/terminal-text-extraction'
17
+
18
+ const TAIL_LINE_COUNT = 10
19
+ const ACTIVE_CHANGE_WINDOW_MS = 600
20
+
21
+ /** Spinner glyphs used by claude code's status lines. */
22
+ const CLAUDE_SPINNER_GLYPHS = '·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆'
23
+
24
+ const SHELL_COMMAND_PATTERN =
25
+ /(^|\/)(bash|zsh|fish|sh|dash|ash|ksh|tcsh|csh|nu|pwsh|powershell|elvish|xonsh)(\.exe)?$/i
26
+
27
+ interface DetectorEntry {
28
+ tail: string
29
+ changedAt: number
30
+ status: TabActivity
31
+ }
32
+
33
+ export interface DetectStatusInput {
34
+ tabId: string
35
+ assistant: AssistantId
36
+ /** The raw command string (first token matters for shell detection). */
37
+ command?: string
38
+ viewport: TerminalSnapshot | undefined
39
+ /** Override the clock for tests. Defaults to Date.now. */
40
+ now?: number
41
+ }
42
+
43
+ export class AssistantStatusDetector {
44
+ private readonly entries = new Map<string, DetectorEntry>()
45
+
46
+ classify(input: DetectStatusInput): TabActivity {
47
+ const { assistant, command, tabId, viewport } = input
48
+ const now = input.now ?? Date.now()
49
+
50
+ if (!viewport) return this.remember(tabId, '', now, 'idle')
51
+
52
+ const tail = extractTailText(viewport, TAIL_LINE_COUNT)
53
+ const prev = this.entries.get(tabId)
54
+ const changedAt = prev && prev.tail === tail ? prev.changedAt : now
55
+ const haystack = tail.toLowerCase()
56
+
57
+ if (assistant === 'terminal' || isShellCommand(command)) {
58
+ return this.remember(tabId, tail, changedAt, 'idle')
59
+ }
60
+
61
+ const perCli = classifyBuiltin(assistant, haystack, tail)
62
+ if (perCli) return this.remember(tabId, tail, changedAt, perCli)
63
+
64
+ const generic = classifyGeneric(haystack, changedAt, now)
65
+ return this.remember(tabId, tail, changedAt, generic)
66
+ }
67
+
68
+ forget(tabId: string): void {
69
+ this.entries.delete(tabId)
70
+ }
71
+
72
+ clear(): void {
73
+ this.entries.clear()
74
+ }
75
+
76
+ private remember(
77
+ tabId: string,
78
+ tail: string,
79
+ changedAt: number,
80
+ status: TabActivity
81
+ ): TabActivity {
82
+ this.entries.set(tabId, { changedAt, status, tail })
83
+ return status
84
+ }
85
+ }
86
+
87
+ function extractTailText(viewport: TerminalSnapshot, lineCount: number): string {
88
+ // Full-screen TUIs (claude, opencode) paint in the alternate buffer and
89
+ // often leave the last rows blank, putting their status bar higher up.
90
+ // Skip trailing blank rows before taking the last `lineCount`.
91
+ const lines = viewport.lines
92
+ let end = lines.length
93
+ while (end > 0) {
94
+ const line = lines[end - 1]
95
+ if (!line) {
96
+ end--
97
+ continue
98
+ }
99
+ const text = getLineText(line).trim()
100
+ if (text.length === 0) {
101
+ end--
102
+ continue
103
+ }
104
+ break
105
+ }
106
+ const start = Math.max(0, end - lineCount)
107
+ const parts: string[] = []
108
+ for (let i = start; i < end; i++) {
109
+ const line = lines[i]
110
+ if (!line) continue
111
+ parts.push(getLineText(line).replace(/\s+$/u, ''))
112
+ }
113
+ return parts.join('\n')
114
+ }
115
+
116
+ function classifyBuiltin(
117
+ assistant: AssistantId,
118
+ haystack: string,
119
+ rawTail: string
120
+ ): TabActivity | null {
121
+ switch (assistant) {
122
+ case 'claude':
123
+ return classifyClaude(haystack, rawTail)
124
+ case 'codex':
125
+ return classifyCodex(haystack)
126
+ case 'opencode':
127
+ return classifyOpencode(haystack)
128
+ default:
129
+ return null
130
+ }
131
+ }
132
+
133
+ function classifyClaude(haystack: string, rawTail: string): TabActivity {
134
+ if (
135
+ haystack.includes('do you want') ||
136
+ haystack.includes('would you like') ||
137
+ haystack.includes('tab to amend') ||
138
+ haystack.includes('enter to select') ||
139
+ (haystack.includes('esc to cancel') && haystack.includes('to navigate'))
140
+ ) {
141
+ return 'waiting-input'
142
+ }
143
+ if (
144
+ haystack.includes('esc/ctrl+c to interrupt') ||
145
+ haystack.includes('esc to interrupt') ||
146
+ haystack.includes('ctrl+c to interrupt') ||
147
+ haystack.includes('esc interrupt')
148
+ ) {
149
+ return 'working'
150
+ }
151
+ if (hasClaudeSpinner(rawTail)) return 'working'
152
+ return 'idle'
153
+ }
154
+
155
+ function hasClaudeSpinner(rawTail: string): boolean {
156
+ if (!rawTail.includes('…') && !rawTail.includes('...')) return false
157
+ for (const ch of CLAUDE_SPINNER_GLYPHS) {
158
+ if (rawTail.includes(ch)) return true
159
+ }
160
+ return false
161
+ }
162
+
163
+ function classifyCodex(haystack: string): TabActivity {
164
+ if (
165
+ haystack.includes('press enter to confirm') ||
166
+ haystack.includes('[y/n]') ||
167
+ haystack.includes('enter to submit answer')
168
+ ) {
169
+ return 'waiting-input'
170
+ }
171
+ if (haystack.includes('esc to interrupt') || haystack.includes('• working (')) {
172
+ return 'working'
173
+ }
174
+ return 'idle'
175
+ }
176
+
177
+ function classifyOpencode(haystack: string): TabActivity {
178
+ if (
179
+ haystack.includes('permission required') ||
180
+ haystack.includes('△ permission') ||
181
+ (haystack.includes('enter submit') && haystack.includes('esc dismiss'))
182
+ ) {
183
+ return 'waiting-input'
184
+ }
185
+ if (
186
+ haystack.includes('esc interrupt') ||
187
+ haystack.includes('esc to interrupt') ||
188
+ haystack.includes('esc again to interrupt')
189
+ ) {
190
+ return 'working'
191
+ }
192
+ return 'idle'
193
+ }
194
+
195
+ const GENERIC_WAITING_PATTERNS: string[] = [
196
+ '[y/n]',
197
+ '(y/n)',
198
+ 'yes/no',
199
+ 'y/n?',
200
+ 'confirm?',
201
+ 'continue?',
202
+ 'proceed?',
203
+ 'press enter to continue',
204
+ 'press any key',
205
+ 'allow?',
206
+ 'approve?',
207
+ 'do you want',
208
+ 'would you like',
209
+ 'permission required',
210
+ 'enter to select',
211
+ ]
212
+
213
+ function classifyGeneric(haystack: string, changedAt: number, now: number): TabActivity {
214
+ for (const pattern of GENERIC_WAITING_PATTERNS) {
215
+ if (haystack.includes(pattern)) return 'waiting-input'
216
+ }
217
+ if (now - changedAt < ACTIVE_CHANGE_WINDOW_MS) return 'working'
218
+ return 'idle'
219
+ }
220
+
221
+ export function isShellCommand(command: string | undefined): boolean {
222
+ if (!command) return false
223
+ const first = command.trim().split(/\s+/u)[0]
224
+ if (!first) return false
225
+ return SHELL_COMMAND_PATTERN.test(first)
226
+ }
@@ -87,13 +87,11 @@ function envInt(name: string, fallback: number): number {
87
87
  }
88
88
 
89
89
  const RENDER_COALESCE_MS = 16
90
- const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS', 32)
91
- const BURST_MAX_MS = envInt('AIMUX_RENDER_BURST_MS', 500)
90
+ const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS', 0)
92
91
 
93
92
  export class PtyManager extends EventEmitter<PtyManagerEvents> {
94
93
  private sessions = new Map<string, SessionHandle>()
95
94
  private pendingFlushes = new Map<string, ReturnType<typeof setTimeout>>()
96
- private pendingBurstCaps = new Map<string, ReturnType<typeof setTimeout>>()
97
95
 
98
96
  private clearTimers(tabId: string): void {
99
97
  const flush = this.pendingFlushes.get(tabId)
@@ -101,11 +99,6 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
101
99
  clearTimeout(flush)
102
100
  this.pendingFlushes.delete(tabId)
103
101
  }
104
- const burst = this.pendingBurstCaps.get(tabId)
105
- if (burst) {
106
- clearTimeout(burst)
107
- this.pendingBurstCaps.delete(tabId)
108
- }
109
102
  }
110
103
 
111
104
  private scheduleRender(session: SessionHandle): void {
@@ -117,20 +110,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
117
110
  if (this.sessions.get(session.tabId) !== session) {
118
111
  return
119
112
  }
120
- const burst = this.pendingBurstCaps.get(session.tabId)
121
- if (burst) {
122
- clearTimeout(burst)
123
- this.pendingBurstCaps.delete(session.tabId)
124
- }
125
113
  this.emitRenderIfChanged(session)
126
114
  }, RENDER_COALESCE_MS)
127
115
  this.pendingFlushes.set(session.tabId, timer)
128
116
  }
129
117
 
130
118
  private scheduleDataRender(session: SessionHandle): void {
131
- const existingFlush = this.pendingFlushes.get(session.tabId)
132
- if (existingFlush) {
133
- clearTimeout(existingFlush)
119
+ if (this.pendingFlushes.has(session.tabId)) {
120
+ return
134
121
  }
135
122
  const flushTimer = setTimeout(() => {
136
123
  this.pendingFlushes.delete(session.tabId)
@@ -141,30 +128,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
141
128
  this.scheduleDataRender(session)
142
129
  return
143
130
  }
144
- const burst = this.pendingBurstCaps.get(session.tabId)
145
- if (burst) {
146
- clearTimeout(burst)
147
- this.pendingBurstCaps.delete(session.tabId)
148
- }
149
131
  this.emitRenderIfChanged(session)
150
132
  }, DATA_DEBOUNCE_MS)
151
133
  this.pendingFlushes.set(session.tabId, flushTimer)
152
-
153
- if (!this.pendingBurstCaps.has(session.tabId)) {
154
- const burstTimer = setTimeout(() => {
155
- this.pendingBurstCaps.delete(session.tabId)
156
- if (this.sessions.get(session.tabId) !== session) {
157
- return
158
- }
159
- const flush = this.pendingFlushes.get(session.tabId)
160
- if (flush) {
161
- clearTimeout(flush)
162
- this.pendingFlushes.delete(session.tabId)
163
- }
164
- this.emitRenderIfChanged(session)
165
- }, BURST_MAX_MS)
166
- this.pendingBurstCaps.set(session.tabId, burstTimer)
167
- }
168
134
  }
169
135
 
170
136
  private flushRenderNow(session: SessionHandle): void {
@@ -204,7 +204,9 @@ async function restartDaemon(socketPath: string): Promise<void> {
204
204
  await spawnDaemon()
205
205
  }
206
206
 
207
- export async function createSessionBackend(): Promise<SessionBackend> {
207
+ export async function createSessionBackend(opts?: {
208
+ onBreakingUpdateRequired?: () => Promise<void>
209
+ }): Promise<SessionBackend> {
208
210
  if (process.env.AIMUX_LOCAL_BACKEND === '1') {
209
211
  logDebug('backend.create.localExplicit')
210
212
  return new LocalSessionBackend()
@@ -237,6 +239,7 @@ export async function createSessionBackend(): Promise<SessionBackend> {
237
239
  error: handshake.error ?? 'incompatible daemon handshake',
238
240
  socketPath,
239
241
  })
242
+ await opts?.onBreakingUpdateRequired?.()
240
243
  await restartDaemon(socketPath)
241
244
  const retriedHandshake = await probeDaemonProtocolCompatibility(socketPath)
242
245
  logDebug('backend.create.handshakeAfterRestart', {