@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.
- package/README.md +3 -0
- package/package.json +2 -2
- package/src/app-runtime/backend-attach-runtime.ts +6 -0
- package/src/app-runtime/backend-runtime-events.ts +21 -21
- package/src/app-runtime/side-effects.ts +43 -12
- package/src/app-runtime/use-backend-runtime.ts +2 -20
- package/src/app-runtime/use-directory-search.ts +6 -1
- package/src/app.tsx +6 -1
- package/src/config.ts +28 -1
- package/src/daemon/daemon.ts +197 -15
- package/src/daemon/session-manager.ts +9 -0
- package/src/daemon/session-registry.ts +5 -5
- package/src/git/diff-hash.ts +10 -0
- package/src/index.tsx +10 -2
- package/src/input/keymap/help-entries.ts +5 -5
- package/src/input/modes/bridge.ts +5 -8
- package/src/input/modes/transitions.ts +15 -23
- package/src/input/modes/types.ts +3 -5
- package/src/ipc/protocol.ts +49 -5
- package/src/platform/project-search.ts +45 -12
- package/src/pty/assistant-status-detection-loop.ts +192 -0
- package/src/pty/assistant-status-detector.ts +226 -0
- package/src/pty/pty-manager.ts +3 -37
- package/src/session-backend/bootstrap.ts +4 -1
- package/src/session-backend/local-session-backend.ts +43 -56
- package/src/session-backend/remote-session-backend.ts +15 -0
- package/src/session-backend/types.ts +10 -1
- package/src/state/git-tree.ts +42 -16
- package/src/state/reducers/diff-cache.ts +64 -0
- package/src/state/reducers/git-mode-state.ts +91 -33
- package/src/state/reducers/git-panel-state.ts +33 -2
- package/src/state/reducers/modal-state.ts +91 -134
- package/src/state/reducers/session-state.ts +13 -6
- package/src/state/reducers/tab-state.ts +0 -10
- package/src/state/selectors.ts +12 -0
- package/src/state/session-persistence.ts +20 -15
- package/src/state/store.ts +13 -3
- package/src/state/types.ts +87 -14
- package/src/ui/breaking-update-screen.tsx +31 -0
- package/src/ui/components/bare-input.tsx +44 -0
- package/src/ui/components/create-session-modal.tsx +16 -8
- package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
- package/src/ui/components/diff-renderer/pierre-diff.tsx +9 -31
- package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
- package/src/ui/components/diff-renderer/split-view.tsx +35 -16
- package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
- package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
- package/src/ui/components/git-pane-widget.tsx +2 -0
- package/src/ui/components/git-panel.tsx +27 -10
- package/src/ui/components/git-view.tsx +23 -9
- package/src/ui/components/help-modal.tsx +45 -160
- package/src/ui/components/input-field.tsx +6 -2
- package/src/ui/components/list-item.tsx +36 -28
- package/src/ui/components/modal-shell.tsx +51 -13
- package/src/ui/components/new-tab-modal.tsx +78 -45
- package/src/ui/components/picker.tsx +179 -0
- package/src/ui/components/session-bar.tsx +47 -21
- package/src/ui/components/session-picker-modal.tsx +58 -56
- package/src/ui/components/sidebar.tsx +49 -22
- package/src/ui/components/snippet-picker-modal.tsx +43 -34
- package/src/ui/components/status-bar.tsx +3 -2
- package/src/ui/components/surface.tsx +11 -8
- package/src/ui/components/tab-item.tsx +38 -22
- package/src/ui/components/terminal-pane.tsx +8 -8
- package/src/ui/components/theme-picker-modal.tsx +51 -91
- package/src/ui/root.tsx +14 -23
- package/src/ui/status-bar-model.ts +5 -5
- package/src/ui/theme-store.ts +26 -2
- package/src/ui/theme.ts +9 -1
- 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
interface DirectoryCache {
|
|
11
|
+
repoPaths: string[]
|
|
12
|
+
workspaceSet: Set<string>
|
|
13
|
+
cachedAt: number
|
|
14
|
+
}
|
|
14
15
|
|
|
15
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
+
}
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
132
|
-
|
|
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(
|
|
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', {
|