@brimveyn/aimux 1.21.0 → 1.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -25
- package/package.json +2 -2
- package/src/app-runtime/auto-commit-driver.ts +22 -27
- package/src/app-runtime/backend-attach-runtime.ts +43 -36
- package/src/app-runtime/backend-runtime-events.ts +107 -88
- package/src/app-runtime/editor-actions.ts +201 -0
- package/src/app-runtime/git-actions.ts +197 -0
- package/src/app-runtime/measure-ref.ts +19 -0
- package/src/app-runtime/navigation-actions.ts +213 -0
- package/src/app-runtime/project-actions.ts +188 -0
- package/src/app-runtime/prompt-injection.ts +123 -0
- package/src/app-runtime/selection.ts +29 -0
- package/src/app-runtime/settings-actions.ts +156 -0
- package/src/app-runtime/setup-actions.ts +237 -0
- package/src/app-runtime/side-effect-context.ts +33 -0
- package/src/app-runtime/side-effects.ts +302 -1541
- package/src/app-runtime/snippet-actions.ts +3 -2
- package/src/app-runtime/tab-actions.ts +218 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
- package/src/app-runtime/use-auto-commit-driver.ts +14 -13
- package/src/app-runtime/use-backend-runtime.ts +12 -11
- package/src/app-runtime/use-directory-search.ts +5 -4
- package/src/app-runtime/use-mouse-handlers.ts +2 -1
- package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
- package/src/app-runtime/use-renderer-bindings.ts +2 -1
- package/src/app-runtime/use-setup-runner.ts +96 -0
- package/src/app-runtime/use-terminal-resize.ts +18 -14
- package/src/app-runtime/workspace-actions.ts +366 -0
- package/src/app-runtime/workspace-activity.ts +138 -0
- package/src/app-runtime/workspace-naming.ts +90 -0
- package/src/app.tsx +93 -48
- package/src/assets/sounds/bell.wav +0 -0
- package/src/assets/sounds/ding.wav +0 -0
- package/src/assets/sounds/plane.wav +0 -0
- package/src/cli/client/daemon-client.ts +2 -2
- package/src/cli/client/project-resolver.ts +112 -0
- package/src/cli/commands/project/close.ts +32 -0
- package/src/cli/commands/project/create.ts +93 -0
- package/src/cli/commands/project/list.ts +25 -0
- package/src/cli/commands/project/show.ts +32 -0
- package/src/cli/commands/{workspace → project}/switch.ts +19 -19
- package/src/cli/commands/tab/await.ts +2 -2
- package/src/cli/commands/tab/close.ts +3 -3
- package/src/cli/commands/tab/create.ts +74 -73
- package/src/cli/commands/tab/focus.ts +3 -3
- package/src/cli/commands/tab/list.ts +6 -6
- package/src/cli/commands/tab/run.ts +2 -2
- package/src/cli/commands/tab/send.ts +2 -2
- package/src/cli/commands/tab/snapshot.ts +2 -2
- package/src/cli/commands/tab/tail.ts +2 -2
- package/src/cli/commands/tab/wait.ts +2 -2
- package/src/cli/commands/worker/await.ts +3 -3
- package/src/cli/commands/worker/doctor.ts +30 -30
- package/src/cli/commands/worker/list.ts +15 -15
- package/src/cli/commands/worker/prompt.ts +3 -3
- package/src/cli/commands/worker/run.ts +23 -23
- package/src/cli/commands/worker/shared.ts +53 -59
- package/src/cli/commands/worker/stop.ts +29 -29
- package/src/cli/commands/worker/submit.ts +3 -3
- package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
- package/src/cli/commands/workspace/create.ts +30 -68
- package/src/cli/commands/workspace/list.ts +52 -9
- package/src/cli/commands/workspace/remove.ts +81 -0
- package/src/cli/completion/plan.ts +2 -2
- package/src/cli/completion/sources.ts +18 -18
- package/src/cli/context.ts +13 -13
- package/src/cli/flags.ts +30 -10
- package/src/cli/index.ts +26 -16
- package/src/cli/output.ts +1 -1
- package/src/cli/registry.ts +12 -12
- package/src/config/loader.ts +16 -5
- package/src/config.ts +70 -112
- package/src/daemon/catalog-writer.ts +67 -67
- package/src/daemon/daemon.ts +205 -201
- package/src/daemon/session-manager.ts +39 -39
- package/src/daemon/session-registry.ts +14 -14
- package/src/git/divergence.ts +33 -2
- package/src/git/git-poller.ts +1 -1
- package/src/git/git-status.ts +2 -2
- package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
- package/src/git/pr-status.ts +1 -1
- package/src/git/repo-discovery.ts +1 -1
- package/src/git/use-repo-discovery.ts +1 -1
- package/src/git/workspace-branch-poller.ts +54 -0
- package/src/git/workspace-divergence-poller.ts +65 -0
- package/src/git/worktree.ts +29 -0
- package/src/index.tsx +4 -4
- package/src/input/keymap/help-entries.ts +7 -5
- package/src/input/modes/bridge.ts +13 -12
- package/src/input/modes/handlers/shared.ts +1 -1
- package/src/input/modes/transitions.ts +34 -21
- package/src/input/modes/types.ts +48 -31
- package/src/ipc/manager-protocol.ts +44 -44
- package/src/ipc/protocol.ts +192 -166
- package/src/platform/play-sound.ts +184 -0
- package/src/platform/project-search.ts +8 -8
- package/src/platform/worktree-paths.ts +8 -6
- package/src/pty/assistant-question-extractor.ts +1 -1
- package/src/pty/assistant-status-detection-loop.ts +79 -55
- package/src/pty/assistant-status-detector.ts +40 -15
- package/src/pty/command-registry.ts +81 -1
- package/src/restart-terminal-manager.ts +1 -1
- package/src/services/ai-usage/provider.ts +9 -2
- package/src/services/ai-usage/spawn.ts +19 -6
- package/src/session-backend/bootstrap.ts +4 -4
- package/src/session-backend/local-session-backend.ts +78 -75
- package/src/session-backend/remote-session-backend.ts +64 -52
- package/src/session-backend/types.ts +43 -32
- package/src/settings/live.ts +90 -0
- package/src/settings/search.ts +43 -0
- package/src/settings/sections/about.ts +49 -0
- package/src/settings/sections/appearance.ts +54 -0
- package/src/settings/sections/automation.ts +105 -0
- package/src/settings/sections/commands.ts +72 -0
- package/src/settings/sections/editor.ts +56 -0
- package/src/settings/sections/experimental.ts +57 -0
- package/src/settings/sections/git.ts +112 -0
- package/src/settings/sections/index.ts +95 -0
- package/src/settings/sections/integrations.ts +23 -0
- package/src/settings/sections/layout.ts +72 -0
- package/src/settings/sections/notifications.ts +99 -0
- package/src/settings/sections/setup.ts +54 -0
- package/src/settings/sections/status-bar.ts +63 -0
- package/src/settings/settings-store.ts +207 -0
- package/src/settings/types.ts +108 -0
- package/src/snippets/run-shell-var.ts +48 -22
- package/src/state/actions.ts +342 -0
- package/src/state/app-store.ts +2 -1
- package/src/state/bars.ts +1 -1
- package/src/state/dispatch-ref.ts +1 -1
- package/src/state/layout-resize.ts +2 -2
- package/src/state/project-catalog.ts +275 -0
- package/src/state/project-data.ts +143 -0
- package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
- package/src/state/project-save.ts +46 -0
- package/src/state/project-workspaces.ts +392 -0
- package/src/state/reducers/auto-commit-state.ts +11 -10
- package/src/state/reducers/git-commit-modal-state.ts +122 -0
- package/src/state/reducers/git-mode-state.ts +1 -1
- package/src/state/reducers/git-panel-state.ts +10 -6
- package/src/state/reducers/modal-state.ts +290 -681
- package/src/state/reducers/multi-repo-state.ts +3 -6
- package/src/state/reducers/project-state.ts +312 -0
- package/src/state/reducers/settings-state.ts +80 -0
- package/src/state/reducers/tab-state.ts +107 -60
- package/src/state/reducers/ui-state.ts +4 -3
- package/src/state/selectors.ts +49 -37
- package/src/state/store.ts +57 -24
- package/src/state/types.ts +244 -405
- package/src/state/validation.ts +14 -12
- package/src/terminal-manager/manager-client.ts +32 -32
- package/src/terminal-manager/terminal-manager.ts +24 -24
- package/src/ui/components/git/git-panel.tsx +2 -2
- package/src/ui/components/git/git-view.tsx +12 -12
- package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
- package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
- package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
- package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
- package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
- package/src/ui/components/layout/status-bar.tsx +6 -2
- package/src/ui/components/layout/terminal-pane.tsx +34 -29
- package/src/ui/components/layout/top-tab-bar.tsx +46 -36
- package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
- package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
- package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
- package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
- package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
- package/src/ui/components/modals/shared/form.tsx +34 -22
- package/src/ui/components/modals/shared/picker.tsx +10 -10
- package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
- package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
- package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
- package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
- package/src/ui/components/primitives/input-field.tsx +7 -3
- package/src/ui/components/primitives/surface.tsx +3 -0
- package/src/ui/components/settings/row-value.tsx +74 -0
- package/src/ui/components/settings/settings-row.tsx +78 -0
- package/src/ui/components/settings/settings-view.tsx +169 -0
- package/src/ui/components/setup/setup-widget.tsx +152 -0
- package/src/ui/flash/build-labels.ts +27 -27
- package/src/ui/hooks/use-activity-sprite.ts +73 -0
- package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
- package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
- package/src/ui/root.tsx +139 -121
- package/src/ui/status-bar-model.ts +48 -34
- package/src/ui/terminal-graphics/kitty.ts +28 -2
- package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
- package/src/ui/terminal-graphics/sprites.ts +246 -0
- package/src/ui/truncate.ts +7 -0
- package/src/ui/widgets/registry.tsx +6 -3
- package/src/ui/widgets/widget-context-menu.ts +26 -10
- package/src/update.ts +2 -2
- package/src/app-runtime/session-actions.ts +0 -187
- package/src/cli/client/workspace-resolver.ts +0 -110
- package/src/cli/commands/workspace/close.ts +0 -32
- package/src/cli/commands/workspace/show.ts +0 -32
- package/src/cli/commands/worktree/create.ts +0 -55
- package/src/cli/commands/worktree/list.ts +0 -68
- package/src/cli/commands/worktree/remove.ts +0 -81
- package/src/git/worktree-branch-poller.ts +0 -54
- package/src/git/worktree-divergence-poller.ts +0 -59
- package/src/state/reducers/session-state.ts +0 -262
- package/src/state/session-catalog.ts +0 -143
- package/src/state/session-worktrees.ts +0 -260
- package/src/state/workspace-save.ts +0 -43
- package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
- package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
- package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
- /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Notification sounds: the shipped three, plus whatever the user drops in
|
|
2
|
+
// their profile's `sounds/` directory. Shaped like `open-url.ts` — a pure
|
|
3
|
+
// command builder the tests can read, and one impure function that spawns it.
|
|
4
|
+
//
|
|
5
|
+
// Everything here fails quietly. A missing player or an unreadable file is a
|
|
6
|
+
// silent notification, never a toast per event.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
9
|
+
import { dirname, extname, join, resolve } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
import { logDebug } from '../debug/input-log'
|
|
13
|
+
import { getProfileConfigDir } from '../profile-paths'
|
|
14
|
+
|
|
15
|
+
/** The value `notifications.sound` holds when the user wants silence. */
|
|
16
|
+
export const SOUND_OFF = 'off'
|
|
17
|
+
|
|
18
|
+
/** Shipped sounds, in the order the settings row offers them. */
|
|
19
|
+
export const BUILTIN_SOUND_IDS = ['plane', 'bell', 'ding'] as const
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extensions offered from the drop-in directory. `afplay` and `ffplay` handle
|
|
23
|
+
* all of them; `paplay`/`aplay` only really handle wav — which is why the
|
|
24
|
+
* shipped three are wav.
|
|
25
|
+
*/
|
|
26
|
+
const SOUND_EXTENSIONS = new Set(['.wav', '.m4a', '.mp3', '.aiff', '.aif', '.ogg', '.flac'])
|
|
27
|
+
|
|
28
|
+
/** No more than one sound per window, however many tabs finish at once. */
|
|
29
|
+
const THROTTLE_MS = 700
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Percent of the file's own level. These are notifications for someone sitting
|
|
33
|
+
* in front of the terminal, not an alarm across the room, and the shipped three
|
|
34
|
+
* are loud at their own scale — hence a default well under full.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_VOLUME = 35
|
|
37
|
+
|
|
38
|
+
/** Where a user drops their own sounds. Named in the settings row. */
|
|
39
|
+
export function getCustomSoundsDir(): string {
|
|
40
|
+
return join(getProfileConfigDir(), 'sounds')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Absolute path to a shipped sound. Resolved from this module's URL, the same
|
|
45
|
+
* walk-up `resolveHookScriptPath` does, so it works wherever aimux is installed.
|
|
46
|
+
*/
|
|
47
|
+
function builtinSoundPath(id: string): string | null {
|
|
48
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
49
|
+
const candidates = [
|
|
50
|
+
resolve(here, '..', 'assets', 'sounds', `${id}.wav`),
|
|
51
|
+
resolve(here, '..', '..', 'assets', 'sounds', `${id}.wav`),
|
|
52
|
+
]
|
|
53
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ids of the files the user has dropped in, without their extension. Deduped:
|
|
58
|
+
* `bell-2.wav` and `bell-2.mp3` are one option, and `resolveSoundPath` picks
|
|
59
|
+
* between them the same way every time.
|
|
60
|
+
*/
|
|
61
|
+
export function listCustomSoundIds(): string[] {
|
|
62
|
+
try {
|
|
63
|
+
const ids = readdirSync(getCustomSoundsDir())
|
|
64
|
+
.filter((name) => SOUND_EXTENSIONS.has(extname(name).toLowerCase()))
|
|
65
|
+
.map((name) => name.slice(0, -extname(name).length))
|
|
66
|
+
.filter(
|
|
67
|
+
(id) => id !== '' && !BUILTIN_SOUND_IDS.includes(id as (typeof BUILTIN_SOUND_IDS)[number])
|
|
68
|
+
)
|
|
69
|
+
return [...new Set(ids)].sort()
|
|
70
|
+
} catch {
|
|
71
|
+
// No directory is the common case, not an error.
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The file behind a sound id, shipped or dropped in, or null when there is none. */
|
|
77
|
+
export function resolveSoundPath(id: string): string | null {
|
|
78
|
+
if (id === '' || id === SOUND_OFF) return null
|
|
79
|
+
if (BUILTIN_SOUND_IDS.includes(id as (typeof BUILTIN_SOUND_IDS)[number])) {
|
|
80
|
+
return builtinSoundPath(id)
|
|
81
|
+
}
|
|
82
|
+
const dir = getCustomSoundsDir()
|
|
83
|
+
for (const extension of SOUND_EXTENSIONS) {
|
|
84
|
+
const candidate = join(dir, `${id}${extension}`)
|
|
85
|
+
if (existsSync(candidate)) return candidate
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How to invoke one player on one file, at `volume` percent. `bin` is whichever
|
|
92
|
+
* player was found; the caller does the looking, so this stays pure.
|
|
93
|
+
*
|
|
94
|
+
* Every player spells volume differently, and two of them cannot say it at all
|
|
95
|
+
* — `aplay` and PowerShell's `SoundPlayer` play at the system level whatever we
|
|
96
|
+
* ask, which the setting's description admits rather than pretending otherwise.
|
|
97
|
+
*/
|
|
98
|
+
export function soundPlayerArgv(
|
|
99
|
+
platform: string,
|
|
100
|
+
bin: string,
|
|
101
|
+
path: string,
|
|
102
|
+
volume: number
|
|
103
|
+
): string[] {
|
|
104
|
+
const percent = Math.min(100, Math.max(0, Math.round(volume)))
|
|
105
|
+
if (platform === 'win32') {
|
|
106
|
+
// The path is a filename the user chose. PowerShell escapes a quote inside
|
|
107
|
+
// a single-quoted string by doubling it; without this a name containing one
|
|
108
|
+
// ends the string and the rest of it is executed.
|
|
109
|
+
const quoted = path.replaceAll("'", "''")
|
|
110
|
+
return [
|
|
111
|
+
bin,
|
|
112
|
+
'-NoProfile',
|
|
113
|
+
'-NonInteractive',
|
|
114
|
+
'-Command',
|
|
115
|
+
`(New-Object Media.SoundPlayer '${quoted}').PlaySync()`,
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
if (bin.endsWith('afplay')) return [bin, '-v', String(percent / 100), path]
|
|
119
|
+
if (bin.endsWith('paplay')) return [bin, `--volume=${Math.round((percent / 100) * 65_536)}`, path]
|
|
120
|
+
if (bin.endsWith('ffplay')) {
|
|
121
|
+
return [bin, '-nodisp', '-autoexit', '-loglevel', 'quiet', '-volume', String(percent), path]
|
|
122
|
+
}
|
|
123
|
+
if (bin.endsWith('mpv')) return [bin, '--no-video', '--really-quiet', `--volume=${percent}`, path]
|
|
124
|
+
return [bin, path]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Players in preference order. macOS ships afplay; on Linux nothing is
|
|
129
|
+
* guaranteed, so we take the first one installed. `Bun.which` is the lookup —
|
|
130
|
+
* spawning a missing binary would throw once per notification.
|
|
131
|
+
*/
|
|
132
|
+
function findPlayer(platform: string): string | null {
|
|
133
|
+
let candidates = ['paplay', 'aplay', 'ffplay', 'mpv']
|
|
134
|
+
if (platform === 'darwin') candidates = ['afplay']
|
|
135
|
+
if (platform === 'win32') candidates = ['powershell.exe', 'powershell']
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
const found = Bun.which(candidate)
|
|
138
|
+
if (found != null && found !== '') return found
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let lastPlayedAt = 0
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether enough time has passed since the last sound. Separate from the play
|
|
147
|
+
* itself so the window is testable without a clock.
|
|
148
|
+
*/
|
|
149
|
+
export function shouldPlayNow(now: number, previous: number): boolean {
|
|
150
|
+
return now - previous >= THROTTLE_MS
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Play a sound file. Returns whether a player was actually spawned, which is
|
|
155
|
+
* what the settings screen's "Test sound" row reports.
|
|
156
|
+
*/
|
|
157
|
+
export function playSoundFile(
|
|
158
|
+
path: string,
|
|
159
|
+
options?: { ignoreThrottle?: boolean; volume?: number }
|
|
160
|
+
): boolean {
|
|
161
|
+
const now = Date.now()
|
|
162
|
+
const throttled = options?.ignoreThrottle !== true
|
|
163
|
+
if (throttled && !shouldPlayNow(now, lastPlayedAt)) return false
|
|
164
|
+
const platform = process.platform
|
|
165
|
+
const bin = findPlayer(platform)
|
|
166
|
+
if (bin == null) {
|
|
167
|
+
logDebug('platform.playSound.noPlayer', { platform })
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
const argv = soundPlayerArgv(platform, bin, path, options?.volume ?? DEFAULT_VOLUME)
|
|
171
|
+
try {
|
|
172
|
+
Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
|
|
173
|
+
// A test press does not start the window: it would swallow a real
|
|
174
|
+
// notification arriving in the next few hundred milliseconds.
|
|
175
|
+
if (throttled) lastPlayedAt = now
|
|
176
|
+
return true
|
|
177
|
+
} catch (error) {
|
|
178
|
+
logDebug('platform.playSound.error', {
|
|
179
|
+
argv,
|
|
180
|
+
error: error instanceof Error ? error.message : String(error),
|
|
181
|
+
})
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -9,7 +9,7 @@ import { logDebug } from '../debug/input-log'
|
|
|
9
9
|
|
|
10
10
|
interface DirectoryCache {
|
|
11
11
|
repoPaths: string[]
|
|
12
|
-
|
|
12
|
+
projectSet: Set<string>
|
|
13
13
|
cachedAt: number
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -38,16 +38,16 @@ async function buildCache(): Promise<DirectoryCache> {
|
|
|
38
38
|
parentCount.set(parent, (parentCount.get(parent) ?? 0) + 1)
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
const
|
|
41
|
+
const projectPaths = [...parentCount.entries()]
|
|
42
42
|
.filter(([, count]) => count >= 2)
|
|
43
43
|
.map(([p]) => p)
|
|
44
44
|
|
|
45
|
-
return { cachedAt: Date.now(),
|
|
45
|
+
return { cachedAt: Date.now(), projectSet: new Set(projectPaths), repoPaths }
|
|
46
46
|
} catch (error) {
|
|
47
47
|
logDebug('platform.projectSearch.buildCache.error', {
|
|
48
48
|
error: error instanceof Error ? error.message : String(error),
|
|
49
49
|
})
|
|
50
|
-
return { cachedAt: Date.now(),
|
|
50
|
+
return { cachedAt: Date.now(), projectSet: new Set(), repoPaths: [] }
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -70,7 +70,7 @@ export async function searchProjectDirectories(query: string): Promise<Directory
|
|
|
70
70
|
|
|
71
71
|
try {
|
|
72
72
|
const cache = await getOrBuildCache()
|
|
73
|
-
const allPaths = [...cache.repoPaths, ...cache.
|
|
73
|
+
const allPaths = [...cache.repoPaths, ...cache.projectSet].join('\n')
|
|
74
74
|
|
|
75
75
|
const filtered =
|
|
76
76
|
await $`printf '%s' ${allPaths} | fzf --filter=${query} --no-sort | head -50`.quiet()
|
|
@@ -89,12 +89,12 @@ export async function searchProjectDirectories(query: string): Promise<Directory
|
|
|
89
89
|
|
|
90
90
|
return Promise.all(
|
|
91
91
|
resultPaths.map(async (path) => {
|
|
92
|
-
if (cache.
|
|
93
|
-
return { path, type: '
|
|
92
|
+
if (cache.projectSet.has(path)) {
|
|
93
|
+
return { path, type: 'project' as const }
|
|
94
94
|
}
|
|
95
95
|
const gitPath = join(path, '.git')
|
|
96
96
|
const st = await stat(gitPath).catch(() => null)
|
|
97
|
-
const type = st !== null && st.isFile() ? '
|
|
97
|
+
const type = st !== null && st.isFile() ? 'workspace' : 'git-repo'
|
|
98
98
|
return { path, type } as const
|
|
99
99
|
})
|
|
100
100
|
)
|
|
@@ -23,18 +23,20 @@ function shortHash(input: string, length = 8): string {
|
|
|
23
23
|
return createHash('sha1').update(input).digest('hex').slice(0, length)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Params speak the app's vocabulary (a workspace); the directory layout keeps
|
|
27
|
+
// git's, so existing worktrees on disk stay exactly where they are.
|
|
26
28
|
export function makeWorktreePath({
|
|
27
29
|
repoRoot,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
workspaceId,
|
|
31
|
+
workspaceName,
|
|
30
32
|
}: {
|
|
31
33
|
repoRoot: string
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
workspaceName: string
|
|
35
|
+
workspaceId: string
|
|
34
36
|
}): string {
|
|
35
37
|
const repoKey = `r-${shortHash(resolve(repoRoot))}`
|
|
36
|
-
const idSuffix = shortHash(
|
|
37
|
-
const slug = `${sanitizePathSegment(
|
|
38
|
+
const idSuffix = shortHash(workspaceId, 5)
|
|
39
|
+
const slug = `${sanitizePathSegment(workspaceName)}-${idSuffix}`
|
|
38
40
|
return join(getAimuxWorktreeRoot(), repoKey, slug)
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Backends drive this loop to monitor every known terminal at a fixed cadence:
|
|
5
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-
|
|
6
|
+
* ~10 non-blank lines, and report per-tab + per-project statuses to the
|
|
7
7
|
* backend's transport. The loop is the single source of truth so that idle
|
|
8
8
|
* tabs can't erase a sibling's waiting-input, and so that clients receive
|
|
9
|
-
* status for every
|
|
9
|
+
* status for every project the backend owns, not just the attached one.
|
|
10
10
|
*
|
|
11
|
-
* Per-
|
|
11
|
+
* Per-project status is a pair of independent booleans:
|
|
12
12
|
* - `working`: at least one tab is working.
|
|
13
13
|
* - `waiting`: at least one tab is waiting for user input.
|
|
14
14
|
* Both can be true at the same time — the chip renders one glyph per flag.
|
|
15
15
|
*/
|
|
16
|
-
import type { AssistantId,
|
|
16
|
+
import type { AssistantId, ProjectStatus, TabActivity, TerminalSnapshot } from '../state/types'
|
|
17
17
|
|
|
18
18
|
import { logDebug } from '../debug/input-log'
|
|
19
19
|
import { getLineText } from '../input/terminal-text-extraction'
|
|
@@ -50,22 +50,36 @@ export interface LoopTabView {
|
|
|
50
50
|
assistant: AssistantId
|
|
51
51
|
command: string
|
|
52
52
|
viewport?: TerminalSnapshot
|
|
53
|
+
/** The workspace this tab belongs to, when its owner knows one. Passed
|
|
54
|
+
* straight through to the status callbacks so a client can group tabs it
|
|
55
|
+
* does not itself hold — see the `workspaceId` note in `src/ipc/protocol.ts`. */
|
|
56
|
+
workspaceId?: string
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
export interface StatusDetectionLoopOptions {
|
|
56
|
-
|
|
57
|
-
listTabs: (
|
|
60
|
+
listProjects: () => string[]
|
|
61
|
+
listTabs: (projectId: string) => LoopTabView[]
|
|
58
62
|
/** Emitted when a tab's status changes. */
|
|
59
|
-
onTabStatus: (
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
onTabStatus: (
|
|
64
|
+
tabId: string,
|
|
65
|
+
status: TabActivity,
|
|
66
|
+
projectId: string,
|
|
67
|
+
workspaceId: string | undefined
|
|
68
|
+
) => void
|
|
69
|
+
/** Emitted when either flag on a project changes. */
|
|
70
|
+
onProjectStatus: (projectId: string, status: ProjectStatus) => void
|
|
62
71
|
/**
|
|
63
72
|
* Emitted once per turn, when a tab's `idle` activity has held continuously
|
|
64
73
|
* for `turnSettleMs`. Edge-triggered: re-armed only after the tab leaves
|
|
65
74
|
* `idle`, so a driver receives exactly one signal per turn. `idleMs` is how
|
|
66
75
|
* long idle had held when the signal fired.
|
|
67
76
|
*/
|
|
68
|
-
onTurnComplete?: (
|
|
77
|
+
onTurnComplete?: (
|
|
78
|
+
tabId: string,
|
|
79
|
+
projectId: string,
|
|
80
|
+
idleMs: number,
|
|
81
|
+
workspaceId: string | undefined
|
|
82
|
+
) => void
|
|
69
83
|
/**
|
|
70
84
|
* Emitted when a tab transitions into `waiting-input`. Edge-triggered: fires
|
|
71
85
|
* once per transition, carrying the captured prompt text and any parsed
|
|
@@ -74,7 +88,7 @@ export interface StatusDetectionLoopOptions {
|
|
|
74
88
|
*/
|
|
75
89
|
onTabQuestion?: (
|
|
76
90
|
tabId: string,
|
|
77
|
-
|
|
91
|
+
projectId: string,
|
|
78
92
|
detail: { kind: 'question' | 'permission'; prompt: string; options?: string[] }
|
|
79
93
|
) => void
|
|
80
94
|
tickMs?: number
|
|
@@ -89,18 +103,18 @@ export interface StatusDetectionLoopHandle {
|
|
|
89
103
|
stop: () => void
|
|
90
104
|
/** Last classified status for a tab, for on-attach replay. */
|
|
91
105
|
getTabStatus: (tabId: string) => TabActivity | undefined
|
|
92
|
-
/** Last
|
|
93
|
-
|
|
94
|
-
/** Snapshot of every known
|
|
95
|
-
|
|
96
|
-
/** Snapshot of every known tab's status plus its
|
|
97
|
-
snapshotTabs: () => { tabId: string;
|
|
106
|
+
/** Last project flags, for on-attach replay. */
|
|
107
|
+
getProjectStatus: (projectId: string) => ProjectStatus | undefined
|
|
108
|
+
/** Snapshot of every known project's flags. */
|
|
109
|
+
snapshotProjects: () => { projectId: string; status: ProjectStatus }[]
|
|
110
|
+
/** Snapshot of every known tab's status plus its project. */
|
|
111
|
+
snapshotTabs: () => { tabId: string; projectId: string; status: TabActivity }[]
|
|
98
112
|
/**
|
|
99
|
-
* Synchronously run classification for a single
|
|
113
|
+
* Synchronously run classification for a single project. Used on client
|
|
100
114
|
* attach so the replay snapshot is populated *before* the client reads it,
|
|
101
115
|
* rather than relying on the next scheduled tick.
|
|
102
116
|
*/
|
|
103
|
-
classifyNow: (
|
|
117
|
+
classifyNow: (projectId: string, tabs: LoopTabView[]) => void
|
|
104
118
|
/**
|
|
105
119
|
* Feed a Claude Code hook event into the arbiter. The next tick will
|
|
106
120
|
* combine it with the visual detector's verdict. Called by the daemon's
|
|
@@ -117,8 +131,11 @@ export function runStatusDetectionLoop(
|
|
|
117
131
|
const now = options.nowFn ?? Date.now
|
|
118
132
|
const detector = new AssistantStatusDetector()
|
|
119
133
|
const arbiter = new AssistantStatusArbiter()
|
|
120
|
-
const lastTabStatus = new Map<
|
|
121
|
-
|
|
134
|
+
const lastTabStatus = new Map<
|
|
135
|
+
string,
|
|
136
|
+
{ status: TabActivity; projectId: string; workspaceId?: string }
|
|
137
|
+
>()
|
|
138
|
+
const lastProjectStatus = new Map<string, ProjectStatus>()
|
|
122
139
|
// Timestamp when a tab most recently entered `idle`. Cleared when it leaves
|
|
123
140
|
// idle. `turnEmitted` guards against re-firing `onTurnComplete` within the
|
|
124
141
|
// same idle episode; it's cleared alongside `idleSince` so the next turn
|
|
@@ -138,7 +155,7 @@ export function runStatusDetectionLoop(
|
|
|
138
155
|
timer.unref?.()
|
|
139
156
|
|
|
140
157
|
function classifySession(
|
|
141
|
-
|
|
158
|
+
projectId: string,
|
|
142
159
|
tabs: LoopTabView[],
|
|
143
160
|
now: number,
|
|
144
161
|
source: 'tick' | 'classifyNow',
|
|
@@ -159,21 +176,21 @@ export function runStatusDetectionLoop(
|
|
|
159
176
|
if (status === 'working') working = true
|
|
160
177
|
if (status === 'waiting-input') waiting = true
|
|
161
178
|
const prev = lastTabStatus.get(tab.id)
|
|
162
|
-
const changed = !prev || prev.status !== status || prev.
|
|
179
|
+
const changed = !prev || prev.status !== status || prev.projectId !== projectId
|
|
163
180
|
logDebug('statusLoop.classify', {
|
|
164
181
|
assistant: tab.assistant,
|
|
165
182
|
changed,
|
|
166
|
-
|
|
183
|
+
prevProjectId: prev?.projectId,
|
|
167
184
|
prevStatus: prev?.status,
|
|
168
|
-
|
|
185
|
+
projectId,
|
|
169
186
|
source,
|
|
170
187
|
status,
|
|
171
188
|
tabId: tab.id,
|
|
172
189
|
tailPreview: tailPreview(tab.viewport),
|
|
173
190
|
})
|
|
174
191
|
if (changed) {
|
|
175
|
-
lastTabStatus.set(tab.id, {
|
|
176
|
-
options.onTabStatus(tab.id, status,
|
|
192
|
+
lastTabStatus.set(tab.id, { projectId, status, workspaceId: tab.workspaceId })
|
|
193
|
+
options.onTabStatus(tab.id, status, projectId, tab.workspaceId)
|
|
177
194
|
}
|
|
178
195
|
|
|
179
196
|
// Turn-complete bookkeeping. The emission itself is gated to `tick` so a
|
|
@@ -189,7 +206,7 @@ export function runStatusDetectionLoop(
|
|
|
189
206
|
}
|
|
190
207
|
if (source === 'tick' && !turnEmitted.has(tab.id) && now - since >= turnSettleMs) {
|
|
191
208
|
turnEmitted.add(tab.id)
|
|
192
|
-
options.onTurnComplete?.(tab.id,
|
|
209
|
+
options.onTurnComplete?.(tab.id, projectId, now - since, tab.workspaceId)
|
|
193
210
|
}
|
|
194
211
|
} else {
|
|
195
212
|
idleSince.delete(tab.id)
|
|
@@ -205,59 +222,66 @@ export function runStatusDetectionLoop(
|
|
|
205
222
|
options.onTabQuestion
|
|
206
223
|
) {
|
|
207
224
|
const detail = extractQuestion(tab.assistant, tab.viewport)
|
|
208
|
-
if (detail) options.onTabQuestion(tab.id,
|
|
225
|
+
if (detail) options.onTabQuestion(tab.id, projectId, detail)
|
|
209
226
|
}
|
|
210
227
|
}
|
|
211
|
-
const next:
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
!
|
|
228
|
+
const next: ProjectStatus = { waiting, working }
|
|
229
|
+
const prevProject = lastProjectStatus.get(projectId)
|
|
230
|
+
const projectChanged =
|
|
231
|
+
!prevProject || prevProject.working !== working || prevProject.waiting !== waiting
|
|
215
232
|
logDebug('statusLoop.classifySession', {
|
|
216
|
-
prev:
|
|
217
|
-
|
|
218
|
-
|
|
233
|
+
prev: prevProject,
|
|
234
|
+
projectChanged,
|
|
235
|
+
projectId,
|
|
219
236
|
source,
|
|
220
237
|
status: next,
|
|
221
238
|
tabCount: tabs.length,
|
|
222
239
|
})
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
options.
|
|
240
|
+
if (projectChanged) {
|
|
241
|
+
lastProjectStatus.set(projectId, next)
|
|
242
|
+
options.onProjectStatus(projectId, next)
|
|
226
243
|
}
|
|
227
244
|
}
|
|
228
245
|
|
|
229
246
|
function tick(): void {
|
|
230
247
|
const ts = now()
|
|
231
|
-
const
|
|
232
|
-
const
|
|
248
|
+
const projectIds = options.listProjects()
|
|
249
|
+
const seenProjects = new Set<string>()
|
|
233
250
|
const seenTabs = new Set<string>()
|
|
234
251
|
|
|
235
|
-
for (const
|
|
236
|
-
|
|
237
|
-
classifySession(
|
|
252
|
+
for (const projectId of projectIds) {
|
|
253
|
+
seenProjects.add(projectId)
|
|
254
|
+
classifySession(projectId, options.listTabs(projectId), ts, 'tick', seenTabs)
|
|
238
255
|
}
|
|
239
256
|
|
|
240
|
-
for (const tabId of lastTabStatus
|
|
257
|
+
for (const [tabId, entry] of lastTabStatus) {
|
|
241
258
|
if (!seenTabs.has(tabId)) {
|
|
242
259
|
detector.forget(tabId)
|
|
243
260
|
arbiter.forget(tabId)
|
|
244
261
|
lastTabStatus.delete(tabId)
|
|
245
262
|
idleSince.delete(tabId)
|
|
246
263
|
turnEmitted.delete(tabId)
|
|
264
|
+
// A gone tab is no longer working or waiting on anyone. Only clients
|
|
265
|
+
// attached to its project hear its `tabExit`, so without this last
|
|
266
|
+
// word a tab that died mid-turn leaves every other client holding a
|
|
267
|
+
// status that will never be corrected.
|
|
268
|
+
if (entry.status !== 'idle') {
|
|
269
|
+
options.onTabStatus(tabId, 'idle', entry.projectId, entry.workspaceId)
|
|
270
|
+
}
|
|
247
271
|
}
|
|
248
272
|
}
|
|
249
|
-
for (const
|
|
250
|
-
if (!
|
|
251
|
-
|
|
273
|
+
for (const projectId of lastProjectStatus.keys()) {
|
|
274
|
+
if (!seenProjects.has(projectId)) {
|
|
275
|
+
lastProjectStatus.delete(projectId)
|
|
252
276
|
}
|
|
253
277
|
}
|
|
254
278
|
}
|
|
255
279
|
|
|
256
280
|
return {
|
|
257
|
-
classifyNow: (
|
|
258
|
-
classifySession(
|
|
281
|
+
classifyNow: (projectId, tabs) => {
|
|
282
|
+
classifySession(projectId, tabs, now(), 'classifyNow')
|
|
259
283
|
},
|
|
260
|
-
|
|
284
|
+
getProjectStatus: (projectId) => lastProjectStatus.get(projectId),
|
|
261
285
|
getTabStatus: (tabId) => lastTabStatus.get(tabId)?.status,
|
|
262
286
|
recordHookEvent: (input) => {
|
|
263
287
|
const mapped = arbiter.recordHookEvent(input)
|
|
@@ -267,11 +291,11 @@ export function runStatusDetectionLoop(
|
|
|
267
291
|
paneId: input.paneId,
|
|
268
292
|
})
|
|
269
293
|
},
|
|
270
|
-
|
|
271
|
-
[...
|
|
294
|
+
snapshotProjects: () =>
|
|
295
|
+
[...lastProjectStatus.entries()].map(([projectId, status]) => ({ projectId, status })),
|
|
272
296
|
snapshotTabs: () =>
|
|
273
297
|
[...lastTabStatus.entries()].map(([tabId, entry]) => ({
|
|
274
|
-
|
|
298
|
+
projectId: entry.projectId,
|
|
275
299
|
status: entry.status,
|
|
276
300
|
tabId,
|
|
277
301
|
})),
|
|
@@ -280,7 +304,7 @@ export function runStatusDetectionLoop(
|
|
|
280
304
|
detector.clear()
|
|
281
305
|
arbiter.clear()
|
|
282
306
|
lastTabStatus.clear()
|
|
283
|
-
|
|
307
|
+
lastProjectStatus.clear()
|
|
284
308
|
idleSince.clear()
|
|
285
309
|
turnEmitted.clear()
|
|
286
310
|
},
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* (https://github.com/ogulcancelik/herdr, MIT). Rule tables trace back to
|
|
6
6
|
* `src/detect.rs` in that repo.
|
|
7
7
|
*
|
|
8
|
-
* The detector classifies a terminal
|
|
8
|
+
* The detector classifies a terminal project as `working`, `waiting-input`,
|
|
9
9
|
* or `idle`. Built-in CLIs (claude, codex, opencode, grok, kimi) have dedicated
|
|
10
10
|
* classify* functions. Custom CLIs fall back to a generic heuristic that
|
|
11
11
|
* (a) recognises common shells as always-idle and (b) uses pane-tail change
|
|
@@ -51,6 +51,13 @@ export class AssistantStatusDetector {
|
|
|
51
51
|
if (!viewport) return this.remember(tabId, '', now, 'idle')
|
|
52
52
|
|
|
53
53
|
const tail = extractTailText(viewport, TAIL_LINE_COUNT)
|
|
54
|
+
// Whole visible screen. Claude's Ctrl+T todo overlay pushes the spinner
|
|
55
|
+
// status line above the 10-line tail, so "is it working" has to look at
|
|
56
|
+
// the full frame. "Is it waiting on me" stays tail-scoped.
|
|
57
|
+
// ponytail: a scrolled-away pane falls back to `viewport.tailLines`, capped
|
|
58
|
+
// at SNAPSHOT_TAIL_LINE_COUNT (10) — the overlay still hides the spinner
|
|
59
|
+
// there. Raise that constant if it bites; it grows every render payload.
|
|
60
|
+
const screen = extractTailText(viewport, viewport.lines.length)
|
|
54
61
|
const prev = this.entries.get(tabId)
|
|
55
62
|
const changedAt = prev && prev.tail === tail ? prev.changedAt : now
|
|
56
63
|
const haystack = tail.toLowerCase()
|
|
@@ -59,7 +66,7 @@ export class AssistantStatusDetector {
|
|
|
59
66
|
return this.remember(tabId, tail, changedAt, 'idle')
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
const perCli = classifyBuiltin(assistant, haystack, tail)
|
|
69
|
+
const perCli = classifyBuiltin(assistant, haystack, tail, screen)
|
|
63
70
|
if (perCli) return this.remember(tabId, tail, changedAt, perCli)
|
|
64
71
|
|
|
65
72
|
const generic = classifyGeneric(haystack, changedAt, now)
|
|
@@ -129,11 +136,12 @@ function extractTailText(viewport: TerminalSnapshot, lineCount: number): string
|
|
|
129
136
|
function classifyBuiltin(
|
|
130
137
|
assistant: AssistantId,
|
|
131
138
|
haystack: string,
|
|
132
|
-
rawTail: string
|
|
139
|
+
rawTail: string,
|
|
140
|
+
rawScreen: string
|
|
133
141
|
): TabActivity | null {
|
|
134
142
|
switch (assistant) {
|
|
135
143
|
case 'claude':
|
|
136
|
-
return classifyClaude(haystack,
|
|
144
|
+
return classifyClaude(haystack, rawScreen)
|
|
137
145
|
case 'codex':
|
|
138
146
|
return classifyCodex(haystack)
|
|
139
147
|
case 'opencode':
|
|
@@ -147,7 +155,15 @@ function classifyBuiltin(
|
|
|
147
155
|
}
|
|
148
156
|
}
|
|
149
157
|
|
|
150
|
-
|
|
158
|
+
/**
|
|
159
|
+
* `haystack` is the lowercased 10-line tail, `rawScreen` the whole visible
|
|
160
|
+
* frame. Prompts always land at the bottom, so waiting-input stays on the tail
|
|
161
|
+
* — widening it would let a "Do you want…" from the transcript above fake a
|
|
162
|
+
* pending question. Working markers get the full frame: with the Ctrl+T todo
|
|
163
|
+
* overlay open, Claude drops the `esc to interrupt` hint entirely and the
|
|
164
|
+
* spinner line sits above however many todos are listed.
|
|
165
|
+
*/
|
|
166
|
+
function classifyClaude(haystack: string, rawScreen: string): TabActivity {
|
|
151
167
|
if (
|
|
152
168
|
haystack.includes('do you want') ||
|
|
153
169
|
haystack.includes('would you like') ||
|
|
@@ -157,15 +173,16 @@ function classifyClaude(haystack: string, rawTail: string): TabActivity {
|
|
|
157
173
|
) {
|
|
158
174
|
return 'waiting-input'
|
|
159
175
|
}
|
|
176
|
+
const screen = rawScreen.toLowerCase()
|
|
160
177
|
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
178
|
+
screen.includes('esc/ctrl+c to interrupt') ||
|
|
179
|
+
screen.includes('esc to interrupt') ||
|
|
180
|
+
screen.includes('ctrl+c to interrupt') ||
|
|
181
|
+
screen.includes('esc interrupt')
|
|
165
182
|
) {
|
|
166
183
|
return 'working'
|
|
167
184
|
}
|
|
168
|
-
if (hasClaudeSpinner(
|
|
185
|
+
if (hasClaudeSpinner(rawScreen)) return 'working'
|
|
169
186
|
return 'idle'
|
|
170
187
|
}
|
|
171
188
|
|
|
@@ -174,8 +191,16 @@ function classifyClaude(haystack: string, rawTail: string): TabActivity {
|
|
|
174
191
|
// separators Claude scatters mid-line through its header/footer (e.g.
|
|
175
192
|
// `Opus 4.7 … · Claude Max`, `… · ← for agents`), which would otherwise pair
|
|
176
193
|
// with a stray `...` placeholder and mark an idle home screen as working.
|
|
177
|
-
|
|
178
|
-
|
|
194
|
+
//
|
|
195
|
+
// The ellipsis is also what separates a live status line (`✱ Flowing… (3s · …)`)
|
|
196
|
+
// from a finished one left in the transcript (`✱ Sautéed for 10s`) — that's why
|
|
197
|
+
// scanning the whole screen instead of the tail doesn't resurrect past turns.
|
|
198
|
+
// ponytail: a spinner line frozen mid-`…` (interrupted turn) would pin the pane
|
|
199
|
+
// to `working` until it scrolls off. Chosen failure direction — a false
|
|
200
|
+
// `working` notifies nobody, a false `idle` fires a turn-complete. Cross-check
|
|
201
|
+
// against screen-text velocity (the `(3s` timer ticks) if it ever shows up.
|
|
202
|
+
function hasClaudeSpinner(rawText: string): boolean {
|
|
203
|
+
for (const line of rawText.split('\n')) {
|
|
179
204
|
const trimmed = line.trimStart()
|
|
180
205
|
const first = trimmed[0]
|
|
181
206
|
if (first == null || !CLAUDE_SPINNER_GLYPH_SET.has(first)) continue
|
|
@@ -275,8 +300,8 @@ function classifyKimi(haystack: string, rawTail: string): TabActivity {
|
|
|
275
300
|
haystack.includes('apply these edits?') ||
|
|
276
301
|
haystack.includes('stop this task?') ||
|
|
277
302
|
haystack.includes('ready to build with this plan?') ||
|
|
278
|
-
haystack.includes('approve for
|
|
279
|
-
haystack.includes('approve for this
|
|
303
|
+
haystack.includes('approve for project') ||
|
|
304
|
+
haystack.includes('approve for this project') ||
|
|
280
305
|
haystack.includes('type feedback') ||
|
|
281
306
|
haystack.includes('waiting for authorization') ||
|
|
282
307
|
haystack.includes('sign in to kimi') ||
|
|
@@ -297,7 +322,7 @@ function classifyKimi(haystack: string, rawTail: string): TabActivity {
|
|
|
297
322
|
|
|
298
323
|
// Agent actively streaming / calling tools.
|
|
299
324
|
// Anchor thinking on ellipsis so the footer model suffix "thinking" alone
|
|
300
|
-
// (e.g. "kimi-k2 thinking") does not mark an idle
|
|
325
|
+
// (e.g. "kimi-k2 thinking") does not mark an idle project as working.
|
|
301
326
|
if (
|
|
302
327
|
haystack.includes('working...') ||
|
|
303
328
|
haystack.includes('working…') ||
|