@brimveyn/aimux 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +43 -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 +6 -1
- package/src/config.ts +28 -1
- 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/git/diff-hash.ts +10 -0
- 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 +3 -5
- 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 +4 -1
- 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/git-tree.ts +42 -16
- package/src/state/reducers/diff-cache.ts +64 -0
- package/src/state/reducers/git-mode-state.ts +91 -33
- package/src/state/reducers/git-panel-state.ts +33 -2
- 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 +13 -3
- package/src/state/types.ts +87 -14
- 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/pierre-diff.tsx +9 -31
- package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
- package/src/ui/components/diff-renderer/split-view.tsx +35 -16
- package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
- package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
- package/src/ui/components/git-pane-widget.tsx +2 -0
- package/src/ui/components/git-panel.tsx +27 -10
- package/src/ui/components/git-view.tsx +23 -9
- 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
|
@@ -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>
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import type { SessionRecord } from '../../state/types'
|
|
2
2
|
|
|
3
|
+
import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
3
4
|
import { filterSessions } from '../../state/selectors'
|
|
4
5
|
import { abbreviatePath } from '../path-format'
|
|
5
6
|
import { orderSessionsForDisplay } from '../session-ordering'
|
|
6
7
|
import { useTheme } from '../theme'
|
|
7
8
|
import { uiTokens } from '../ui-tokens'
|
|
8
|
-
import {
|
|
9
|
-
import { ModalFilterBar } from './modal-filter-bar'
|
|
10
|
-
import { ModalShell } from './modal-shell'
|
|
9
|
+
import { Picker, type PickerItem } from './picker'
|
|
11
10
|
|
|
12
11
|
interface SessionPickerModalProps {
|
|
13
12
|
sessions: SessionRecord[]
|
|
@@ -15,6 +14,7 @@ interface SessionPickerModalProps {
|
|
|
15
14
|
currentSessionId: string | null
|
|
16
15
|
currentTabCount: number
|
|
17
16
|
filter: string | null
|
|
17
|
+
cursorPos?: number
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function formatSessionLine(
|
|
@@ -41,6 +41,7 @@ function getEmptyStateMessage(hasFilter: boolean): string {
|
|
|
41
41
|
export function SessionPickerModal({
|
|
42
42
|
currentSessionId,
|
|
43
43
|
currentTabCount,
|
|
44
|
+
cursorPos,
|
|
44
45
|
filter,
|
|
45
46
|
selectedIndex,
|
|
46
47
|
sessions,
|
|
@@ -50,62 +51,63 @@ export function SessionPickerModal({
|
|
|
50
51
|
const baselineOrder = ordered.map((s) => s.id)
|
|
51
52
|
const filtered = filterSessions(ordered, filter)
|
|
52
53
|
const hasFilter = !!filter
|
|
53
|
-
|
|
54
|
-
const
|
|
54
|
+
|
|
55
|
+
const sessionItems: PickerItem[] = filtered.map((session, index) => {
|
|
56
|
+
const active = index === selectedIndex
|
|
57
|
+
const displayIndex = baselineOrder.indexOf(session.id) + 1
|
|
58
|
+
return {
|
|
59
|
+
key: session.id,
|
|
60
|
+
onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
|
|
61
|
+
onDelete: () => runSideEffectGlobal({ type: 'delete-selected-session' }),
|
|
62
|
+
subtitle: session.projectPath ? (
|
|
63
|
+
<text fg={theme.colors['descriptionForeground']}>
|
|
64
|
+
{abbreviatePath(session.projectPath)}
|
|
65
|
+
</text>
|
|
66
|
+
) : undefined,
|
|
67
|
+
title: (
|
|
68
|
+
<text
|
|
69
|
+
fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
|
|
70
|
+
>
|
|
71
|
+
{formatSessionLine(session, currentSessionId, currentTabCount, displayIndex)}
|
|
72
|
+
</text>
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const createNewItem: PickerItem = {
|
|
78
|
+
key: '__create-new__',
|
|
79
|
+
onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
|
|
80
|
+
title: (
|
|
81
|
+
<text
|
|
82
|
+
fg={
|
|
83
|
+
selectedIndex === filtered.length
|
|
84
|
+
? theme.colors['editor.foreground']
|
|
85
|
+
: theme.colors['descriptionForeground']
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
Create new session
|
|
89
|
+
</text>
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const items = [...sessionItems, createNewItem]
|
|
55
94
|
|
|
56
95
|
return (
|
|
57
|
-
<
|
|
96
|
+
<Picker
|
|
58
97
|
title="Sessions"
|
|
59
|
-
keybindsModeId="modal.session-picker"
|
|
98
|
+
keybindsModeId="modal.session-picker.filtering"
|
|
60
99
|
width={uiTokens.modalWidth.lg}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<ListItem
|
|
74
|
-
key={session.id}
|
|
75
|
-
active={active}
|
|
76
|
-
title={
|
|
77
|
-
<text
|
|
78
|
-
fg={
|
|
79
|
-
active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
|
|
80
|
-
}
|
|
81
|
-
>
|
|
82
|
-
{formatSessionLine(session, currentSessionId, currentTabCount, displayIndex)}
|
|
83
|
-
</text>
|
|
84
|
-
}
|
|
85
|
-
subtitle={
|
|
86
|
-
session.projectPath ? (
|
|
87
|
-
<text fg={theme.colors['descriptionForeground']}>
|
|
88
|
-
{abbreviatePath(session.projectPath)}
|
|
89
|
-
</text>
|
|
90
|
-
) : undefined
|
|
91
|
-
}
|
|
92
|
-
/>
|
|
93
|
-
)
|
|
94
|
-
})}
|
|
95
|
-
<ListItem
|
|
96
|
-
active={selectedIndex === filtered.length}
|
|
97
|
-
title={
|
|
98
|
-
<text
|
|
99
|
-
fg={
|
|
100
|
-
selectedIndex === filtered.length
|
|
101
|
-
? theme.colors['editor.foreground']
|
|
102
|
-
: theme.colors['descriptionForeground']
|
|
103
|
-
}
|
|
104
|
-
>
|
|
105
|
-
Create new session
|
|
106
|
-
</text>
|
|
107
|
-
}
|
|
108
|
-
/>
|
|
109
|
-
</ModalShell>
|
|
100
|
+
gap={1}
|
|
101
|
+
filter={filter}
|
|
102
|
+
cursorPos={cursorPos}
|
|
103
|
+
items={items}
|
|
104
|
+
selectedIndex={selectedIndex}
|
|
105
|
+
emptyState={
|
|
106
|
+
filtered.length === 0 ? (
|
|
107
|
+
<text fg={theme.colors['descriptionForeground']}>{getEmptyStateMessage(hasFilter)}</text>
|
|
108
|
+
) : undefined
|
|
109
|
+
}
|
|
110
|
+
onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
|
|
111
|
+
/>
|
|
110
112
|
)
|
|
111
113
|
}
|