@brimveyn/aimux 1.23.4 → 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/workspace-actions.ts +11 -0
- 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/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/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
|
|
@@ -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
|
}
|
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
|
|
|
@@ -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
|
}
|