@brimveyn/aimux 1.6.2 → 1.7.1
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 +3 -0
- package/package.json +2 -2
- package/src/app-runtime/backend-attach-runtime.ts +6 -0
- package/src/app-runtime/backend-runtime-events.ts +21 -21
- package/src/app-runtime/side-effects.ts +24 -12
- package/src/app-runtime/use-backend-runtime.ts +2 -20
- package/src/app-runtime/use-directory-search.ts +6 -1
- package/src/app.tsx +2 -1
- package/src/config.ts +9 -0
- package/src/daemon/daemon.ts +197 -15
- package/src/daemon/session-manager.ts +9 -0
- package/src/daemon/session-registry.ts +5 -5
- package/src/index.tsx +10 -2
- package/src/input/keymap/help-entries.ts +5 -5
- package/src/input/modes/bridge.ts +5 -8
- package/src/input/modes/transitions.ts +15 -23
- package/src/input/modes/types.ts +2 -5
- package/src/ipc/manager-protocol.ts +2 -2
- package/src/ipc/protocol.ts +49 -5
- package/src/platform/project-search.ts +45 -12
- package/src/pty/assistant-status-detection-loop.ts +192 -0
- package/src/pty/assistant-status-detector.ts +226 -0
- package/src/pty/pty-manager.ts +3 -37
- package/src/session-backend/bootstrap.ts +25 -2
- package/src/session-backend/local-session-backend.ts +43 -56
- package/src/session-backend/remote-session-backend.ts +15 -0
- package/src/session-backend/types.ts +10 -1
- package/src/state/reducers/modal-state.ts +91 -134
- package/src/state/reducers/session-state.ts +13 -6
- package/src/state/reducers/tab-state.ts +0 -10
- package/src/state/selectors.ts +12 -0
- package/src/state/session-persistence.ts +20 -15
- package/src/state/store.ts +11 -3
- package/src/state/types.ts +29 -13
- package/src/ui/breaking-update-screen.tsx +31 -0
- package/src/ui/components/bare-input.tsx +44 -0
- package/src/ui/components/create-session-modal.tsx +16 -8
- package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
- package/src/ui/components/diff-renderer/split-view.tsx +11 -17
- package/src/ui/components/diff-renderer/stacked-view.tsx +7 -14
- package/src/ui/components/git-panel.tsx +10 -3
- package/src/ui/components/git-view.tsx +4 -8
- package/src/ui/components/help-modal.tsx +45 -160
- package/src/ui/components/input-field.tsx +6 -2
- package/src/ui/components/list-item.tsx +36 -28
- package/src/ui/components/modal-shell.tsx +51 -13
- package/src/ui/components/new-tab-modal.tsx +78 -45
- package/src/ui/components/picker.tsx +179 -0
- package/src/ui/components/session-bar.tsx +47 -21
- package/src/ui/components/session-picker-modal.tsx +58 -56
- package/src/ui/components/sidebar.tsx +49 -22
- package/src/ui/components/snippet-picker-modal.tsx +43 -34
- package/src/ui/components/status-bar.tsx +3 -2
- package/src/ui/components/surface.tsx +11 -8
- package/src/ui/components/tab-item.tsx +38 -22
- package/src/ui/components/terminal-pane.tsx +8 -8
- package/src/ui/components/theme-picker-modal.tsx +51 -91
- package/src/ui/root.tsx +14 -23
- package/src/ui/status-bar-model.ts +5 -5
- package/src/ui/theme-store.ts +26 -2
- package/src/ui/theme.ts +9 -1
- package/src/ui/components/modal-filter-bar.tsx +0 -19
|
@@ -8,16 +8,22 @@ export type ListItemDirection = 'row' | 'column'
|
|
|
8
8
|
interface ListItemProps {
|
|
9
9
|
active: boolean
|
|
10
10
|
direction?: ListItemDirection
|
|
11
|
+
id?: string
|
|
11
12
|
leading?: ReactNode
|
|
12
13
|
subtitle?: ReactNode
|
|
13
14
|
title: ReactNode
|
|
14
15
|
trailing?: ReactNode
|
|
16
|
+
onHover?: () => void
|
|
17
|
+
onClick?: () => void
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export function ListItem({
|
|
18
21
|
active,
|
|
19
22
|
direction = 'column',
|
|
23
|
+
id,
|
|
20
24
|
leading,
|
|
25
|
+
onClick,
|
|
26
|
+
onHover,
|
|
21
27
|
subtitle,
|
|
22
28
|
title,
|
|
23
29
|
trailing,
|
|
@@ -25,34 +31,36 @@ export function ListItem({
|
|
|
25
31
|
const theme = useTheme()
|
|
26
32
|
const isRow = direction === 'row'
|
|
27
33
|
return (
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<box flexDirection="
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
34
|
+
<box id={id} onMouseOver={onHover} onMouseDown={onClick}>
|
|
35
|
+
<Surface
|
|
36
|
+
tone={active ? 'selected' : 'elevated'}
|
|
37
|
+
paddingLeft={isRow ? 2 : 1}
|
|
38
|
+
paddingRight={isRow ? 2 : 1}
|
|
39
|
+
>
|
|
40
|
+
<box flexDirection="column">
|
|
41
|
+
<box flexDirection="row">
|
|
42
|
+
{isRow ? null : (
|
|
43
|
+
<>
|
|
44
|
+
<text
|
|
45
|
+
fg={
|
|
46
|
+
active
|
|
47
|
+
? theme.colors['textLink.foreground']
|
|
48
|
+
: theme.colors['editor.lineHighlightBackground']
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
{active ? '›' : '·'}
|
|
52
|
+
</text>
|
|
53
|
+
<text> </text>
|
|
54
|
+
</>
|
|
55
|
+
)}
|
|
56
|
+
{leading}
|
|
57
|
+
{leading ? <text> </text> : null}
|
|
58
|
+
<box flexGrow={isRow ? 0 : 1}>{title}</box>
|
|
59
|
+
{trailing ? <box>{trailing}</box> : null}
|
|
60
|
+
</box>
|
|
61
|
+
{subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
|
|
53
62
|
</box>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
</Surface>
|
|
63
|
+
</Surface>
|
|
64
|
+
</box>
|
|
57
65
|
)
|
|
58
66
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
|
-
import type { ReactNode } from 'react'
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
|
|
4
|
+
import { type ReactNode } from 'react'
|
|
5
|
+
|
|
6
|
+
import { useTheme, useTransparent } from '../theme'
|
|
5
7
|
import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
|
|
6
|
-
import { Surface } from './surface'
|
|
7
8
|
|
|
8
9
|
interface ModalShellProps {
|
|
9
10
|
children: ReactNode
|
|
@@ -15,6 +16,43 @@ interface ModalShellProps {
|
|
|
15
16
|
width: number | `${number}%`
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
|
|
20
|
+
|
|
21
|
+
// opentui's BoxRenderable only paints a fill when `backgroundColor.a > 0`, so
|
|
22
|
+
// a fully transparent modal bg leaks the chrome characters underneath. The
|
|
23
|
+
// `renderAfter` hook runs after the box paints itself but before its children
|
|
24
|
+
// render, so we manually overwrite every interior cell with a blank space.
|
|
25
|
+
//
|
|
26
|
+
// Two gotchas we hit by reading the zig source (packages/core/src/zig/buffer.zig):
|
|
27
|
+
// 1. `setCellWithAlphaBlending` early-returns via `isFullyTransparent` when
|
|
28
|
+
// both fg and bg alpha are 0 — nothing gets written.
|
|
29
|
+
// 2. Its `blendCells` deliberately preserves the destination char when the
|
|
30
|
+
// overlay char is `DEFAULT_SPACE_CHAR` (codepoint 32) so that drawing a
|
|
31
|
+
// space on top of existing text does not erase it.
|
|
32
|
+
//
|
|
33
|
+
// `setCell` (→ `bufferSetCell` → zig `buffer.set`) bypasses both: it writes
|
|
34
|
+
// the cell unconditionally, no alpha check, no blending, no char preservation.
|
|
35
|
+
// That overwrites each chrome char with a space while keeping the cell bg
|
|
36
|
+
// transparent, so the terminal emulator's own (blurred) window bg still shows
|
|
37
|
+
// through. Children (modal content) then render on top as normal.
|
|
38
|
+
function fillModalInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
|
|
39
|
+
const x0 = this.screenX
|
|
40
|
+
const y0 = this.screenY
|
|
41
|
+
const w = this.width
|
|
42
|
+
const h = this.height
|
|
43
|
+
// Inset by 1 on each side so we don't erase the border that renderSelf just
|
|
44
|
+
// drew. The modal always has a single-cell border.
|
|
45
|
+
const startX = x0 + 1
|
|
46
|
+
const startY = y0 + 1
|
|
47
|
+
const endX = x0 + w - 1
|
|
48
|
+
const endY = y0 + h - 1
|
|
49
|
+
for (let y = startY; y < endY; y++) {
|
|
50
|
+
for (let x = startX; x < endX; x++) {
|
|
51
|
+
buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
18
56
|
export function ModalShell({
|
|
19
57
|
children,
|
|
20
58
|
footer,
|
|
@@ -25,6 +63,8 @@ export function ModalShell({
|
|
|
25
63
|
width,
|
|
26
64
|
}: ModalShellProps) {
|
|
27
65
|
const theme = useTheme()
|
|
66
|
+
const transparent = useTransparent()
|
|
67
|
+
const bg = transparent ? 'transparent' : theme.colors['sideBar.background']
|
|
28
68
|
return (
|
|
29
69
|
<box
|
|
30
70
|
position="absolute"
|
|
@@ -36,15 +76,13 @@ export function ModalShell({
|
|
|
36
76
|
alignItems="center"
|
|
37
77
|
>
|
|
38
78
|
<box
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/>
|
|
47
|
-
<Surface tone="elevated" padding={1} gap={1} width={width}>
|
|
79
|
+
border
|
|
80
|
+
borderColor={theme.colors['focusBorder']}
|
|
81
|
+
backgroundColor={bg}
|
|
82
|
+
padding={1}
|
|
83
|
+
width={width}
|
|
84
|
+
renderAfter={transparent ? fillModalInteriorWithSpaces : undefined}
|
|
85
|
+
>
|
|
48
86
|
<box width="100%" flexDirection="column" gap={listGap}>
|
|
49
87
|
<box flexDirection="column">
|
|
50
88
|
<text fg={theme.colors['terminal.ansiMagenta']}>{title}</text>
|
|
@@ -53,7 +91,7 @@ export function ModalShell({
|
|
|
53
91
|
{children}
|
|
54
92
|
{footer ? <box>{footer}</box> : null}
|
|
55
93
|
</box>
|
|
56
|
-
</
|
|
94
|
+
</box>
|
|
57
95
|
{keybindsModeId ? <ModalKeybindsOverlay modeId={keybindsModeId} /> : null}
|
|
58
96
|
</box>
|
|
59
97
|
)
|
|
@@ -1,63 +1,96 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AssistantId } from '../../state/types'
|
|
2
|
+
|
|
3
|
+
import { getAllAssistantOptions, getAssistantOption } from '../../pty/command-registry'
|
|
4
|
+
import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
5
|
+
import { filterAssistants } from '../../state/selectors'
|
|
2
6
|
import { useTheme } from '../theme'
|
|
3
7
|
import { uiTokens } from '../ui-tokens'
|
|
4
8
|
import { InputField } from './input-field'
|
|
5
|
-
import { ListItem } from './list-item'
|
|
6
9
|
import { ModalShell } from './modal-shell'
|
|
10
|
+
import { Picker, type PickerItem } from './picker'
|
|
7
11
|
|
|
8
12
|
interface NewTabModalProps {
|
|
9
13
|
selectedIndex: number
|
|
10
14
|
customCommands: Record<string, string>
|
|
11
|
-
|
|
15
|
+
filter: string | null
|
|
16
|
+
cursorPos?: number
|
|
17
|
+
editingCommand: AssistantId | null
|
|
18
|
+
editBuffer: string
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
export function NewTabModal({
|
|
21
|
+
export function NewTabModal({
|
|
22
|
+
cursorPos,
|
|
23
|
+
customCommands,
|
|
24
|
+
editBuffer,
|
|
25
|
+
editingCommand,
|
|
26
|
+
filter,
|
|
27
|
+
selectedIndex,
|
|
28
|
+
}: NewTabModalProps) {
|
|
15
29
|
const theme = useTheme()
|
|
30
|
+
|
|
31
|
+
if (editingCommand !== null) {
|
|
32
|
+
const option =
|
|
33
|
+
getAllAssistantOptions(customCommands).find((o) => o.id === editingCommand) ??
|
|
34
|
+
getAssistantOption(0)
|
|
35
|
+
return (
|
|
36
|
+
<ModalShell
|
|
37
|
+
title={`Edit command — ${option.label}`}
|
|
38
|
+
keybindsModeId="modal.new-tab.editing-command"
|
|
39
|
+
width={uiTokens.modalWidth.md}
|
|
40
|
+
>
|
|
41
|
+
<box flexDirection="column">
|
|
42
|
+
<text fg={theme.colors['descriptionForeground']}>
|
|
43
|
+
Custom command (blank to reset to default: {option.command})
|
|
44
|
+
</text>
|
|
45
|
+
<InputField
|
|
46
|
+
active
|
|
47
|
+
value={editBuffer}
|
|
48
|
+
cursorPos={cursorPos}
|
|
49
|
+
placeholder={option.command}
|
|
50
|
+
/>
|
|
51
|
+
</box>
|
|
52
|
+
</ModalShell>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
16
56
|
const options = getAllAssistantOptions(customCommands)
|
|
17
|
-
const
|
|
18
|
-
|
|
57
|
+
const filtered = filterAssistants(options, filter)
|
|
58
|
+
|
|
59
|
+
const items: PickerItem[] = filtered.map((option, index) => {
|
|
60
|
+
const active = index === selectedIndex
|
|
61
|
+
const customCmd = customCommands[option.id]
|
|
62
|
+
return {
|
|
63
|
+
key: option.id,
|
|
64
|
+
onClick: () => runSideEffectGlobal({ type: 'launch-selected-assistant' }),
|
|
65
|
+
onEdit: () => dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
|
|
66
|
+
subtitle: (
|
|
67
|
+
<box flexDirection="column">
|
|
68
|
+
<text fg={theme.colors['descriptionForeground']}>{option.description}</text>
|
|
69
|
+
{customCmd ? <text fg={theme.colors['textLink.foreground']}>{customCmd}</text> : null}
|
|
70
|
+
</box>
|
|
71
|
+
),
|
|
72
|
+
title: (
|
|
73
|
+
<text
|
|
74
|
+
fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
|
|
75
|
+
>
|
|
76
|
+
{option.label}
|
|
77
|
+
</text>
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
})
|
|
19
81
|
|
|
20
82
|
return (
|
|
21
|
-
<
|
|
83
|
+
<Picker
|
|
22
84
|
title="New assistant tab"
|
|
23
|
-
|
|
24
|
-
keybindsModeId={isEditing ? 'modal.new-tab.command-edit' : 'modal.new-tab'}
|
|
85
|
+
keybindsModeId="modal.new-tab.command-edit"
|
|
25
86
|
width={uiTokens.modalWidth.md}
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return (
|
|
35
|
-
<ListItem
|
|
36
|
-
key={option.id}
|
|
37
|
-
active={active}
|
|
38
|
-
title={
|
|
39
|
-
<text
|
|
40
|
-
fg={
|
|
41
|
-
active
|
|
42
|
-
? theme.colors['editor.foreground']
|
|
43
|
-
: theme.colors['descriptionForeground']
|
|
44
|
-
}
|
|
45
|
-
>
|
|
46
|
-
{option.label}
|
|
47
|
-
</text>
|
|
48
|
-
}
|
|
49
|
-
subtitle={
|
|
50
|
-
<box flexDirection="column">
|
|
51
|
-
<text fg={theme.colors['descriptionForeground']}>{option.description}</text>
|
|
52
|
-
{customCmd ? (
|
|
53
|
-
<text fg={theme.colors['textLink.foreground']}>{customCmd}</text>
|
|
54
|
-
) : null}
|
|
55
|
-
</box>
|
|
56
|
-
}
|
|
57
|
-
/>
|
|
58
|
-
)
|
|
59
|
-
})
|
|
60
|
-
)}
|
|
61
|
-
</ModalShell>
|
|
87
|
+
gap={1}
|
|
88
|
+
filter={filter}
|
|
89
|
+
cursorPos={cursorPos}
|
|
90
|
+
items={items}
|
|
91
|
+
selectedIndex={selectedIndex}
|
|
92
|
+
emptyState={<text fg={theme.colors['descriptionForeground']}>No matching assistants.</text>}
|
|
93
|
+
onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
|
|
94
|
+
/>
|
|
62
95
|
)
|
|
63
96
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
|
+
import type { ScrollBoxRenderable } from '@opentui/core'
|
|
3
|
+
|
|
4
|
+
import { useTerminalDimensions } from '@opentui/react'
|
|
5
|
+
import { type ReactNode, useLayoutEffect, useRef } from 'react'
|
|
6
|
+
|
|
7
|
+
import { useTheme } from '../theme'
|
|
8
|
+
import { BareInput } from './bare-input'
|
|
9
|
+
import { ListItem } from './list-item'
|
|
10
|
+
import { ModalShell } from './modal-shell'
|
|
11
|
+
|
|
12
|
+
export interface PickerItem {
|
|
13
|
+
key: string
|
|
14
|
+
group?: string
|
|
15
|
+
title: ReactNode
|
|
16
|
+
subtitle?: ReactNode
|
|
17
|
+
trailing?: ReactNode
|
|
18
|
+
onClick?: () => void
|
|
19
|
+
onEdit?: () => void
|
|
20
|
+
onDelete?: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PickerProps {
|
|
24
|
+
title: string
|
|
25
|
+
width: number | `${number}%`
|
|
26
|
+
keybindsModeId?: ModeId
|
|
27
|
+
listGap?: number
|
|
28
|
+
gap?: number
|
|
29
|
+
footer?: ReactNode
|
|
30
|
+
items: PickerItem[]
|
|
31
|
+
selectedIndex: number
|
|
32
|
+
emptyState?: ReactNode
|
|
33
|
+
onHover: (index: number) => void
|
|
34
|
+
filter: string | null
|
|
35
|
+
cursorPos?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const VIEWPORT_HEIGHT_RATIO = 0.6
|
|
39
|
+
const MODAL_CHROME_ROWS = 6
|
|
40
|
+
|
|
41
|
+
function PickerItemCtas({ onDelete, onEdit }: { onEdit?: () => void; onDelete?: () => void }) {
|
|
42
|
+
const theme = useTheme()
|
|
43
|
+
return (
|
|
44
|
+
<box flexDirection="row" gap={1}>
|
|
45
|
+
{onEdit ? (
|
|
46
|
+
<box
|
|
47
|
+
onMouseDown={(event) => {
|
|
48
|
+
event.stopPropagation()
|
|
49
|
+
onEdit()
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<text fg={theme.colors['textLink.foreground']}>[edit]</text>
|
|
53
|
+
</box>
|
|
54
|
+
) : null}
|
|
55
|
+
{onDelete ? (
|
|
56
|
+
<box
|
|
57
|
+
onMouseDown={(event) => {
|
|
58
|
+
event.stopPropagation()
|
|
59
|
+
onDelete()
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<text fg={theme.colors['editorError.foreground']}>[del]</text>
|
|
63
|
+
</box>
|
|
64
|
+
) : null}
|
|
65
|
+
</box>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function Picker({
|
|
70
|
+
cursorPos,
|
|
71
|
+
emptyState,
|
|
72
|
+
filter,
|
|
73
|
+
footer,
|
|
74
|
+
gap = 0,
|
|
75
|
+
items,
|
|
76
|
+
keybindsModeId,
|
|
77
|
+
listGap,
|
|
78
|
+
onHover,
|
|
79
|
+
selectedIndex,
|
|
80
|
+
title,
|
|
81
|
+
width,
|
|
82
|
+
}: PickerProps) {
|
|
83
|
+
const theme = useTheme()
|
|
84
|
+
const dimensions = useTerminalDimensions()
|
|
85
|
+
const maxHeight = Math.max(6, Math.floor(dimensions.height * VIEWPORT_HEIGHT_RATIO))
|
|
86
|
+
const listHeight = Math.max(1, maxHeight - MODAL_CHROME_ROWS)
|
|
87
|
+
|
|
88
|
+
const scrollboxRef = useRef<ScrollBoxRenderable | null>(null)
|
|
89
|
+
const isScrollingRef = useRef(false)
|
|
90
|
+
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
91
|
+
const skipNextScrollRef = useRef(false)
|
|
92
|
+
|
|
93
|
+
const resetScrollTimer = () => {
|
|
94
|
+
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
|
|
95
|
+
scrollTimerRef.current = setTimeout(() => {
|
|
96
|
+
isScrollingRef.current = false
|
|
97
|
+
scrollTimerRef.current = null
|
|
98
|
+
}, 10)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
useLayoutEffect(() => {
|
|
102
|
+
if (!scrollboxRef.current) return
|
|
103
|
+
if (skipNextScrollRef.current) {
|
|
104
|
+
skipNextScrollRef.current = false
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const target = items[selectedIndex]?.key
|
|
108
|
+
if (!target) return
|
|
109
|
+
isScrollingRef.current = true
|
|
110
|
+
scrollboxRef.current.scrollChildIntoView(target)
|
|
111
|
+
resetScrollTimer()
|
|
112
|
+
}, [selectedIndex, items])
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<ModalShell
|
|
116
|
+
title={title}
|
|
117
|
+
width={width}
|
|
118
|
+
keybindsModeId={keybindsModeId}
|
|
119
|
+
listGap={listGap}
|
|
120
|
+
footer={footer}
|
|
121
|
+
>
|
|
122
|
+
<BareInput value={filter ?? ''} cursorPos={cursorPos} placeholder="Type to filter..." />
|
|
123
|
+
{items.length === 0 ? (emptyState ?? null) : null}
|
|
124
|
+
<scrollbox
|
|
125
|
+
ref={scrollboxRef}
|
|
126
|
+
scrollY
|
|
127
|
+
height={items.length === 0 ? 0 : listHeight}
|
|
128
|
+
contentOptions={{ flexDirection: 'column', gap }}
|
|
129
|
+
onMouseScroll={() => {
|
|
130
|
+
isScrollingRef.current = true
|
|
131
|
+
resetScrollTimer()
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{(() => {
|
|
135
|
+
let prevGroup: string | undefined
|
|
136
|
+
const nodes: ReactNode[] = []
|
|
137
|
+
for (let index = 0; index < items.length; index++) {
|
|
138
|
+
const item = items[index]
|
|
139
|
+
if (!item) continue
|
|
140
|
+
if (item.group && item.group !== prevGroup) {
|
|
141
|
+
nodes.push(
|
|
142
|
+
<box key={`group::${item.group}`} paddingLeft={1} paddingTop={index === 0 ? 0 : 1}>
|
|
143
|
+
<text fg={theme.colors['editorWarning.foreground']} wrapMode="none">
|
|
144
|
+
{item.group}
|
|
145
|
+
</text>
|
|
146
|
+
</box>
|
|
147
|
+
)
|
|
148
|
+
prevGroup = item.group
|
|
149
|
+
}
|
|
150
|
+
const active = index === selectedIndex
|
|
151
|
+
const capturedIndex = index
|
|
152
|
+
const trailing =
|
|
153
|
+
item.trailing ??
|
|
154
|
+
(active && (item.onEdit || item.onDelete) ? (
|
|
155
|
+
<PickerItemCtas onEdit={item.onEdit} onDelete={item.onDelete} />
|
|
156
|
+
) : null)
|
|
157
|
+
nodes.push(
|
|
158
|
+
<ListItem
|
|
159
|
+
key={item.key}
|
|
160
|
+
id={item.key}
|
|
161
|
+
active={active}
|
|
162
|
+
title={item.title}
|
|
163
|
+
subtitle={item.subtitle}
|
|
164
|
+
trailing={trailing}
|
|
165
|
+
onHover={() => {
|
|
166
|
+
if (isScrollingRef.current) return
|
|
167
|
+
skipNextScrollRef.current = true
|
|
168
|
+
onHover(capturedIndex)
|
|
169
|
+
}}
|
|
170
|
+
onClick={item.onClick}
|
|
171
|
+
/>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
return nodes
|
|
175
|
+
})()}
|
|
176
|
+
</scrollbox>
|
|
177
|
+
</ModalShell>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -2,20 +2,23 @@ import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
|
2
2
|
|
|
3
3
|
import { useMemo, useRef, useState } from 'react'
|
|
4
4
|
|
|
5
|
-
import type { SessionRecord } from '../../state/types'
|
|
5
|
+
import type { SessionRecord, SessionStatus } from '../../state/types'
|
|
6
6
|
|
|
7
7
|
import { useAppStore } from '../../state/app-store'
|
|
8
8
|
import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
9
|
+
// eslint-disable-next-line no-duplicate-imports
|
|
10
|
+
import { IDLE_SESSION_STATUS } from '../../state/types'
|
|
9
11
|
import { useBusySpinner } from '../hooks/use-busy-spinner'
|
|
10
12
|
import { moveIdToIdPosition, orderSessionsForDisplay } from '../session-ordering'
|
|
11
|
-
import { useTheme } from '../theme'
|
|
13
|
+
import { useBg, useTheme } from '../theme'
|
|
12
14
|
|
|
13
15
|
export function SessionBar() {
|
|
14
16
|
const theme = useTheme()
|
|
17
|
+
const headerBg = useBg('sideBarSectionHeader.background')
|
|
15
18
|
const sessions = useAppStore((s) => s.sessions)
|
|
16
19
|
const currentId = useAppStore((s) => s.currentSessionId)
|
|
17
20
|
const bar = useAppStore((s) => s.sessionBar)
|
|
18
|
-
const
|
|
21
|
+
const statusMap = useAppStore((s) => s.sessionStatuses)
|
|
19
22
|
|
|
20
23
|
const [draggingId, setDraggingId] = useState<string | null>(null)
|
|
21
24
|
const [dragOrder, setDragOrder] = useState<string[] | null>(null)
|
|
@@ -60,13 +63,10 @@ export function SessionBar() {
|
|
|
60
63
|
if (!draggingId) return
|
|
61
64
|
const hit = findChipAtX(event.x)
|
|
62
65
|
if (hit === null) {
|
|
63
|
-
// Cursor left the bar entirely — allow the next hit to re-trigger a swap.
|
|
64
66
|
lastSwapWithRef.current = null
|
|
65
67
|
return
|
|
66
68
|
}
|
|
67
69
|
if (hit === draggingId) {
|
|
68
|
-
// Over the dragged chip itself — reset hysteresis so re-entering a
|
|
69
|
-
// neighbour can swap again.
|
|
70
70
|
lastSwapWithRef.current = null
|
|
71
71
|
return
|
|
72
72
|
}
|
|
@@ -90,7 +90,6 @@ export function SessionBar() {
|
|
|
90
90
|
return
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// Drag did not change anything → treat as click, switch to that session.
|
|
94
93
|
const idx = baselineOrder.indexOf(source)
|
|
95
94
|
if (idx >= 0) {
|
|
96
95
|
runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
|
|
@@ -109,7 +108,7 @@ export function SessionBar() {
|
|
|
109
108
|
flexDirection="row"
|
|
110
109
|
paddingLeft={1}
|
|
111
110
|
paddingRight={1}
|
|
112
|
-
backgroundColor={
|
|
111
|
+
backgroundColor={headerBg}
|
|
113
112
|
>
|
|
114
113
|
{visibleSessions.map((session) => {
|
|
115
114
|
const displayIndex = baselineOrder.indexOf(session.id) + 1
|
|
@@ -119,7 +118,7 @@ export function SessionBar() {
|
|
|
119
118
|
session={session}
|
|
120
119
|
index={displayIndex}
|
|
121
120
|
active={session.id === currentId}
|
|
122
|
-
|
|
121
|
+
status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
|
|
123
122
|
dragging={draggingId === session.id}
|
|
124
123
|
onRef={(r) => setChipRef(session.id, r)}
|
|
125
124
|
onMouseDown={() => handleMouseDown(session.id)}
|
|
@@ -129,6 +128,21 @@ export function SessionBar() {
|
|
|
129
128
|
/>
|
|
130
129
|
)
|
|
131
130
|
})}
|
|
131
|
+
<box flexGrow={1} />
|
|
132
|
+
<box
|
|
133
|
+
flexDirection="row"
|
|
134
|
+
paddingLeft={1}
|
|
135
|
+
paddingRight={1}
|
|
136
|
+
backgroundColor={theme.colors['list.activeSelectionBackground']}
|
|
137
|
+
onMouseDown={(e) => {
|
|
138
|
+
e.stopPropagation()
|
|
139
|
+
dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<text fg={theme.colors['editor.foreground']} selectable={false}>
|
|
143
|
+
+ New
|
|
144
|
+
</text>
|
|
145
|
+
</box>
|
|
132
146
|
</box>
|
|
133
147
|
)
|
|
134
148
|
}
|
|
@@ -145,7 +159,7 @@ interface SessionChipProps {
|
|
|
145
159
|
session: SessionRecord
|
|
146
160
|
index: number
|
|
147
161
|
active: boolean
|
|
148
|
-
|
|
162
|
+
status: SessionStatus
|
|
149
163
|
dragging: boolean
|
|
150
164
|
onRef: (ref: BoxRenderable | null) => void
|
|
151
165
|
onMouseDown: (event: OtuiMouseEvent) => void
|
|
@@ -156,7 +170,6 @@ interface SessionChipProps {
|
|
|
156
170
|
|
|
157
171
|
function SessionChip({
|
|
158
172
|
active,
|
|
159
|
-
busy,
|
|
160
173
|
dragging,
|
|
161
174
|
index,
|
|
162
175
|
onMouseDown,
|
|
@@ -165,19 +178,20 @@ function SessionChip({
|
|
|
165
178
|
onMouseUp,
|
|
166
179
|
onRef,
|
|
167
180
|
session,
|
|
181
|
+
status,
|
|
168
182
|
}: SessionChipProps) {
|
|
169
183
|
const theme = useTheme()
|
|
170
|
-
const
|
|
184
|
+
const selectionBg = useBg('list.activeSelectionBackground')
|
|
185
|
+
const showSpinner = status.working
|
|
186
|
+
const showWaiting = status.waiting
|
|
171
187
|
const spinner = useBusySpinner(showSpinner)
|
|
172
|
-
const indicator = showSpinner ? spinner : '●'
|
|
173
|
-
const indicatorColor =
|
|
174
|
-
active || showSpinner
|
|
175
|
-
? theme.colors['textLink.foreground']
|
|
176
|
-
: theme.colors['gitDecoration.addedResourceForeground']
|
|
177
188
|
const labelColor = active
|
|
178
189
|
? theme.colors['editor.foreground']
|
|
179
190
|
: theme.colors['descriptionForeground']
|
|
180
|
-
const bgColor = dragging || active ?
|
|
191
|
+
const bgColor = dragging || active ? selectionBg : undefined
|
|
192
|
+
const idleColor = theme.colors['gitDecoration.addedResourceForeground'] ?? ''
|
|
193
|
+
const workingColor = theme.colors['textLink.foreground'] ?? ''
|
|
194
|
+
const waitingColor = theme.colors['editorWarning.foreground'] ?? ''
|
|
181
195
|
|
|
182
196
|
return (
|
|
183
197
|
<box
|
|
@@ -201,9 +215,21 @@ function SessionChip({
|
|
|
201
215
|
onMouseDragEnd(e)
|
|
202
216
|
}}
|
|
203
217
|
>
|
|
204
|
-
|
|
205
|
-
{
|
|
206
|
-
|
|
218
|
+
{showWaiting ? (
|
|
219
|
+
<text fg={waitingColor} selectable={false}>
|
|
220
|
+
?{' '}
|
|
221
|
+
</text>
|
|
222
|
+
) : null}
|
|
223
|
+
{showSpinner ? (
|
|
224
|
+
<text fg={workingColor} selectable={false}>
|
|
225
|
+
{spinner}{' '}
|
|
226
|
+
</text>
|
|
227
|
+
) : null}
|
|
228
|
+
{!showWaiting && !showSpinner ? (
|
|
229
|
+
<text fg={active ? workingColor : idleColor} selectable={false}>
|
|
230
|
+
{'● '}
|
|
231
|
+
</text>
|
|
232
|
+
) : null}
|
|
207
233
|
<text fg={labelColor} selectable={false}>
|
|
208
234
|
[{index}] {session.name}
|
|
209
235
|
</text>
|