@brimveyn/aimux 1.23.2 → 1.23.5
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/package.json +2 -2
- package/src/app-runtime/navigation-actions.ts +4 -1
- package/src/app-runtime/side-effects.ts +5 -0
- package/src/app-runtime/use-auto-commit-driver.ts +6 -1
- package/src/app-runtime/use-backend-runtime.ts +25 -8
- package/src/app-runtime/workspace-actions.ts +11 -0
- package/src/app-runtime/workspace-launch.ts +5 -0
- package/src/git/workspace-branch-poller.ts +17 -24
- package/src/git/workspace-divergence-poller.ts +26 -17
- package/src/input/modes/types.ts +1 -0
- package/src/settings/live.ts +6 -1
- package/src/settings/sections/status-bar.ts +10 -0
- package/src/state/project-workspaces.ts +23 -3
- package/src/state/types.ts +6 -2
- package/src/state/validation.ts +2 -1
- package/src/ui/components/git/pane/git-pane-widget.tsx +7 -1
- package/src/ui/components/layout/bar-footer.tsx +74 -0
- package/src/ui/components/layout/bar.tsx +2 -0
- package/src/ui/components/layout/sidebar/project-list.tsx +33 -101
- package/src/ui/components/layout/status-bar.tsx +19 -15
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +13 -10
- package/src/ui/components/modals/workspace/create-workspace-modal.tsx +3 -3
- package/src/ui/git-branch.ts +26 -5
- package/src/ui/hooks/use-settled.ts +22 -0
- package/src/ui/root.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.5",
|
|
4
4
|
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@brimveyn/aimux-config": "0.10.
|
|
69
|
+
"@brimveyn/aimux-config": "0.10.6",
|
|
70
70
|
"@opentui/core": "^0.1.90",
|
|
71
71
|
"@opentui/react": "^0.1.90",
|
|
72
72
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
@@ -7,6 +7,7 @@ import { saveProjectCatalog } from '../state/project-catalog'
|
|
|
7
7
|
import {
|
|
8
8
|
filterTabsForActiveWorkspace,
|
|
9
9
|
getPrimaryWorkspace,
|
|
10
|
+
getSidebarWorkspaces,
|
|
10
11
|
withActiveWorkspace,
|
|
11
12
|
} from '../state/project-workspaces'
|
|
12
13
|
import { appReducer } from '../state/store'
|
|
@@ -103,7 +104,9 @@ function buildSidebarItems(state: AppState): SidebarItem[] {
|
|
|
103
104
|
)
|
|
104
105
|
const items: SidebarItem[] = []
|
|
105
106
|
for (const project of ordered) {
|
|
106
|
-
|
|
107
|
+
// `true`, for every project: a folded one keeps exactly the row j/k would
|
|
108
|
+
// land on, and that row is on screen the moment the cursor gets there.
|
|
109
|
+
for (const workspace of getSidebarWorkspaces(project, true)) {
|
|
107
110
|
items.push({ projectId: project.id, workspaceId: workspace.id })
|
|
108
111
|
}
|
|
109
112
|
}
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
runDeleteWorkspace,
|
|
70
70
|
runMoveWorkspace,
|
|
71
71
|
setProjectDefaultBaseRef,
|
|
72
|
+
toggleProjectCollapsed,
|
|
72
73
|
} from './workspace-actions'
|
|
73
74
|
import { launchPendingWorkspace, startWorkspaceCreation } from './workspace-launch'
|
|
74
75
|
|
|
@@ -586,6 +587,10 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
586
587
|
setProjectDefaultBaseRef(ctx, effect.projectId, effect.baseRef)
|
|
587
588
|
return
|
|
588
589
|
}
|
|
590
|
+
case 'toggle-project-collapsed': {
|
|
591
|
+
toggleProjectCollapsed(ctx, effect.projectId)
|
|
592
|
+
return
|
|
593
|
+
}
|
|
589
594
|
case 'ask-agent-for-setup-script': {
|
|
590
595
|
handleAskAgentForSetupScriptEffect(ctx)
|
|
591
596
|
return
|
|
@@ -93,8 +93,13 @@ export function useAutoCommitDriver({
|
|
|
93
93
|
const lastGitHashRef = useRef<string | null>(null)
|
|
94
94
|
const gitStabilizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
95
95
|
|
|
96
|
+
// `state.gitPanel`, not `state`: the payload is built from the git panel and
|
|
97
|
+
// nothing else, while `state` changes on every PTY frame. Keyed on the whole
|
|
98
|
+
// state this ran — and JSON.stringify'd the changed-file list — at the rate
|
|
99
|
+
// the assistants print.
|
|
96
100
|
useEffect(() => {
|
|
97
101
|
if (!configRef.current.enabled) return
|
|
102
|
+
const state = stateRef.current
|
|
98
103
|
const projectId = state.currentProjectId
|
|
99
104
|
if (!(projectId != null && projectId !== '')) return
|
|
100
105
|
const payload = gitPayloadFromState(state)
|
|
@@ -126,7 +131,7 @@ export function useAutoCommitDriver({
|
|
|
126
131
|
tabId: activeTab.id,
|
|
127
132
|
})
|
|
128
133
|
}, GIT_STABILIZATION_DEBOUNCE_MS)
|
|
129
|
-
}, [state])
|
|
134
|
+
}, [state.gitPanel, stateRef])
|
|
130
135
|
|
|
131
136
|
useEffect(
|
|
132
137
|
() => () => {
|
|
@@ -19,6 +19,9 @@ interface BackendRuntimeOptions {
|
|
|
19
19
|
syntaxOverlayEnabled: () => boolean
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Below the eye's notice on a deliberate switch; wide enough to swallow key repeat. */
|
|
23
|
+
const ATTACH_SETTLE_MS = 90
|
|
24
|
+
|
|
22
25
|
export interface TabRuntimeControls {
|
|
23
26
|
clearIdleTimer: (tabId: string) => void
|
|
24
27
|
clearStartupGrace: (tabId: string) => void
|
|
@@ -45,14 +48,28 @@ export function useBackendRuntime({
|
|
|
45
48
|
return
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
// Settle before attaching. An attach is a socket teardown + reconnect +
|
|
52
|
+
// handshake to the daemon, which then re-attaches every PTY of the project
|
|
53
|
+
// and classifies them — and holding `j`/`k` across the sidebar walks
|
|
54
|
+
// through projects at key-repeat rate. Firing one per keypress meant every
|
|
55
|
+
// intermediate project paid that round trip only to have its result
|
|
56
|
+
// discarded by the next. Only where the cursor comes to rest attaches.
|
|
57
|
+
let detach: (() => void) | null = null
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
detach = attachCurrentSession({
|
|
60
|
+
attachRequestIdRef,
|
|
61
|
+
backend,
|
|
62
|
+
currentProjectId,
|
|
63
|
+
currentProjectProjectSnapshot,
|
|
64
|
+
dispatch,
|
|
65
|
+
layoutRef,
|
|
66
|
+
})
|
|
67
|
+
}, ATTACH_SETTLE_MS)
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
clearTimeout(timer)
|
|
71
|
+
detach?.()
|
|
72
|
+
}
|
|
56
73
|
}, [backend, currentProjectId, currentProjectProjectSnapshot, dispatch, layoutRef])
|
|
57
74
|
|
|
58
75
|
useEffect(() => {
|
|
@@ -87,6 +87,17 @@ export function setProjectDefaultBaseRef(
|
|
|
87
87
|
ctx.dispatch({ projects, type: 'set-projects' })
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Fold or unfold a project's workspace rows in the sidebar. */
|
|
91
|
+
export function toggleProjectCollapsed(ctx: SideEffectContext, projectId: string): void {
|
|
92
|
+
const projects = replaceProject(ctx, projectId, (entry) => ({
|
|
93
|
+
...entry,
|
|
94
|
+
collapsed: entry.collapsed !== true ? true : undefined,
|
|
95
|
+
updatedAt: new Date().toISOString(),
|
|
96
|
+
}))
|
|
97
|
+
saveProjectCatalog(projects)
|
|
98
|
+
ctx.dispatch({ projects, type: 'set-projects' })
|
|
99
|
+
}
|
|
100
|
+
|
|
90
101
|
function normalizeBranchName(branch: string | undefined): string | undefined {
|
|
91
102
|
return branch?.replace(/^refs\/heads\//, '').trim()
|
|
92
103
|
}
|
|
@@ -73,6 +73,9 @@ export function startWorkspaceCreation(
|
|
|
73
73
|
* conclusion. Only prefixed when a setup is actually live.
|
|
74
74
|
*/
|
|
75
75
|
function buildWorkspacePrompt(ctx: SideEffectContext, pending: PendingWorkspaceLaunch): string {
|
|
76
|
+
// No prompt is a valid choice: the user wanted the worktree, not a task. Send
|
|
77
|
+
// nothing — not even the setup note, which would arrive as a prompt of its own.
|
|
78
|
+
if (pending.prompt.trim() === '') return ''
|
|
76
79
|
const setupTab = findSetupTab(ctx.getState().tabs, pending.workspaceId)
|
|
77
80
|
// No setup tab yet does not mean no setup: the workspace can land in the
|
|
78
81
|
// store the same tick the user picks, and the runner only spawns on the next
|
|
@@ -95,6 +98,8 @@ function renameWorkspaceFromLaunch(
|
|
|
95
98
|
workspace: WorkspaceRecord,
|
|
96
99
|
assistant: AssistantId
|
|
97
100
|
): void {
|
|
101
|
+
// Nothing to name it after; the `wt-<project>` placeholder stands.
|
|
102
|
+
if (pending.prompt.trim() === '') return
|
|
98
103
|
void renameWorkspaceFromPrompt(
|
|
99
104
|
{ projectId: pending.projectId, prompt: pending.prompt, provider: assistant, workspace },
|
|
100
105
|
{
|
|
@@ -18,36 +18,29 @@ export function useWorkspaceBranchPolling(enabled: boolean): void {
|
|
|
18
18
|
useEffect(() => {
|
|
19
19
|
if (!enabled) return
|
|
20
20
|
|
|
21
|
-
let cancelled = false
|
|
22
21
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
23
22
|
|
|
24
|
-
const tick =
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
})
|
|
41
|
-
)
|
|
42
|
-
)
|
|
43
|
-
if (cancelled) return
|
|
44
|
-
timer = setTimeout(() => void tick(), INTERVAL_MS)
|
|
23
|
+
const tick = () => {
|
|
24
|
+
for (const project of appStore.getState().projects) {
|
|
25
|
+
for (const workspace of project.workspaces ?? []) {
|
|
26
|
+
if (workspace.path == null || workspace.path === '') continue
|
|
27
|
+
const branch = getCurrentBranch(workspace.path)
|
|
28
|
+
if (branch != null && branch !== workspace.branch) {
|
|
29
|
+
dispatchGlobal({
|
|
30
|
+
patch: { branch },
|
|
31
|
+
projectId: project.id,
|
|
32
|
+
type: 'update-workspace-record',
|
|
33
|
+
workspaceId: workspace.id,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
timer = setTimeout(tick, INTERVAL_MS)
|
|
45
39
|
}
|
|
46
40
|
|
|
47
|
-
|
|
41
|
+
tick()
|
|
48
42
|
|
|
49
43
|
return () => {
|
|
50
|
-
cancelled = true
|
|
51
44
|
if (timer != null) clearTimeout(timer)
|
|
52
45
|
}
|
|
53
46
|
}, [enabled])
|
|
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import type { BranchDivergence } from '../state/types'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { appStore } from '../state/app-store'
|
|
6
6
|
import { dispatchGlobal } from '../state/dispatch-ref'
|
|
7
7
|
import { getBranchDivergence, getWorkspaceDiffStat } from './divergence'
|
|
8
8
|
|
|
@@ -14,25 +14,30 @@ const INTERVAL_MS = 4000
|
|
|
14
14
|
// never forked, so they fall back to their own upstream — for a root checkout on
|
|
15
15
|
// main that reads as "unpushed commits + dirty work". A branch with no upstream
|
|
16
16
|
// makes git fail, which the poller already renders as nothing.
|
|
17
|
+
//
|
|
18
|
+
// Runs once when enabled; reads projects from the store on each tick so project
|
|
19
|
+
// updates do NOT re-create the effect. Taking `projects` as a dependency meant
|
|
20
|
+
// every workspace switch tore the loop down and fired a fresh tick — holding
|
|
21
|
+
// `j` in the sidebar launched a full git fan-out per keypress (~200ms of
|
|
22
|
+
// subprocesses each) and the machine spent the whole time spawning `git`.
|
|
17
23
|
export function useWorkspaceDivergencePolling(enabled: boolean): void {
|
|
18
|
-
const projects = useAppStore((s) => s.projects)
|
|
19
|
-
|
|
20
24
|
useEffect(() => {
|
|
21
25
|
if (!enabled) return
|
|
22
|
-
// Every project, not just the current one: each dispatch replaces the whole
|
|
23
|
-
// map, so polling one project's workspaces blanks the stats of every other
|
|
24
|
-
// project's rows — which the sidebar shows all of at once.
|
|
25
|
-
// ponytail: one unbounded fan-out per tick, two `git` spawns per workspace.
|
|
26
|
-
// Batch or stagger if a machine with many projects feels it.
|
|
27
|
-
const targets = projects
|
|
28
|
-
.flatMap((project) => project.workspaces ?? [])
|
|
29
|
-
.filter((w) => w.branch != null && w.branch !== '')
|
|
30
|
-
if (targets.length === 0) return
|
|
31
26
|
|
|
32
27
|
let cancelled = false
|
|
33
28
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
34
29
|
|
|
35
30
|
const tick = async () => {
|
|
31
|
+
// Every project, not just the current one: each dispatch replaces the
|
|
32
|
+
// whole map, so polling one project's workspaces blanks the stats of
|
|
33
|
+
// every other project's rows — which the sidebar shows all of at once.
|
|
34
|
+
// ponytail: one unbounded fan-out per tick, two `git` spawns per
|
|
35
|
+
// workspace. Batch or stagger if a machine with many projects feels it.
|
|
36
|
+
const targets = appStore
|
|
37
|
+
.getState()
|
|
38
|
+
.projects.flatMap((project) => project.workspaces ?? [])
|
|
39
|
+
.filter((w) => w.branch != null && w.branch !== '')
|
|
40
|
+
|
|
36
41
|
const entries = await Promise.all(
|
|
37
42
|
targets.map(async (workspace) => {
|
|
38
43
|
const branch = workspace.branch
|
|
@@ -54,11 +59,15 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
|
|
|
54
59
|
})
|
|
55
60
|
)
|
|
56
61
|
if (cancelled) return
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
// Only when there was something to measure: an empty dispatch on a tick
|
|
63
|
+
// that found no branches would blank rows the previous tick filled.
|
|
64
|
+
if (targets.length > 0) {
|
|
65
|
+
const next: Record<string, BranchDivergence> = {}
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry != null) next[entry[0]] = entry[1]
|
|
68
|
+
}
|
|
69
|
+
dispatchGlobal({ divergence: next, type: 'set-workspace-divergence' })
|
|
60
70
|
}
|
|
61
|
-
dispatchGlobal({ divergence: next, type: 'set-workspace-divergence' })
|
|
62
71
|
timer = setTimeout(() => void tick(), INTERVAL_MS)
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -68,5 +77,5 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
|
|
|
68
77
|
cancelled = true
|
|
69
78
|
if (timer != null) clearTimeout(timer)
|
|
70
79
|
}
|
|
71
|
-
}, [enabled
|
|
80
|
+
}, [enabled])
|
|
72
81
|
}
|
package/src/input/modes/types.ts
CHANGED
|
@@ -116,6 +116,7 @@ export type SideEffect =
|
|
|
116
116
|
| { type: 'stop-setup' }
|
|
117
117
|
| { type: 'configure-setup-script'; projectId?: string }
|
|
118
118
|
| { type: 'set-project-default-base-ref'; projectId: string; baseRef: string }
|
|
119
|
+
| { type: 'toggle-project-collapsed'; projectId: string }
|
|
119
120
|
| { type: 'ask-agent-for-setup-script' }
|
|
120
121
|
| { type: 'promote-setup-tab' }
|
|
121
122
|
/** Toggle a checkbox, cycle an enum, run a row's action — whatever the row is. */
|
package/src/settings/live.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { useMemo } from 'react'
|
|
|
5
5
|
import { AUTO_COMMIT_ENABLED, AUTO_COMMIT_TIMEOUT } from './sections/automation'
|
|
6
6
|
import { AUTO_COMMIT_MODEL_PREFIX, SNIPPET_TRIGGER_CHAR } from './sections/commands'
|
|
7
7
|
import { ACTIVITY_SPRITES, HARMONIZE_CLAUDE_THEME } from './sections/experimental'
|
|
8
|
-
import { AI_USAGE_ENABLED, AI_USAGE_POLL_SECONDS } from './sections/status-bar'
|
|
8
|
+
import { AI_USAGE_ENABLED, AI_USAGE_POLL_SECONDS, HINTS_ENABLED } from './sections/status-bar'
|
|
9
9
|
import { useSettingsStore } from './settings-store'
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -88,3 +88,8 @@ export function useHarmonizeClaudeTheme(fromConfigFile: boolean | undefined): bo
|
|
|
88
88
|
const stored = useSettingsStore((s) => s.values[HARMONIZE_CLAUDE_THEME])
|
|
89
89
|
return typeof stored === 'boolean' ? stored : fromConfigFile === true
|
|
90
90
|
}
|
|
91
|
+
|
|
92
|
+
/** Whether the status bar draws its second row of keybinding hints. */
|
|
93
|
+
export function useStatusBarHints(): boolean {
|
|
94
|
+
return useSettingsStore((s) => s.values[HINTS_ENABLED] !== false)
|
|
95
|
+
}
|
|
@@ -2,6 +2,7 @@ import { setStatusBarSeparator, type StatusBarSeparator } from '@brimveyn/aimux-
|
|
|
2
2
|
|
|
3
3
|
import type { SettingSection } from '../types'
|
|
4
4
|
|
|
5
|
+
export const HINTS_ENABLED = 'statusBar.hints'
|
|
5
6
|
export const AI_USAGE_ENABLED = 'statusBar.aiUsage.enabled'
|
|
6
7
|
export const AI_USAGE_POLL_SECONDS = 'statusBar.aiUsage.pollSeconds'
|
|
7
8
|
|
|
@@ -35,6 +36,15 @@ export const STATUS_BAR_SECTION: SettingSection = {
|
|
|
35
36
|
options: SEPARATORS,
|
|
36
37
|
storage: 'settings',
|
|
37
38
|
},
|
|
39
|
+
{
|
|
40
|
+
description: 'The keybinding row under the bar. Off frees a line.',
|
|
41
|
+
fallback: true,
|
|
42
|
+
fromConfig: (config) => config.statusBar?.hints,
|
|
43
|
+
id: HINTS_ENABLED,
|
|
44
|
+
kind: 'toggle',
|
|
45
|
+
label: 'Keybinding hints',
|
|
46
|
+
storage: 'settings',
|
|
47
|
+
},
|
|
38
48
|
{
|
|
39
49
|
description: 'Show how much of your Claude or Codex quota is left.',
|
|
40
50
|
fallback: false,
|
|
@@ -293,13 +293,13 @@ export function findWorkspace(
|
|
|
293
293
|
return undefined
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/** The workspace a project's cursor sits on: its active one, else the checkout. */
|
|
296
297
|
export function getActiveWorkspace(
|
|
297
298
|
project: ProjectRecord | undefined
|
|
298
299
|
): WorkspaceRecord | undefined {
|
|
299
|
-
if (!(project?.workspaces?.length != null && project?.workspaces?.length !== 0)) return undefined
|
|
300
300
|
return (
|
|
301
|
-
project
|
|
302
|
-
project
|
|
301
|
+
project?.workspaces?.find((workspace) => workspace.id === project.activeWorkspaceId) ??
|
|
302
|
+
getPrimaryWorkspace(project?.workspaces)
|
|
303
303
|
)
|
|
304
304
|
}
|
|
305
305
|
|
|
@@ -390,3 +390,23 @@ export function orderTabsByWorkspace(
|
|
|
390
390
|
})
|
|
391
391
|
.map((entry) => entry.tab)
|
|
392
392
|
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The workspace rows a folded project still shows: just the one the cursor is
|
|
396
|
+
* on, so folding never hides the row you are standing on — and, for a project
|
|
397
|
+
* you are not in, nothing at all.
|
|
398
|
+
*
|
|
399
|
+
* Sidebar navigation calls this with `isCurrent: true` for every project: the
|
|
400
|
+
* moment j/k crosses into one it *is* current, and a project whose rows all
|
|
401
|
+
* vanished from the item list would be unreachable by keyboard.
|
|
402
|
+
*/
|
|
403
|
+
export function getSidebarWorkspaces(
|
|
404
|
+
project: ProjectRecord,
|
|
405
|
+
isCurrent: boolean
|
|
406
|
+
): WorkspaceRecord[] {
|
|
407
|
+
const workspaces = project.workspaces ?? []
|
|
408
|
+
if (project.collapsed !== true) return workspaces
|
|
409
|
+
if (!isCurrent) return []
|
|
410
|
+
const active = getActiveWorkspace(project)
|
|
411
|
+
return active ? [active] : []
|
|
412
|
+
}
|
package/src/state/types.ts
CHANGED
|
@@ -42,8 +42,6 @@ export interface ProjectStatus {
|
|
|
42
42
|
waiting: boolean
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export const IDLE_PROJECT_STATUS: ProjectStatus = { waiting: false, working: false }
|
|
46
|
-
|
|
47
45
|
/**
|
|
48
46
|
* A workspace's status, as the sidebar draws it. `working`/`waiting` mirror
|
|
49
47
|
* `ProjectStatus` one level down; `done` is a latch — an assistant here
|
|
@@ -226,6 +224,12 @@ export interface ProjectRecord {
|
|
|
226
224
|
* answers. Unset means the repo's default branch.
|
|
227
225
|
*/
|
|
228
226
|
defaultBaseRef?: string
|
|
227
|
+
/**
|
|
228
|
+
* Folded in the sidebar: the project's workspace rows are hidden, bar the one
|
|
229
|
+
* the cursor is on. Persisted, because a fold you have to redo on every start
|
|
230
|
+
* is worse than no fold at all.
|
|
231
|
+
*/
|
|
232
|
+
collapsed?: boolean
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
export interface ProjectBarState {
|
package/src/state/validation.ts
CHANGED
|
@@ -181,7 +181,8 @@ export function isProjectRecord(value: unknown): value is ProjectRecord {
|
|
|
181
181
|
(value.workspaces === undefined ||
|
|
182
182
|
(Array.isArray(value.workspaces) && value.workspaces.every(isWorkspaceRecord))) &&
|
|
183
183
|
(value.activeWorkspaceId === undefined || isString(value.activeWorkspaceId)) &&
|
|
184
|
-
(value.defaultBaseRef === undefined || isString(value.defaultBaseRef))
|
|
184
|
+
(value.defaultBaseRef === undefined || isString(value.defaultBaseRef)) &&
|
|
185
|
+
(value.collapsed === undefined || isBoolean(value.collapsed))
|
|
185
186
|
)
|
|
186
187
|
}
|
|
187
188
|
|
|
@@ -7,10 +7,14 @@ import { usePrStatusPolling } from '../../../../git/pr-status-poller'
|
|
|
7
7
|
import { useRepoDiscovery } from '../../../../git/use-repo-discovery'
|
|
8
8
|
import { useAppStore } from '../../../../state/app-store'
|
|
9
9
|
import { getActiveWorkspacePath } from '../../../../state/project-workspaces'
|
|
10
|
+
import { useSettled } from '../../../hooks/use-settled'
|
|
10
11
|
import { GitPanel } from '../git-panel'
|
|
11
12
|
import { GitPaneHeader, type GitPaneTab } from './git-pane-header'
|
|
12
13
|
import { PrChecksPanel } from './pr-checks-panel'
|
|
13
14
|
|
|
15
|
+
/** Long enough to swallow key repeat, short enough to read as instant. */
|
|
16
|
+
const PATH_SETTLE_MS = 150
|
|
17
|
+
|
|
14
18
|
interface GitPaneWidgetProps {
|
|
15
19
|
pollingEnabled: boolean
|
|
16
20
|
contentWidth: number
|
|
@@ -32,7 +36,9 @@ export const GitPaneWidget = memo(function GitPaneWidget({
|
|
|
32
36
|
currentProjectId != null && currentProjectId !== ''
|
|
33
37
|
? projects.find((s) => s.id === currentProjectId)
|
|
34
38
|
: undefined
|
|
35
|
-
|
|
39
|
+
// Settled: every poller below keys off this path and fires an immediate tick
|
|
40
|
+
// when it changes, and holding `j`/`k` in the sidebar changes it per keypress.
|
|
41
|
+
const projectPath = useSettled(getActiveWorkspacePath(currentProject), PATH_SETTLE_MS)
|
|
36
42
|
|
|
37
43
|
const [tab, setTab] = useState<GitPaneTab>('diff')
|
|
38
44
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
|
|
5
|
+
import { dispatchGlobal } from '../../../state/dispatch-ref'
|
|
6
|
+
import { useTheme } from '../../theme'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* U+2699, not the nerd-font gear: its Emoji_Presentation is No, so a conforming
|
|
10
|
+
* terminal draws it text-style in one cell and no font has to be installed for
|
|
11
|
+
* the one button that opens the settings.
|
|
12
|
+
*/
|
|
13
|
+
const SETTINGS_GLYPH = '⚙'
|
|
14
|
+
/**
|
|
15
|
+
* U+25A4, chosen on the same rule as the gear above: one cell, text presentation,
|
|
16
|
+
* present in the base fonts. A ▁▄█ mini bar chart reads better but is three cells
|
|
17
|
+
* wide, which pushes this row past a narrow sidebar.
|
|
18
|
+
*/
|
|
19
|
+
const STATS_GLYPH = '▤'
|
|
20
|
+
const SETTINGS_LABEL = `${SETTINGS_GLYPH} Settings`
|
|
21
|
+
const STATS_LABEL = `${STATS_GLYPH} Stats`
|
|
22
|
+
/** The two entries and the gap between them, so a renamed label re-measures itself. */
|
|
23
|
+
const FOOTER_GAP = 2
|
|
24
|
+
const FOOTER_FULL_WIDTH = SETTINGS_LABEL.length + FOOTER_GAP + STATS_LABEL.length
|
|
25
|
+
/** The row's own left and right padding. */
|
|
26
|
+
const FOOTER_PAD = 2
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The bar's bottom bar: settings and stats, pinned under every widget in the
|
|
30
|
+
* column rather than living inside whichever widget happens to be last. These
|
|
31
|
+
* are the only full-screen views the panes step aside for that a mouse can
|
|
32
|
+
* reach at all, so they need a slot that is on screen whenever the bar is.
|
|
33
|
+
*/
|
|
34
|
+
export function BarFooter({ contentWidth }: { contentWidth: number }) {
|
|
35
|
+
const t = useTheme()
|
|
36
|
+
|
|
37
|
+
const handleOpenSettings = useCallback((e: OtuiMouseEvent) => {
|
|
38
|
+
e.stopPropagation()
|
|
39
|
+
e.preventDefault()
|
|
40
|
+
dispatchGlobal({ type: 'enter-settings' })
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const handleOpenStats = useCallback((e: OtuiMouseEvent) => {
|
|
44
|
+
e.stopPropagation()
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
dispatchGlobal({ type: 'enter-stats' })
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
// The bar clamps down to 18 columns, narrower than both labels together, so
|
|
50
|
+
// below that the second entry drops to its glyph rather than being sliced
|
|
51
|
+
// mid-word by the overflow.
|
|
52
|
+
const statsLabel = contentWidth - FOOTER_PAD >= FOOTER_FULL_WIDTH ? STATS_LABEL : STATS_GLYPH
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<box flexDirection="column" flexShrink={0}>
|
|
56
|
+
<box flexShrink={0}>
|
|
57
|
+
<text fg={t.border} selectable={false} wrapMode="none">
|
|
58
|
+
{'─'.repeat(Math.max(1, contentWidth))}
|
|
59
|
+
</text>
|
|
60
|
+
</box>
|
|
61
|
+
{/* Each entry is its own <text>, with a spacer box between them: one string
|
|
62
|
+
holding both would make the whole footer a single click target. */}
|
|
63
|
+
<box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
|
|
64
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenSettings}>
|
|
65
|
+
{SETTINGS_LABEL}
|
|
66
|
+
</text>
|
|
67
|
+
<box width={FOOTER_GAP} flexShrink={1} />
|
|
68
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenStats}>
|
|
69
|
+
{statsLabel}
|
|
70
|
+
</text>
|
|
71
|
+
</box>
|
|
72
|
+
</box>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -11,6 +11,7 @@ import { useTheme } from '../../theme'
|
|
|
11
11
|
import { WIDGET_RENDERERS } from '../../widgets/registry'
|
|
12
12
|
import { buildBarContextMenu, buildWidgetContextMenu } from '../../widgets/widget-context-menu'
|
|
13
13
|
import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
|
|
14
|
+
import { BarFooter } from './bar-footer'
|
|
14
15
|
|
|
15
16
|
export interface BarBoundaryResizeInfo {
|
|
16
17
|
containerStart: number
|
|
@@ -119,6 +120,7 @@ export function Bar({
|
|
|
119
120
|
{side === 'right' ? edge : null}
|
|
120
121
|
<box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
|
|
121
122
|
{body}
|
|
123
|
+
{side === 'left' ? <BarFooter contentWidth={contentWidth} /> : null}
|
|
122
124
|
</box>
|
|
123
125
|
{side === 'left' ? edge : null}
|
|
124
126
|
</ContextMenuBox>
|
|
@@ -6,13 +6,15 @@ import type {
|
|
|
6
6
|
|
|
7
7
|
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
|
|
8
8
|
|
|
9
|
-
import type { ProjectRecord
|
|
9
|
+
import type { ProjectRecord } from '../../../../state/types'
|
|
10
10
|
|
|
11
11
|
import { useAppStore } from '../../../../state/app-store'
|
|
12
12
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
import {
|
|
14
|
+
getActiveWorkspace,
|
|
15
|
+
getPrimaryWorkspace,
|
|
16
|
+
getSidebarWorkspaces,
|
|
17
|
+
} from '../../../../state/project-workspaces'
|
|
16
18
|
import { moveIdToInsertIndex, orderProjectsForDisplay } from '../../../project-ordering'
|
|
17
19
|
import { useBaseTheme, useTheme } from '../../../theme'
|
|
18
20
|
import { truncate } from '../../../truncate'
|
|
@@ -31,25 +33,9 @@ const RULE = '─'
|
|
|
31
33
|
/** Heavier than the chrome rules, so the drop preview never reads as a border. */
|
|
32
34
|
const DROP_BAR = '━'
|
|
33
35
|
const HEADER_TITLE = 'Projects'
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
* the one button that opens the settings.
|
|
38
|
-
*/
|
|
39
|
-
const SETTINGS_GLYPH = '⚙'
|
|
40
|
-
/**
|
|
41
|
-
* U+25A4, chosen on the same rule as the gear above: one cell, text presentation,
|
|
42
|
-
* present in the base fonts. A ▁▄█ mini bar chart reads better but is three cells
|
|
43
|
-
* wide, which pushes this row past a narrow sidebar.
|
|
44
|
-
*/
|
|
45
|
-
const STATS_GLYPH = '▤'
|
|
46
|
-
const SETTINGS_LABEL = `${SETTINGS_GLYPH} Settings`
|
|
47
|
-
const STATS_LABEL = `${STATS_GLYPH} Stats`
|
|
48
|
-
/** The two entries and the gap between them, so a renamed label re-measures itself. */
|
|
49
|
-
const FOOTER_GAP = 2
|
|
50
|
-
const FOOTER_FULL_WIDTH = SETTINGS_LABEL.length + FOOTER_GAP + STATS_LABEL.length
|
|
51
|
-
/** The row's own left and right padding. */
|
|
52
|
-
const FOOTER_PAD = 2
|
|
36
|
+
/** Same pair the git panel folds its directories with, so the gesture reads once. */
|
|
37
|
+
const COLLAPSED_GLYPH = '▸ '
|
|
38
|
+
const EXPANDED_GLYPH = '▾ '
|
|
53
39
|
|
|
54
40
|
interface DragState {
|
|
55
41
|
id: string
|
|
@@ -61,7 +47,6 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
61
47
|
const t = useTheme()
|
|
62
48
|
const projects = useAppStore((s) => s.projects)
|
|
63
49
|
const currentProjectId = useAppStore((s) => s.currentProjectId)
|
|
64
|
-
const statusMap = useAppStore((s) => s.projectStatuses)
|
|
65
50
|
|
|
66
51
|
// The drag lives in a ref because mouse events can arrive before React has
|
|
67
52
|
// committed the state they set — reading `draggingId` out of a handler
|
|
@@ -90,11 +75,7 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
90
75
|
// The cursor is always on a workspace row now, so there is one id to scroll
|
|
91
76
|
// to instead of two — otherwise it visually "disappears" off-screen when a
|
|
92
77
|
// key press crosses a project boundary.
|
|
93
|
-
const
|
|
94
|
-
const activeWorkspaceId =
|
|
95
|
-
rawActiveWorkspaceId != null && rawActiveWorkspaceId !== ''
|
|
96
|
-
? rawActiveWorkspaceId
|
|
97
|
-
: getPrimaryWorkspace(currentProject?.workspaces)?.id
|
|
78
|
+
const activeWorkspaceId = getActiveWorkspace(currentProject)?.id
|
|
98
79
|
const activeRowId =
|
|
99
80
|
activeWorkspaceId != null && activeWorkspaceId !== '' ? `sidebar-wt-${activeWorkspaceId}` : null
|
|
100
81
|
|
|
@@ -187,28 +168,7 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
187
168
|
dispatchGlobal({ returnToProjectPicker: false, type: 'open-create-project-modal' })
|
|
188
169
|
}, [])
|
|
189
170
|
|
|
190
|
-
// The settings screen's only mouse-reachable way in. It lives here because this
|
|
191
|
-
// header is on screen whenever the left bar is, and because the `+` beside it
|
|
192
|
-
// already taught the same gesture.
|
|
193
|
-
const handleOpenSettings = useCallback((e: OtuiMouseEvent) => {
|
|
194
|
-
e.stopPropagation()
|
|
195
|
-
e.preventDefault()
|
|
196
|
-
dispatchGlobal({ type: 'enter-settings' })
|
|
197
|
-
}, [])
|
|
198
|
-
|
|
199
|
-
// Stats sits beside it for the same reason, and because the two are the only
|
|
200
|
-
// full-screen views the panes step aside for that a mouse can reach at all.
|
|
201
|
-
const handleOpenStats = useCallback((e: OtuiMouseEvent) => {
|
|
202
|
-
e.stopPropagation()
|
|
203
|
-
e.preventDefault()
|
|
204
|
-
dispatchGlobal({ type: 'enter-stats' })
|
|
205
|
-
}, [])
|
|
206
|
-
|
|
207
171
|
const rule = RULE.repeat(Math.max(1, contentWidth))
|
|
208
|
-
// The bar clamps down to 18 columns, narrower than both labels together, so
|
|
209
|
-
// below that the second entry drops to its glyph rather than being sliced
|
|
210
|
-
// mid-word by the overflow.
|
|
211
|
-
const statsLabel = contentWidth - FOOTER_PAD >= FOOTER_FULL_WIDTH ? STATS_LABEL : STATS_GLYPH
|
|
212
172
|
|
|
213
173
|
return (
|
|
214
174
|
// Drag and release are handled here, not on the row that started them:
|
|
@@ -253,15 +213,11 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
253
213
|
for (const [index, project] of ordered.entries()) {
|
|
254
214
|
const projectIndex = index + 1
|
|
255
215
|
const isCurrentProject = project.id === currentProjectId
|
|
256
|
-
const workspaces = project.workspaces ?? []
|
|
257
216
|
// Every workspace gets a row, the checkout included. Folding it into
|
|
258
217
|
// the project row made one row mean two things — a project you
|
|
259
218
|
// switch to and a workspace you run tabs in — and left the checkout
|
|
260
219
|
// the only workspace with no branch and no churn on screen.
|
|
261
|
-
const activeWorkspaceId =
|
|
262
|
-
project.activeWorkspaceId != null && project.activeWorkspaceId !== ''
|
|
263
|
-
? project.activeWorkspaceId
|
|
264
|
-
: getPrimaryWorkspace(workspaces)?.id
|
|
220
|
+
const activeWorkspaceId = getActiveWorkspace(project)?.id
|
|
265
221
|
rows.push(
|
|
266
222
|
// Every project already had a blank line above it, which doubles
|
|
267
223
|
// as the gap under the header. The drop bar is drawn *in* that
|
|
@@ -280,13 +236,12 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
280
236
|
project={project}
|
|
281
237
|
inCurrentGroup={isCurrentProject}
|
|
282
238
|
projectIndex={projectIndex}
|
|
283
|
-
status={statusMap[project.id] ?? IDLE_PROJECT_STATUS}
|
|
284
239
|
dragging={draggingId === project.id}
|
|
285
240
|
contentWidth={contentWidth}
|
|
286
241
|
onDragStart={handleRowDragStart}
|
|
287
242
|
/>
|
|
288
243
|
)
|
|
289
|
-
for (const workspace of
|
|
244
|
+
for (const workspace of getSidebarWorkspaces(project, isCurrentProject)) {
|
|
290
245
|
rows.push(
|
|
291
246
|
<WorkspaceRow
|
|
292
247
|
key={`wt:${workspace.id}`}
|
|
@@ -313,22 +268,6 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
313
268
|
return rows
|
|
314
269
|
})()}
|
|
315
270
|
</scrollbox>
|
|
316
|
-
<box flexShrink={0}>
|
|
317
|
-
<text fg={t.border} selectable={false} wrapMode="none">
|
|
318
|
-
{rule}
|
|
319
|
-
</text>
|
|
320
|
-
</box>
|
|
321
|
-
{/* Each entry is its own <text>, with a spacer box between them: one string
|
|
322
|
-
holding both would make the whole footer a single click target. */}
|
|
323
|
-
<box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
|
|
324
|
-
<text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenSettings}>
|
|
325
|
-
{SETTINGS_LABEL}
|
|
326
|
-
</text>
|
|
327
|
-
<box width={FOOTER_GAP} flexShrink={1} />
|
|
328
|
-
<text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenStats}>
|
|
329
|
-
{statsLabel}
|
|
330
|
-
</text>
|
|
331
|
-
</box>
|
|
332
271
|
</box>
|
|
333
272
|
)
|
|
334
273
|
}
|
|
@@ -370,7 +309,6 @@ interface ProjectRowProps {
|
|
|
370
309
|
inCurrentGroup: boolean
|
|
371
310
|
/** 1-based index in the visible order, so the "+" can switch projects first. */
|
|
372
311
|
projectIndex: number
|
|
373
|
-
status: ProjectStatus
|
|
374
312
|
dragging: boolean
|
|
375
313
|
contentWidth: number
|
|
376
314
|
/** Only the gesture's start lives here — the list owns drag and release. */
|
|
@@ -384,25 +322,11 @@ const ProjectRow = memo(function ProjectRow({
|
|
|
384
322
|
onDragStart,
|
|
385
323
|
project,
|
|
386
324
|
projectIndex,
|
|
387
|
-
status,
|
|
388
325
|
}: ProjectRowProps) {
|
|
389
326
|
const t = useTheme()
|
|
390
327
|
// Selection highlight must stay opaque in transparent mode — otherwise the
|
|
391
328
|
// cursor row visually disappears against the see-through chrome.
|
|
392
329
|
const base = useBaseTheme()
|
|
393
|
-
// State belongs on the workspace rows: they say *which* one is working or
|
|
394
|
-
// waiting, and this heading can only say "somewhere below". So the heading
|
|
395
|
-
// speaks only when none of its workspaces can — a tab whose workspace this
|
|
396
|
-
// client doesn't know, which today means a daemon still running a pre-v18
|
|
397
|
-
// protocol. Without that fallback the sidebar would go silent instead of
|
|
398
|
-
// degrading.
|
|
399
|
-
const workspacesSpeak = useAppStore((s) =>
|
|
400
|
-
(project.workspaces ?? []).some((workspace) => {
|
|
401
|
-
const activity = s.workspaceActivity[workspace.id]
|
|
402
|
-
return activity !== undefined && (activity.working || activity.waiting || activity.done)
|
|
403
|
-
})
|
|
404
|
-
)
|
|
405
|
-
const showWaiting = status.waiting && !workspacesSpeak
|
|
406
330
|
// Only the drag highlight is "selected"-strength here. A heading that lights
|
|
407
331
|
// up like a cursor row is what made the project look like a workspace.
|
|
408
332
|
let bgColor: string | undefined
|
|
@@ -411,7 +335,6 @@ const ProjectRow = memo(function ProjectRow({
|
|
|
411
335
|
} else if (inCurrentGroup) {
|
|
412
336
|
bgColor = base.backgroundPanel
|
|
413
337
|
}
|
|
414
|
-
const waitingColor = t.warning
|
|
415
338
|
const currentProjectId = useAppStore((s) => s.currentProjectId)
|
|
416
339
|
|
|
417
340
|
const handleMouseDown = useCallback(
|
|
@@ -422,6 +345,16 @@ const ProjectRow = memo(function ProjectRow({
|
|
|
422
345
|
},
|
|
423
346
|
[onDragStart, project.id]
|
|
424
347
|
)
|
|
348
|
+
// stopPropagation keeps the row's own mousedown from starting a drag, so the
|
|
349
|
+
// release never falls through to switching project — same trick as the "+".
|
|
350
|
+
const handleToggleCollapsed = useCallback(
|
|
351
|
+
(e: OtuiMouseEvent) => {
|
|
352
|
+
e.preventDefault()
|
|
353
|
+
e.stopPropagation()
|
|
354
|
+
runSideEffectGlobal({ projectId: project.id, type: 'toggle-project-collapsed' })
|
|
355
|
+
},
|
|
356
|
+
[project.id]
|
|
357
|
+
)
|
|
425
358
|
const handleNewWorkspace = useCallback(
|
|
426
359
|
(e: OtuiMouseEvent) => {
|
|
427
360
|
e.preventDefault()
|
|
@@ -457,17 +390,11 @@ const ProjectRow = memo(function ProjectRow({
|
|
|
457
390
|
[project.id, project.name]
|
|
458
391
|
)
|
|
459
392
|
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
let leadingGlyph = '• '
|
|
466
|
-
let leadingColor = t.textMuted
|
|
467
|
-
if (showWaiting) {
|
|
468
|
-
leadingGlyph = '? '
|
|
469
|
-
leadingColor = waitingColor
|
|
470
|
-
}
|
|
393
|
+
// The slot used to carry a status marker, which only ever said "somewhere
|
|
394
|
+
// below" — the workspace rows one line down say it precisely. It now carries
|
|
395
|
+
// the fold arrow instead, and the trailing space is part of the glyph so the
|
|
396
|
+
// name never shifts.
|
|
397
|
+
const leadingGlyph = project.collapsed === true ? COLLAPSED_GLYPH : EXPANDED_GLYPH
|
|
471
398
|
|
|
472
399
|
// No branch line: the repo checkout is not somewhere aimux works, so naming
|
|
473
400
|
// it under every project only ever read as "you are on main".
|
|
@@ -485,7 +412,12 @@ const ProjectRow = memo(function ProjectRow({
|
|
|
485
412
|
onMouseDown={handleMouseDown}
|
|
486
413
|
>
|
|
487
414
|
<box flexDirection="row" alignItems="center">
|
|
488
|
-
<text
|
|
415
|
+
<text
|
|
416
|
+
fg={t.textMuted}
|
|
417
|
+
selectable={false}
|
|
418
|
+
wrapMode="none"
|
|
419
|
+
onMouseDown={handleToggleCollapsed}
|
|
420
|
+
>
|
|
489
421
|
{leadingGlyph}
|
|
490
422
|
</text>
|
|
491
423
|
<FlashLabelBadge rowKey={`ws:${project.id}`} />
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import type { AppState } from '../../../state/types'
|
|
8
8
|
|
|
9
9
|
import { version as APP_VERSION } from '../../../../package.json'
|
|
10
|
+
import { useStatusBarHints } from '../../../settings/live'
|
|
10
11
|
import { useAIUsageStore } from '../../../state/ai-usage-store'
|
|
11
12
|
import { useAppStore } from '../../../state/app-store'
|
|
12
13
|
import { useKeymap } from '../../keymap-context'
|
|
@@ -124,6 +125,7 @@ export function StatusBar() {
|
|
|
124
125
|
const modeColor = getModeColor(state.focusMode, t)
|
|
125
126
|
const ambient = composeAmbient(model.right, model.help)
|
|
126
127
|
const aiEnabled = useAIUsageStore((s) => s.enabled)
|
|
128
|
+
const showHints = useStatusBarHints()
|
|
127
129
|
|
|
128
130
|
const glyphs = SEPARATOR_GLYPHS[getStatusBarSeparator()]
|
|
129
131
|
|
|
@@ -137,7 +139,7 @@ export function StatusBar() {
|
|
|
137
139
|
|
|
138
140
|
return (
|
|
139
141
|
<box
|
|
140
|
-
height={2}
|
|
142
|
+
height={showHints ? 2 : 1}
|
|
141
143
|
flexShrink={0}
|
|
142
144
|
overflow="hidden"
|
|
143
145
|
flexDirection="column"
|
|
@@ -201,20 +203,22 @@ export function StatusBar() {
|
|
|
201
203
|
</box>
|
|
202
204
|
|
|
203
205
|
{/* Row 2 — ambient hints */}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
{
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
206
|
+
{showHints ? (
|
|
207
|
+
<box
|
|
208
|
+
height={1}
|
|
209
|
+
flexShrink={0}
|
|
210
|
+
flexDirection="row"
|
|
211
|
+
paddingLeft={ROW2_INDENT}
|
|
212
|
+
paddingRight={1}
|
|
213
|
+
overflow="hidden"
|
|
214
|
+
>
|
|
215
|
+
{ambient !== '' ? (
|
|
216
|
+
<text fg={t.textMuted} wrapMode="none" selectable={false}>
|
|
217
|
+
{ambient}
|
|
218
|
+
</text>
|
|
219
|
+
) : null}
|
|
220
|
+
</box>
|
|
221
|
+
) : null}
|
|
218
222
|
</box>
|
|
219
223
|
)
|
|
220
224
|
}
|
|
@@ -17,8 +17,12 @@ interface NewTabModalProps {
|
|
|
17
17
|
cursorPos?: number
|
|
18
18
|
editingCommand: AssistantId | null
|
|
19
19
|
editBuffer: string
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Chained from `<C-p>`: the prompt waiting for the picked assistant, `''` when
|
|
22
|
+
* the workspace was created without one, `null` when this is a plain new tab.
|
|
23
|
+
* A shell cannot take a prompt, so Terminal is hidden only when there is one.
|
|
24
|
+
*/
|
|
25
|
+
pendingPrompt: string | null
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function NewTabModal({
|
|
@@ -26,11 +30,16 @@ export function NewTabModal({
|
|
|
26
30
|
customCommands,
|
|
27
31
|
editBuffer,
|
|
28
32
|
editingCommand,
|
|
29
|
-
excludeTerminal,
|
|
30
33
|
filter,
|
|
34
|
+
pendingPrompt,
|
|
31
35
|
selectedIndex,
|
|
32
36
|
}: NewTabModalProps) {
|
|
33
37
|
const t = useTheme()
|
|
38
|
+
const excludeTerminal = pendingPrompt != null && pendingPrompt.trim() !== ''
|
|
39
|
+
const footerText =
|
|
40
|
+
pendingPrompt == null
|
|
41
|
+
? 'Enter launches in the active workspace'
|
|
42
|
+
: `Enter launches in the new workspace${excludeTerminal ? ' and sends your prompt' : ''}`
|
|
34
43
|
const options = useMemo(() => getAllAssistantOptions(customCommands), [customCommands])
|
|
35
44
|
const filtered = useMemo(
|
|
36
45
|
() => getNewTabAssistantOptions(customCommands, filter, excludeTerminal),
|
|
@@ -100,13 +109,7 @@ export function NewTabModal({
|
|
|
100
109
|
selectedIndex={selectedIndex}
|
|
101
110
|
emptyState={<text fg={t.textMuted}>No matching assistants.</text>}
|
|
102
111
|
onHover={handleHover}
|
|
103
|
-
footer={
|
|
104
|
-
<text fg={t.textMuted}>
|
|
105
|
-
{excludeTerminal
|
|
106
|
-
? 'Enter launches in the new workspace and sends your prompt'
|
|
107
|
-
: 'Enter launches in the active workspace'}
|
|
108
|
-
</text>
|
|
109
|
-
}
|
|
112
|
+
footer={<text fg={t.textMuted}>{footerText}</text>}
|
|
110
113
|
/>
|
|
111
114
|
)
|
|
112
115
|
}
|
|
@@ -70,11 +70,11 @@ export function CreateWorkspaceModal({
|
|
|
70
70
|
<box flexDirection="column">
|
|
71
71
|
<TextField
|
|
72
72
|
active={activeField === 'prompt'}
|
|
73
|
-
label="What do you want to work on?"
|
|
74
|
-
description="Sent to the assistant, and names the workspace and its branch."
|
|
73
|
+
label="What do you want to work on? (optional)"
|
|
74
|
+
description="Sent to the assistant, and names the workspace and its branch. Leave empty for a bare workspace."
|
|
75
75
|
value={prompt}
|
|
76
76
|
cursorPos={activeField === 'prompt' ? cursorPos : undefined}
|
|
77
|
-
placeholder="Describe the task..."
|
|
77
|
+
placeholder="Describe the task, or leave empty..."
|
|
78
78
|
minLines={PROMPT_LINES}
|
|
79
79
|
/>
|
|
80
80
|
{branchError != null && branchError !== '' ? (
|
package/src/ui/git-branch.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* `.git` is a directory in a normal checkout and a `gitdir: <path>` pointer
|
|
6
|
+
* file inside a worktree — which is what every aimux-created workspace is.
|
|
7
|
+
*/
|
|
8
|
+
function gitDirOf(cwd: string): string {
|
|
9
|
+
const dotGit = join(cwd, '.git')
|
|
10
|
+
if (statSync(dotGit).isDirectory()) return dotGit
|
|
11
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf8'))?.[1]?.trim()
|
|
12
|
+
if (pointer == null || pointer === '') throw new Error('unreadable .git pointer')
|
|
13
|
+
return isAbsolute(pointer) ? pointer : resolve(cwd, pointer)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Current branch name, or null when detached (or when `cwd` is not a checkout) —
|
|
18
|
+
* the same contract as `git branch --show-current`, from one file read instead
|
|
19
|
+
* of a subprocess.
|
|
20
|
+
*
|
|
21
|
+
* The branch poller calls this for every workspace of every project every four
|
|
22
|
+
* seconds. Spawning git there cost ~35ms a tick on a 13-workspace setup; the
|
|
23
|
+
* reads cost ~0.1ms.
|
|
24
|
+
*/
|
|
25
|
+
export function getCurrentBranch(cwd: string): string | null {
|
|
4
26
|
try {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
return branch || null
|
|
27
|
+
const head = readFileSync(join(gitDirOf(cwd), 'HEAD'), 'utf8').trim()
|
|
28
|
+
return /^ref:\s*refs\/heads\/(.+)$/.exec(head)?.[1] ?? null
|
|
8
29
|
} catch {
|
|
9
30
|
return null
|
|
10
31
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `value`, but only once it has stopped changing for `ms`. The first value is
|
|
5
|
+
* returned as-is, so mounting is never delayed.
|
|
6
|
+
*
|
|
7
|
+
* Everything keyed on the active workspace's path — git status, PR checks,
|
|
8
|
+
* nested-repo discovery — restarts its poller and fires an immediate tick when
|
|
9
|
+
* that path changes. Holding `j`/`k` through the sidebar changes it at
|
|
10
|
+
* key-repeat rate, so without this every keypress spawned a `git status`
|
|
11
|
+
* fan-out and a network-bound `gh pr view` that the next keypress discarded.
|
|
12
|
+
*/
|
|
13
|
+
export function useSettled<T>(value: T, ms: number): T {
|
|
14
|
+
const [settled, setSettled] = useState(value)
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const timer = setTimeout(() => setSettled(value), ms)
|
|
18
|
+
return () => clearTimeout(timer)
|
|
19
|
+
}, [value, ms])
|
|
20
|
+
|
|
21
|
+
return settled
|
|
22
|
+
}
|
package/src/ui/root.tsx
CHANGED
|
@@ -109,7 +109,7 @@ function renderModal(
|
|
|
109
109
|
cursorPos={modal.cursorPos}
|
|
110
110
|
editingCommand={modal.type === 'new-tab' ? modal.editingCommand : null}
|
|
111
111
|
editBuffer={modal.editBuffer ?? ''}
|
|
112
|
-
|
|
112
|
+
pendingPrompt={modal.type === 'new-tab' ? (modal.pendingWorkspace?.prompt ?? null) : null}
|
|
113
113
|
/>
|
|
114
114
|
)
|
|
115
115
|
case 'create-workspace':
|