@brimveyn/aimux 1.20.4 → 1.22.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 +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 +305 -1589
- package/src/app-runtime/snippet-actions.ts +3 -2
- package/src/app-runtime/split-drag-controller.ts +4 -8
- 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 +28 -42
- 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 +28 -33
- 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 +102 -85
- 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 +149 -101
- 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-merge.ts +64 -0
- package/src/git/pr-status-poller.ts +57 -0
- package/src/git/pr-status.ts +227 -0
- 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 +11 -6
- 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/open-url.ts +39 -0
- 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 +3 -1
- 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 +75 -0
- package/src/state/dispatch-ref.ts +1 -1
- package/src/state/git-pane-sizing.ts +0 -9
- package/src/state/layout-resize.ts +2 -2
- package/src/state/pr-status-store.ts +39 -0
- 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} +77 -73
- 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 -53
- 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 +86 -15
- package/src/state/selectors.ts +49 -37
- package/src/state/store.ts +83 -57
- package/src/state/types.ts +261 -417
- 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/diff-renderer/pierre-diff.tsx +2 -1
- 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-header.tsx +105 -39
- package/src/ui/components/git/pane/git-pane-widget.tsx +37 -23
- package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
- package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
- package/src/ui/components/layout/bar.tsx +191 -0
- 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 +48 -37
- 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 +164 -230
- 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 +21 -0
- package/src/ui/widgets/widget-context-menu.ts +85 -0
- 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 -45
- package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
- package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
- 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,39 @@
|
|
|
1
|
+
import { logDebug } from '../debug/input-log'
|
|
2
|
+
import { toast } from '../state/toast-store'
|
|
3
|
+
import { detectClipboardPlatform } from './clipboard'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* URLs here come from the GitHub API, i.e. outside the process. Handing an
|
|
7
|
+
* arbitrary scheme to the OS opener is a code-execution path (`file://`,
|
|
8
|
+
* `javascript:`, custom app handlers), so only plain https is allowed through.
|
|
9
|
+
*/
|
|
10
|
+
function isSafeUrl(url: string): boolean {
|
|
11
|
+
return /^https:\/\/[^\s]+$/.test(url)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function openUrlCommand(platform: string, isWsl: boolean, url: string): string[] | null {
|
|
15
|
+
if (!isSafeUrl(url)) return null
|
|
16
|
+
if (platform === 'darwin') return ['open', url]
|
|
17
|
+
// Under WSL the browser lives on the Windows side; explorer.exe is the one
|
|
18
|
+
// bridge present on every install (wslview/xdg-open often are not).
|
|
19
|
+
if (platform === 'win32' || isWsl) return ['explorer.exe', url]
|
|
20
|
+
return ['xdg-open', url]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function openUrl(url: string): void {
|
|
24
|
+
const { isWsl, platform } = detectClipboardPlatform()
|
|
25
|
+
const argv = openUrlCommand(platform, isWsl, url)
|
|
26
|
+
if (!argv) {
|
|
27
|
+
logDebug('platform.openUrl.rejected', { url })
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
|
|
32
|
+
} catch (error) {
|
|
33
|
+
logDebug('platform.openUrl.error', {
|
|
34
|
+
argv,
|
|
35
|
+
error: error instanceof Error ? error.message : String(error),
|
|
36
|
+
})
|
|
37
|
+
toast.error(`Could not open ${argv[0]}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -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
|
},
|