@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,99 @@
|
|
|
1
|
+
import type { SettingOption, SettingSection } from '../types'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BUILTIN_SOUND_IDS,
|
|
5
|
+
DEFAULT_VOLUME,
|
|
6
|
+
getCustomSoundsDir,
|
|
7
|
+
listCustomSoundIds,
|
|
8
|
+
playSoundFile,
|
|
9
|
+
resolveSoundPath,
|
|
10
|
+
SOUND_OFF,
|
|
11
|
+
} from '../../platform/play-sound'
|
|
12
|
+
import { toast } from '../../state/toast-store'
|
|
13
|
+
import { settingsStore } from '../settings-store'
|
|
14
|
+
|
|
15
|
+
/** Spelled once, here, next to the rows that own them. */
|
|
16
|
+
const NOTIFICATION_SOUND = 'notifications.sound'
|
|
17
|
+
const NOTIFICATION_VOLUME = 'notifications.volume'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `off` plus the shipped three plus whatever is in the drop-in directory. Built
|
|
21
|
+
* once, at import: a settings row's options are part of the schema (hydration
|
|
22
|
+
* validates the stored value against them), so they cannot depend on a scan that
|
|
23
|
+
* happens per render. A newly dropped file therefore shows up on the next
|
|
24
|
+
* launch, which the row's description says out loud.
|
|
25
|
+
*/
|
|
26
|
+
const SOUND_OPTIONS: SettingOption[] = [
|
|
27
|
+
{ label: 'off', value: SOUND_OFF },
|
|
28
|
+
...BUILTIN_SOUND_IDS.map((id) => ({ label: id, value: id })),
|
|
29
|
+
...listCustomSoundIds().map((id) => ({ label: id, value: id })),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/** The selected sound id, or `off`. */
|
|
33
|
+
function selectedSoundId(): string {
|
|
34
|
+
const value = settingsStore.getState().values[NOTIFICATION_SOUND]
|
|
35
|
+
return typeof value === 'string' ? value : SOUND_OFF
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function selectedVolume(): number {
|
|
39
|
+
const value = settingsStore.getState().values[NOTIFICATION_VOLUME]
|
|
40
|
+
return typeof value === 'number' ? value : DEFAULT_VOLUME
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Play the configured notification sound, if any. Silent — and cheap — when the
|
|
45
|
+
* user has set the row to `off`, which is what makes it safe to call on every
|
|
46
|
+
* status edge.
|
|
47
|
+
*/
|
|
48
|
+
export function playNotificationSound(options?: { ignoreThrottle?: boolean }): boolean {
|
|
49
|
+
const path = resolveSoundPath(selectedSoundId())
|
|
50
|
+
if (path == null) return false
|
|
51
|
+
return playSoundFile(path, { ...options, volume: selectedVolume() })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const NOTIFICATIONS_SECTION: SettingSection = {
|
|
55
|
+
description: 'Plays when an assistant needs an answer, or finishes a turn.',
|
|
56
|
+
id: 'notifications',
|
|
57
|
+
label: 'Notifications',
|
|
58
|
+
rows: [
|
|
59
|
+
{
|
|
60
|
+
description: `"off" disables it. Drop your own in ${getCustomSoundsDir()} — picked up at the next launch.`,
|
|
61
|
+
fallback: 'plane',
|
|
62
|
+
id: NOTIFICATION_SOUND,
|
|
63
|
+
kind: 'select',
|
|
64
|
+
label: 'Sound',
|
|
65
|
+
options: SOUND_OPTIONS,
|
|
66
|
+
storage: 'settings',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
description:
|
|
70
|
+
"Percent of the file's own level. Ignored by `aplay` and by Windows, which play at the system volume.",
|
|
71
|
+
fallback: DEFAULT_VOLUME,
|
|
72
|
+
id: NOTIFICATION_VOLUME,
|
|
73
|
+
kind: 'number',
|
|
74
|
+
label: 'Volume',
|
|
75
|
+
max: 100,
|
|
76
|
+
min: 0,
|
|
77
|
+
step: 5,
|
|
78
|
+
storage: 'settings',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
// The one way to find out that this machine has no audio player before a
|
|
82
|
+
// notification you actually cared about goes silent.
|
|
83
|
+
description: 'Play the selected sound now.',
|
|
84
|
+
id: 'notifications.test',
|
|
85
|
+
kind: 'action',
|
|
86
|
+
label: 'Test sound',
|
|
87
|
+
run: () => {
|
|
88
|
+
if (selectedSoundId() === SOUND_OFF) {
|
|
89
|
+
toast.info('Notification sound is off')
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (!playNotificationSound({ ignoreThrottle: true })) {
|
|
93
|
+
toast.error('Could not play that sound — no audio player found')
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
value: () => selectedSoundId(),
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ProjectRecord } from '../../state/types'
|
|
2
|
+
import type { SettingRow, SettingSection } from '../types'
|
|
3
|
+
|
|
4
|
+
import { runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
5
|
+
import { readSetupScriptLines, writeSetupCommand } from '../../state/project-data'
|
|
6
|
+
|
|
7
|
+
const SETUP_ROW_PREFIX = 'setup.script.'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One row per project, over the setup script at
|
|
11
|
+
* `~/.config/aimux/<profile>/projects/<id>/setup.sh`.
|
|
12
|
+
*
|
|
13
|
+
* A script whose work is a single command is editable here, which is the case
|
|
14
|
+
* this section exists for — `bun install && cp .env.example .env` should not
|
|
15
|
+
* require opening an editor. A script with more than one line of work is shown
|
|
16
|
+
* but not editable: a one-line field cannot round-trip it, and overwriting it
|
|
17
|
+
* from here would destroy whatever was there. Those hand over to the editor
|
|
18
|
+
* instead.
|
|
19
|
+
*/
|
|
20
|
+
function setupRow(projectId: string, projectName: string): SettingRow {
|
|
21
|
+
const lines = readSetupScriptLines(projectId)
|
|
22
|
+
|
|
23
|
+
if (lines.length > 1) {
|
|
24
|
+
return {
|
|
25
|
+
description: `${String(lines.length)} lines — too much for one field. Opens your editor.`,
|
|
26
|
+
id: `${SETUP_ROW_PREFIX}${projectId}`,
|
|
27
|
+
kind: 'action',
|
|
28
|
+
label: projectName,
|
|
29
|
+
run: () => runSideEffectGlobal({ projectId, type: 'configure-setup-script' }),
|
|
30
|
+
value: () => `${String(lines.length)} lines ›`,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
description: 'Runs once in each new workspace, from its root.',
|
|
36
|
+
id: `${SETUP_ROW_PREFIX}${projectId}`,
|
|
37
|
+
kind: 'text',
|
|
38
|
+
label: projectName,
|
|
39
|
+
placeholder: 'nothing yet',
|
|
40
|
+
// The file, not the state: this is the only row whose value lives on disk,
|
|
41
|
+
// and the screen re-reads it after every write.
|
|
42
|
+
read: () => lines[0] ?? '',
|
|
43
|
+
storage: 'app',
|
|
44
|
+
write: (value) => writeSetupCommand(projectId, String(value)),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const SETUP_SECTION: SettingSection = {
|
|
49
|
+
id: 'setup',
|
|
50
|
+
label: 'Setup',
|
|
51
|
+
rowCount: (projects) => projects.length,
|
|
52
|
+
rows: (projects: readonly ProjectRecord[]) =>
|
|
53
|
+
projects.map((project) => setupRow(project.id, project.name)),
|
|
54
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { setStatusBarSeparator, type StatusBarSeparator } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import type { SettingSection } from '../types'
|
|
4
|
+
|
|
5
|
+
export const AI_USAGE_ENABLED = 'statusBar.aiUsage.enabled'
|
|
6
|
+
export const AI_USAGE_POLL_SECONDS = 'statusBar.aiUsage.pollSeconds'
|
|
7
|
+
|
|
8
|
+
const SEPARATORS: { value: StatusBarSeparator; label: string }[] = [
|
|
9
|
+
{ label: 'arrow', value: 'arrow' },
|
|
10
|
+
{ label: 'round', value: 'round' },
|
|
11
|
+
{ label: 'slant', value: 'slant' },
|
|
12
|
+
{ label: 'flame', value: 'flame' },
|
|
13
|
+
{ label: 'none', value: 'none' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `statusBar.aiUsage.tools` is not here: it is a list, and this screen has no row
|
|
18
|
+
* kind for one. `claudePlan` and `codexWeeklyLimit` are not here either — they are
|
|
19
|
+
* declared in the config type but read nowhere, and a row that does nothing is
|
|
20
|
+
* worse than no row.
|
|
21
|
+
*/
|
|
22
|
+
export const STATUS_BAR_SECTION: SettingSection = {
|
|
23
|
+
id: 'statusBar',
|
|
24
|
+
label: 'Status bar',
|
|
25
|
+
rows: [
|
|
26
|
+
{
|
|
27
|
+
apply: (value) => setStatusBarSeparator(value as StatusBarSeparator),
|
|
28
|
+
description: 'Glyph between sections. All but "none" need a nerd font.',
|
|
29
|
+
fallback: 'arrow',
|
|
30
|
+
fromConfig: (config) => config.statusBar?.separator,
|
|
31
|
+
id: 'statusBar.separator',
|
|
32
|
+
kind: 'select',
|
|
33
|
+
label: 'Separator',
|
|
34
|
+
options: SEPARATORS,
|
|
35
|
+
storage: 'settings',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
description: 'Show how much of your Claude or Codex quota is left.',
|
|
39
|
+
fallback: false,
|
|
40
|
+
fromConfig: (config) => config.statusBar?.aiUsage?.enabled,
|
|
41
|
+
id: AI_USAGE_ENABLED,
|
|
42
|
+
kind: 'toggle',
|
|
43
|
+
label: 'AI usage indicator',
|
|
44
|
+
storage: 'settings',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
// The floor is 180 rather than a warning at 180: Claude's endpoint answers
|
|
48
|
+
// a faster caller with a rate limit, and the symptom is an indicator that
|
|
49
|
+
// quietly stops updating. A setting whose own description tells you not to
|
|
50
|
+
// use half its range should not have that half.
|
|
51
|
+
description: "Seconds between checks. Claude's endpoint rate-limits anything faster.",
|
|
52
|
+
fallback: 180,
|
|
53
|
+
fromConfig: (config) => config.statusBar?.aiUsage?.pollSeconds,
|
|
54
|
+
id: AI_USAGE_POLL_SECONDS,
|
|
55
|
+
kind: 'number',
|
|
56
|
+
label: 'Refresh interval',
|
|
57
|
+
max: 3_600,
|
|
58
|
+
min: 180,
|
|
59
|
+
step: 60,
|
|
60
|
+
storage: 'settings',
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { AimuxUserConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { useStore } from 'zustand'
|
|
4
|
+
import { createStore } from 'zustand/vanilla'
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
SettingCtx,
|
|
8
|
+
SettingRow,
|
|
9
|
+
SettingValue,
|
|
10
|
+
StoredSettingRow,
|
|
11
|
+
StoredSettings,
|
|
12
|
+
} from './types'
|
|
13
|
+
|
|
14
|
+
import { loadConfig, saveConfig } from '../config'
|
|
15
|
+
import { logDebug } from '../debug/input-log'
|
|
16
|
+
import { toast } from '../state/toast-store'
|
|
17
|
+
|
|
18
|
+
export interface SettingsState {
|
|
19
|
+
/** Bumped by every write. Rows whose value lives in a file have no other way
|
|
20
|
+
* to tell the screen to look again. */
|
|
21
|
+
revision: number
|
|
22
|
+
/** Effective value per row id, for the rows the settings screen owns. */
|
|
23
|
+
values: StoredSettings
|
|
24
|
+
/**
|
|
25
|
+
* Rows the user's `aimux.config.ts` declares. Their value comes back from that
|
|
26
|
+
* file at every launch, so an edit made here is good for this session only.
|
|
27
|
+
*/
|
|
28
|
+
fromConfigFile: ReadonlySet<string>
|
|
29
|
+
/**
|
|
30
|
+
* Rows this screen has a value written for. Not the same as "differs from the
|
|
31
|
+
* default" — someone may have set a row back to its default value — but it is
|
|
32
|
+
* what "reset" removes, so it is what the marker has to mean.
|
|
33
|
+
*/
|
|
34
|
+
touched: ReadonlySet<string>
|
|
35
|
+
/**
|
|
36
|
+
* What each row would hold if this screen had never touched it: the config
|
|
37
|
+
* file's value where it declares one, else the built-in default. Resolved once,
|
|
38
|
+
* at hydration, because that is when the config file was read — recomputing it
|
|
39
|
+
* later would read a file the rest of the app has not.
|
|
40
|
+
*/
|
|
41
|
+
defaults: StoredSettings
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EMPTY_FROM_CONFIG: ReadonlySet<string> = new Set()
|
|
45
|
+
|
|
46
|
+
export const settingsStore = createStore<SettingsState>(() => ({
|
|
47
|
+
defaults: {},
|
|
48
|
+
fromConfigFile: EMPTY_FROM_CONFIG,
|
|
49
|
+
revision: 0,
|
|
50
|
+
touched: EMPTY_FROM_CONFIG,
|
|
51
|
+
values: {},
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
export function useSettingsStore<T>(selector: (state: SettingsState) => T): T {
|
|
55
|
+
return useStore(settingsStore, selector)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A value a row publishes is one that row can actually hold, wherever it came
|
|
60
|
+
* from. Everything the screen writes is already in range; `aimux.config.ts` and a
|
|
61
|
+
* hand-edited (or older) `aimux.json` are not, and an unchecked one shows the row
|
|
62
|
+
* saying 60 while the code reading it uses its own floor of 180 — or shows a raw
|
|
63
|
+
* `wavy` where a separator name belongs.
|
|
64
|
+
*
|
|
65
|
+
* A number is clamped to its range. An option outside the list has no ordering to
|
|
66
|
+
* be clamped along, so the only value in the set that means anything is the row's
|
|
67
|
+
* own default, which `settings-schema.test.ts` keeps inside it.
|
|
68
|
+
*
|
|
69
|
+
* The range is the envelope for a setting this screen owns. The config file keeps
|
|
70
|
+
* its full reach over everything the screen has no row for.
|
|
71
|
+
*/
|
|
72
|
+
function withinRowDomain(row: StoredSettingRow, value: SettingValue): SettingValue {
|
|
73
|
+
if (row.kind === 'number' && typeof value === 'number') {
|
|
74
|
+
return Math.min(row.max, Math.max(row.min, value))
|
|
75
|
+
}
|
|
76
|
+
if (row.kind === 'select' && !row.options.some((option) => option.value === value)) {
|
|
77
|
+
return row.fallback
|
|
78
|
+
}
|
|
79
|
+
return value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve every stored row and publish the result: the config file wins, then
|
|
84
|
+
* whatever the settings screen last wrote, then the built-in default. The config
|
|
85
|
+
* file is re-read at every launch, which is what makes its value the one that
|
|
86
|
+
* comes back — see `docs/guide/settings.md`.
|
|
87
|
+
*
|
|
88
|
+
* The schema is a parameter rather than an import so this module stays downstream
|
|
89
|
+
* of the sections, which read from it.
|
|
90
|
+
*/
|
|
91
|
+
export function hydrateSettings(rows: readonly SettingRow[], userConfig: AimuxUserConfig): void {
|
|
92
|
+
const stored = loadConfig().settings ?? {}
|
|
93
|
+
const values: StoredSettings = {}
|
|
94
|
+
const defaults: StoredSettings = {}
|
|
95
|
+
const fromConfigFile = new Set<string>()
|
|
96
|
+
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
const owned = storedRow(row)
|
|
99
|
+
if (!owned) continue
|
|
100
|
+
const declared = owned.fromConfig?.(userConfig)
|
|
101
|
+
if (declared !== undefined) fromConfigFile.add(owned.id)
|
|
102
|
+
defaults[owned.id] = withinRowDomain(owned, declared ?? owned.fallback)
|
|
103
|
+
const value = withinRowDomain(owned, declared ?? stored[owned.id] ?? owned.fallback)
|
|
104
|
+
values[owned.id] = value
|
|
105
|
+
// Unconditional: the caller has already applied the config file's own values
|
|
106
|
+
// (they are the baseline), and re-applying the same value is a no-op. What
|
|
107
|
+
// matters is that a value coming from `stored` reaches the running app.
|
|
108
|
+
owned.apply?.(value)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
settingsStore.setState((state) => ({
|
|
112
|
+
defaults,
|
|
113
|
+
fromConfigFile,
|
|
114
|
+
revision: state.revision + 1,
|
|
115
|
+
touched: new Set(Object.keys(stored)),
|
|
116
|
+
values,
|
|
117
|
+
}))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Persist one value and apply it live. The merge reads the file rather than the
|
|
122
|
+
* store: the store holds resolved values, so merging from it would bake a value
|
|
123
|
+
* that came from `aimux.config.ts` into the JSON, where it would outlive the
|
|
124
|
+
* config-file line it came from.
|
|
125
|
+
*/
|
|
126
|
+
function persist(id: string, value: SettingValue | undefined): boolean {
|
|
127
|
+
const config = loadConfig()
|
|
128
|
+
const settings: StoredSettings = { ...config.settings }
|
|
129
|
+
if (value === undefined) delete settings[id]
|
|
130
|
+
else settings[id] = value
|
|
131
|
+
if (saveConfig({ ...config, settings })) return true
|
|
132
|
+
// Loud, not just logged: a read-only config directory or a full disk otherwise
|
|
133
|
+
// shows up as a key that does nothing, with no explanation anywhere.
|
|
134
|
+
logDebug('settings.write.failed', { id })
|
|
135
|
+
toast.error('Could not save that setting — see `aimux doctor`')
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The row as something with a key of its own, or nothing. Every operation that
|
|
141
|
+
* touches the `settings` block — hydrating it, resetting a key, saying a value
|
|
142
|
+
* needs a restart — asks this rather than narrowing the union by hand for the
|
|
143
|
+
* fourth time.
|
|
144
|
+
*/
|
|
145
|
+
export function storedRow(row: SettingRow): StoredSettingRow | null {
|
|
146
|
+
if (row.kind === 'info' || row.kind === 'action') return null
|
|
147
|
+
return row.storage === 'settings' ? row : null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function readRow(row: SettingRow, ctx: SettingCtx): SettingValue {
|
|
151
|
+
if (row.kind === 'info' || row.kind === 'action') return row.value(ctx)
|
|
152
|
+
if (row.storage === 'app') return row.read(ctx)
|
|
153
|
+
return ctx.values[row.id] ?? row.fallback
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Writing the value a row already has does nothing. That is not just an
|
|
158
|
+
* optimisation: an `app` row delegates to the action behind the keybinding, and
|
|
159
|
+
* several of those toggle rather than set — so handing one a value it already
|
|
160
|
+
* holds would flip it the wrong way. One rule here beats a guard per row.
|
|
161
|
+
*/
|
|
162
|
+
export function writeRow(row: SettingRow, value: SettingValue, ctx: SettingCtx): void {
|
|
163
|
+
if (row.kind === 'info' || row.kind === 'action') return
|
|
164
|
+
if (readRow(row, ctx) === value) return
|
|
165
|
+
if (row.storage === 'app') {
|
|
166
|
+
row.write(value, ctx)
|
|
167
|
+
bumpRevision()
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (!persist(row.id, value)) return
|
|
171
|
+
settingsStore.setState((state) => ({
|
|
172
|
+
revision: state.revision + 1,
|
|
173
|
+
touched: new Set(state.touched).add(row.id),
|
|
174
|
+
values: { ...state.values, [row.id]: value },
|
|
175
|
+
}))
|
|
176
|
+
row.apply?.(value)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Forget what this screen wrote for a row and go back to what the row would have
|
|
181
|
+
* been without it: the config file's value if it declares one, else the built-in
|
|
182
|
+
* default. Only the rows this screen owns — an `app` row's value has its own home
|
|
183
|
+
* and its own idea of a default.
|
|
184
|
+
*/
|
|
185
|
+
export function resetRow(row: SettingRow): void {
|
|
186
|
+
const owned = storedRow(row)
|
|
187
|
+
if (!owned) return
|
|
188
|
+
if (!settingsStore.getState().touched.has(owned.id)) return
|
|
189
|
+
if (!persist(owned.id, undefined)) return
|
|
190
|
+
|
|
191
|
+
const value = settingsStore.getState().defaults[owned.id] ?? owned.fallback
|
|
192
|
+
settingsStore.setState((state) => {
|
|
193
|
+
const touched = new Set(state.touched)
|
|
194
|
+
touched.delete(owned.id)
|
|
195
|
+
return { revision: state.revision + 1, touched, values: { ...state.values, [owned.id]: value } }
|
|
196
|
+
})
|
|
197
|
+
owned.apply?.(value)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Tells the screen that what it is showing may be out of date. Needed because a
|
|
202
|
+
* row's value can live outside both stores — the setup script is a file — and
|
|
203
|
+
* nothing else would make the list read it again.
|
|
204
|
+
*/
|
|
205
|
+
function bumpRevision(): void {
|
|
206
|
+
settingsStore.setState((state) => ({ revision: state.revision + 1 }))
|
|
207
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { AimuxUserConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import type { AppState, ProjectRecord } from '../state/types'
|
|
4
|
+
|
|
5
|
+
export type SettingValue = boolean | number | string
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The values the settings screen owns, keyed by row id. Sparse on purpose: a
|
|
9
|
+
* missing key means "never touched", which is not the same as `false`.
|
|
10
|
+
*/
|
|
11
|
+
export type StoredSettings = Record<string, SettingValue>
|
|
12
|
+
|
|
13
|
+
export interface SettingCtx {
|
|
14
|
+
state: AppState
|
|
15
|
+
values: StoredSettings
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SettingOption {
|
|
19
|
+
value: SettingValue
|
|
20
|
+
label: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How a row draws itself, and what activating it does. */
|
|
24
|
+
type SettingKind =
|
|
25
|
+
| { kind: 'toggle' }
|
|
26
|
+
| { kind: 'select'; options: readonly SettingOption[] }
|
|
27
|
+
| { kind: 'number'; min: number; max: number; step: number }
|
|
28
|
+
/** Activating it opens a one-field modal. Empty means "unset", not "empty string". */
|
|
29
|
+
| { kind: 'text'; placeholder?: string }
|
|
30
|
+
|
|
31
|
+
interface SettingRowBase {
|
|
32
|
+
id: string
|
|
33
|
+
label: string
|
|
34
|
+
description?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A row with no other home for its value: it lives in the `settings` block of
|
|
39
|
+
* `aimux.json`, and `readRow`/`writeRow` reach it by id. Nothing here repeats
|
|
40
|
+
* the id, so a row can't read one key and write another.
|
|
41
|
+
*/
|
|
42
|
+
interface StoredRow {
|
|
43
|
+
storage: 'settings'
|
|
44
|
+
/** Used when neither the config file nor the settings screen has a value. */
|
|
45
|
+
fallback: SettingValue
|
|
46
|
+
/**
|
|
47
|
+
* The value the user's `aimux.config.ts` declares for this row, or undefined
|
|
48
|
+
* when it doesn't declare it. Declared means it wins at every startup — a UI
|
|
49
|
+
* edit then lasts until the next launch, and the row says so.
|
|
50
|
+
*/
|
|
51
|
+
fromConfig?: (config: AimuxUserConfig) => SettingValue | undefined
|
|
52
|
+
/**
|
|
53
|
+
* Hands a new value to whatever owns it in the running app. Not every live row
|
|
54
|
+
* needs one — a value whose only reader subscribes to this store is live on its
|
|
55
|
+
* own, which is why "needs a restart" is declared rather than inferred.
|
|
56
|
+
*/
|
|
57
|
+
apply?: (value: SettingValue) => void
|
|
58
|
+
/** The running app won't see the new value; the row says so. */
|
|
59
|
+
restart?: true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A view over a value `AppState` already owns (git pane preferences, bar
|
|
64
|
+
* visibility, …). It delegates to that value's existing action or side effect
|
|
65
|
+
* rather than keeping a second copy.
|
|
66
|
+
*/
|
|
67
|
+
interface DerivedRow {
|
|
68
|
+
storage: 'app'
|
|
69
|
+
read: (ctx: SettingCtx) => SettingValue
|
|
70
|
+
/** Same context `read` gets, so a write that has to merge into a record can. */
|
|
71
|
+
write: (value: SettingValue, ctx: SettingCtx) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A row whose value lives in the `settings` block of `aimux.json`. */
|
|
75
|
+
export type StoredSettingRow = SettingRowBase & SettingKind & StoredRow
|
|
76
|
+
|
|
77
|
+
export type SettingRow =
|
|
78
|
+
| StoredSettingRow
|
|
79
|
+
| (SettingRowBase & SettingKind & DerivedRow)
|
|
80
|
+
| (SettingRowBase & { kind: 'info'; value: (ctx: SettingCtx) => string })
|
|
81
|
+
/**
|
|
82
|
+
* Not a setting: a button. For the things a row cannot hold — a multi-line
|
|
83
|
+
* script, a list — where the honest move is to hand over to whatever can.
|
|
84
|
+
*/
|
|
85
|
+
| (SettingRowBase & { kind: 'action'; value: (ctx: SettingCtx) => string; run: () => void })
|
|
86
|
+
|
|
87
|
+
export interface SettingSection {
|
|
88
|
+
id: string
|
|
89
|
+
label: string
|
|
90
|
+
/** Shown once under the section's title, for a caveat that covers every row. */
|
|
91
|
+
description?: string
|
|
92
|
+
/**
|
|
93
|
+
* A function when the rows depend on the projects — one row per project, say.
|
|
94
|
+
* The projects and nothing else: a builder handed the whole state would be free
|
|
95
|
+
* to depend on anything in it, and every caller would have to have all of it.
|
|
96
|
+
*
|
|
97
|
+
* Building a row may cost something (Setup reads a script off disk), so anything
|
|
98
|
+
* that only needs to know *how many* rows there are asks `rowCount` instead.
|
|
99
|
+
*/
|
|
100
|
+
rows: SettingRow[] | ((projects: readonly ProjectRecord[]) => SettingRow[])
|
|
101
|
+
/**
|
|
102
|
+
* How many rows there will be, without building them. Required alongside a
|
|
103
|
+
* dynamic `rows`, and kept honest by `settings-schema.test.ts`: the reducer
|
|
104
|
+
* clamps the cursor with this, and a count that disagrees with the list is a
|
|
105
|
+
* cursor that stops one row short of the end, or one past it.
|
|
106
|
+
*/
|
|
107
|
+
rowCount?: (projects: readonly ProjectRecord[]) => number
|
|
108
|
+
}
|
|
@@ -30,6 +30,13 @@ function buildShellArgv(cmd: string): string[] {
|
|
|
30
30
|
export async function runShellVar(name: string, v: SnippetShellVar): Promise<string> {
|
|
31
31
|
const requested = v.timeout ?? DEFAULT_TIMEOUT_MS
|
|
32
32
|
const timeoutMs = Math.min(Math.max(requested, 0), MAX_TIMEOUT_MS)
|
|
33
|
+
const logFailure = (event: string, error: unknown): void => {
|
|
34
|
+
logDebug(event, {
|
|
35
|
+
cmd: v.sh,
|
|
36
|
+
error: error instanceof Error ? error.message : String(error),
|
|
37
|
+
name,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
33
40
|
|
|
34
41
|
let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
|
|
35
42
|
try {
|
|
@@ -39,35 +46,55 @@ export async function runShellVar(name: string, v: SnippetShellVar): Promise<str
|
|
|
39
46
|
stdout: 'pipe',
|
|
40
47
|
})
|
|
41
48
|
} catch (error) {
|
|
42
|
-
|
|
43
|
-
cmd: v.sh,
|
|
44
|
-
error: error instanceof Error ? error.message : String(error),
|
|
45
|
-
name,
|
|
46
|
-
})
|
|
49
|
+
logFailure('snippets.shellVar.spawnError', error)
|
|
47
50
|
return ''
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// Racing the timeout rather than checking a flag after the reads: `proc.kill()`
|
|
54
|
+
// signals the shell, but a command it forked instead of exec'ing outlives it
|
|
55
|
+
// still holding the stdout pipe, so reading to EOF blocks past the deadline —
|
|
56
|
+
// on Linux `sleep 5` behind an 80ms timeout waited the full five seconds.
|
|
57
|
+
// ponytail: the orphan is left running. Kill the process group if a snippet
|
|
58
|
+
// ever spawns something expensive enough to care about.
|
|
50
59
|
let timeoutFired = false
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
61
|
+
const expired = new Promise<'timeout'>((resolve) => {
|
|
62
|
+
timeoutHandle = setTimeout(() => {
|
|
63
|
+
timeoutFired = true
|
|
64
|
+
try {
|
|
65
|
+
proc.kill()
|
|
66
|
+
} catch {
|
|
67
|
+
// process already gone
|
|
68
|
+
}
|
|
69
|
+
resolve('timeout')
|
|
70
|
+
}, timeoutMs)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Swallow inside, not with a trailing `.catch`: the loser of the race below
|
|
74
|
+
// is still a live promise, and a late failure would otherwise surface as an
|
|
75
|
+
// unhandled rejection long after we returned.
|
|
76
|
+
const collected = (async (): Promise<[string, string, number] | 'failed'> => {
|
|
53
77
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
return await Promise.all([
|
|
79
|
+
new Response(proc.stdout).text(),
|
|
80
|
+
new Response(proc.stderr).text(),
|
|
81
|
+
proc.exited,
|
|
82
|
+
])
|
|
83
|
+
} catch (error) {
|
|
84
|
+
logFailure('snippets.shellVar.error', error)
|
|
85
|
+
return 'failed'
|
|
57
86
|
}
|
|
58
|
-
}
|
|
87
|
+
})()
|
|
59
88
|
|
|
60
89
|
try {
|
|
61
|
-
const
|
|
62
|
-
new Response(proc.stdout).text(),
|
|
63
|
-
new Response(proc.stderr).text(),
|
|
64
|
-
proc.exited,
|
|
65
|
-
])
|
|
90
|
+
const outcome = await Promise.race([collected, expired])
|
|
66
91
|
|
|
67
|
-
if (timeoutFired) {
|
|
92
|
+
if (outcome === 'timeout' || timeoutFired) {
|
|
68
93
|
logDebug('snippets.shellVar.timeout', { cmd: v.sh, name, timeoutMs })
|
|
69
94
|
return ''
|
|
70
95
|
}
|
|
96
|
+
if (outcome === 'failed') return ''
|
|
97
|
+
const [stdout, stderr, exitCode] = outcome
|
|
71
98
|
|
|
72
99
|
if (exitCode !== 0) {
|
|
73
100
|
logDebug('snippets.shellVar.nonZeroExit', {
|
|
@@ -81,11 +108,10 @@ export async function runShellVar(name: string, v: SnippetShellVar): Promise<str
|
|
|
81
108
|
|
|
82
109
|
return v.trim === false ? stdout : stdout.replace(/\s+$/, '')
|
|
83
110
|
} catch (error) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
})
|
|
111
|
+
// Belt and braces: nothing in the try can reject today (`collected` swallows
|
|
112
|
+
// its own errors, `expired` never rejects), but this runs arbitrary user
|
|
113
|
+
// shell and the contract is that it never throws at its caller.
|
|
114
|
+
logFailure('snippets.shellVar.error', error)
|
|
89
115
|
return ''
|
|
90
116
|
} finally {
|
|
91
117
|
clearTimeout(timeoutHandle)
|