@brimveyn/aimux 1.14.4 → 1.14.6
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 +1 -1
- package/src/app-runtime/side-effects.ts +7 -1
- package/src/app-runtime/use-terminal-resize.ts +12 -0
- package/src/app.tsx +5 -1
- package/src/state/reducers/session-state.ts +40 -8
- package/src/state/reducers/tab-state.ts +18 -3
- package/src/state/session-worktrees.ts +0 -10
- package/src/ui/components/git/pane/git-pane-header.tsx +1 -1
- package/src/ui/components/layout/sidebar/workspace-list.tsx +13 -14
- package/src/ui/components/layout/sidebar/worktree-row.tsx +30 -8
- package/src/ui/components/layout/terminal-pane.tsx +46 -3
- package/src/ui/components/layout/top-tab-bar.tsx +3 -1
package/package.json
CHANGED
|
@@ -1059,7 +1059,13 @@ async function openEditorInline(
|
|
|
1059
1059
|
}
|
|
1060
1060
|
|
|
1061
1061
|
function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
|
|
1062
|
-
const { backend, dispatch
|
|
1062
|
+
const { backend, dispatch } = ctx
|
|
1063
|
+
// Read fresh state. ctx.state is the snapshot from the previous render and
|
|
1064
|
+
// lags behind dispatches that happened in the same JS turn (a worktree-row
|
|
1065
|
+
// click first dispatches set-active-worktree then fires this side effect —
|
|
1066
|
+
// we need to see that just-applied activeWorktreeId so the new session
|
|
1067
|
+
// lands on the right worktree, not its last-saved one).
|
|
1068
|
+
const state = ctx.getState()
|
|
1063
1069
|
const ordered = [...state.sessions].sort(
|
|
1064
1070
|
(a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
|
|
1065
1071
|
)
|
|
@@ -165,6 +165,18 @@ export function useTerminalResize({
|
|
|
165
165
|
// loop so an unchanged box never re-triggers a resize.
|
|
166
166
|
const measuredRef = useRef(new Map<string, { cols: number; rows: number }>())
|
|
167
167
|
|
|
168
|
+
// Drop the dedupe cache when the workspace changes. attach() re-gates every
|
|
169
|
+
// restored tab's paneReady to false; if a tabId carries the same dimensions
|
|
170
|
+
// across workspaces, handleMeasure's prev-match would short-circuit and
|
|
171
|
+
// never call resizeTab({confirmedFromMeasurement:true}), leaving the gate
|
|
172
|
+
// closed forever — the new pane then paints a stale buffered snapshot from
|
|
173
|
+
// the old workspace, which is the 2–3 row offset users see.
|
|
174
|
+
const lastSessionIdRef = useRef(state.currentSessionId)
|
|
175
|
+
if (lastSessionIdRef.current !== state.currentSessionId) {
|
|
176
|
+
lastSessionIdRef.current = state.currentSessionId
|
|
177
|
+
measuredRef.current.clear()
|
|
178
|
+
}
|
|
179
|
+
|
|
168
180
|
// Closed measurement loop: the rendered terminal content box reports its
|
|
169
181
|
// real geometry; that — not the hardcoded chrome model below — is the
|
|
170
182
|
// authority for the PTY/xterm size and the mouse-mapping origin. The model
|
package/src/app.tsx
CHANGED
|
@@ -409,7 +409,11 @@ export function App({
|
|
|
409
409
|
if (!(state.currentSessionId != null && state.currentSessionId !== '')) return
|
|
410
410
|
return getSessionProjectPath(state.sessions.find((s) => s.id === state.currentSessionId))
|
|
411
411
|
},
|
|
412
|
-
|
|
412
|
+
// Read straight from the store, not stateRef. stateRef is only refreshed
|
|
413
|
+
// on render, so within a single JS turn (a click handler that dispatches
|
|
414
|
+
// then fires a side effect) it lags one step behind. appStore.getState
|
|
415
|
+
// reflects every dispatch synchronously.
|
|
416
|
+
getState: () => appStore.getState(),
|
|
413
417
|
renderer,
|
|
414
418
|
setThemeId,
|
|
415
419
|
startStartupGrace,
|
|
@@ -3,7 +3,7 @@ import type { AppAction, AppState } from '../types'
|
|
|
3
3
|
import { moveIdToIdPosition, orderSessionsForDisplay } from '../../ui/session-ordering'
|
|
4
4
|
import { filterSessions } from '../selectors'
|
|
5
5
|
import { restoreWorkspaceState } from '../session-persistence'
|
|
6
|
-
import { withActiveWorktree } from '../session-worktrees'
|
|
6
|
+
import { filterTabsForActiveWorktree, withActiveWorktree } from '../session-worktrees'
|
|
7
7
|
|
|
8
8
|
const CLOSED_MODAL = {
|
|
9
9
|
editBuffer: null,
|
|
@@ -18,9 +18,24 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
18
18
|
const snapshot =
|
|
19
19
|
action.workspaceSnapshot ??
|
|
20
20
|
state.sessions.find((entry) => entry.id === action.sessionId)?.workspaceSnapshot
|
|
21
|
+
const restored = restoreWorkspaceState(state, snapshot)
|
|
22
|
+
// The session's activeWorktreeId may have been patched right before
|
|
23
|
+
// this load (e.g. the cross-workspace branch of handleCycleSidebarItem
|
|
24
|
+
// sets it to the worktree the user just clicked). The snapshot's
|
|
25
|
+
// activeTabId still reflects the *last* worktree they were on, so
|
|
26
|
+
// honor the patched worktree by filtering the restored tab list.
|
|
27
|
+
const loadedSession = state.sessions.find((entry) => entry.id === action.sessionId)
|
|
28
|
+
const visible = filterTabsForActiveWorktree(restored.tabs, loadedSession)
|
|
29
|
+
const activeTabId =
|
|
30
|
+
restored.activeTabId != null &&
|
|
31
|
+
restored.activeTabId !== '' &&
|
|
32
|
+
visible.some((t) => t.id === restored.activeTabId)
|
|
33
|
+
? restored.activeTabId
|
|
34
|
+
: (visible[0]?.id ?? null)
|
|
21
35
|
return {
|
|
22
36
|
...state,
|
|
23
|
-
...
|
|
37
|
+
...restored,
|
|
38
|
+
activeTabId,
|
|
24
39
|
currentSessionId: action.sessionId,
|
|
25
40
|
focusMode: 'navigation',
|
|
26
41
|
modal: CLOSED_MODAL,
|
|
@@ -175,13 +190,30 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
175
190
|
)
|
|
176
191
|
}),
|
|
177
192
|
}
|
|
178
|
-
case 'set-active-worktree':
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
193
|
+
case 'set-active-worktree': {
|
|
194
|
+
const sessions = state.sessions.map((session) =>
|
|
195
|
+
session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
|
|
196
|
+
)
|
|
197
|
+
// Changing the worktree of a non-current session has no effect on
|
|
198
|
+
// which tab the user is looking at — leave activeTabId alone.
|
|
199
|
+
if (action.sessionId !== state.currentSessionId) {
|
|
200
|
+
return { ...state, sessions }
|
|
201
|
+
}
|
|
202
|
+
// For the current session: if the existing activeTabId belongs to
|
|
203
|
+
// the new worktree, keep it. Otherwise hop to the first tab visible
|
|
204
|
+
// under that worktree (or clear it if the worktree is empty — the
|
|
205
|
+
// pane then renders the "this worktree has no tabs" placeholder).
|
|
206
|
+
const updated = sessions.find((s) => s.id === action.sessionId)
|
|
207
|
+
const visible = filterTabsForActiveWorktree(state.tabs, updated)
|
|
208
|
+
const currentStillVisible =
|
|
209
|
+
state.activeTabId != null &&
|
|
210
|
+
state.activeTabId !== '' &&
|
|
211
|
+
visible.some((t) => t.id === state.activeTabId)
|
|
212
|
+
if (currentStillVisible) {
|
|
213
|
+
return { ...state, sessions }
|
|
184
214
|
}
|
|
215
|
+
return { ...state, activeTabId: visible[0]?.id ?? null, sessions }
|
|
216
|
+
}
|
|
185
217
|
case 'update-worktree-record':
|
|
186
218
|
return {
|
|
187
219
|
...state,
|
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
splitNode,
|
|
16
16
|
} from '../layout-tree'
|
|
17
17
|
import { normalizeGroupedTabOrder } from '../session-persistence'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
filterTabsForActiveWorktree,
|
|
20
|
+
orderTabsByWorktree,
|
|
21
|
+
withActiveWorktree,
|
|
22
|
+
} from '../session-worktrees'
|
|
19
23
|
import { createDefaultTerminalModes } from '../terminal-modes'
|
|
20
24
|
|
|
21
25
|
const MAX_BUFFER_LENGTH = 50_000
|
|
@@ -317,12 +321,23 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
317
321
|
)
|
|
318
322
|
}
|
|
319
323
|
case 'hydrate-workspace': {
|
|
324
|
+
// The session's activeWorktreeId may have just been switched by the
|
|
325
|
+
// user (j/k cycle, sidebar click) before the backend attached. Honor
|
|
326
|
+
// that choice by only considering tabs visible in that worktree —
|
|
327
|
+
// otherwise we'd land the active tab on whatever the backend picked
|
|
328
|
+
// (typically the worktree we *last* persisted, not the current one).
|
|
329
|
+
const currentSession =
|
|
330
|
+
state.currentSessionId != null && state.currentSessionId !== ''
|
|
331
|
+
? state.sessions.find((s) => s.id === state.currentSessionId)
|
|
332
|
+
: undefined
|
|
333
|
+
const visibleForWorktree = filterTabsForActiveWorktree(action.tabs, currentSession)
|
|
334
|
+
const visibleIds = new Set(visibleForWorktree.map((t) => t.id))
|
|
320
335
|
const hydratedActiveTabId =
|
|
321
336
|
action.activeTabId != null &&
|
|
322
337
|
action.activeTabId !== '' &&
|
|
323
|
-
|
|
338
|
+
visibleIds.has(action.activeTabId)
|
|
324
339
|
? action.activeTabId
|
|
325
|
-
: (
|
|
340
|
+
: (visibleForWorktree[0]?.id ?? null)
|
|
326
341
|
const tabIds = new Set(action.tabs.map((t) => t.id))
|
|
327
342
|
|
|
328
343
|
// Restore from new multi-tree format or migrate from legacy single tree
|
|
@@ -8,8 +8,6 @@ import type { BranchDivergence, SessionRecord, TabSession, WorktreeRecord } from
|
|
|
8
8
|
import { createPrefixedId } from '../platform/id'
|
|
9
9
|
import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
|
|
10
10
|
|
|
11
|
-
const WORKTREE_COLORS = ['#7dd3fc', '#86efac', '#facc15', '#f0abfc', '#fb7185', '#c4b5fd']
|
|
12
|
-
|
|
13
11
|
export function formatDivergence(divergence: BranchDivergence | undefined): string {
|
|
14
12
|
if (divergence == null) return ''
|
|
15
13
|
const parts: string[] = []
|
|
@@ -18,14 +16,6 @@ export function formatDivergence(divergence: BranchDivergence | undefined): stri
|
|
|
18
16
|
return parts.join(' ')
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
export function getWorktreeColor(worktreeId: string): string {
|
|
22
|
-
let hash = 0
|
|
23
|
-
for (let i = 0; i < worktreeId.length; i++) {
|
|
24
|
-
hash = (hash * 31 + worktreeId.charCodeAt(i)) >>> 0
|
|
25
|
-
}
|
|
26
|
-
return WORKTREE_COLORS[hash % WORKTREE_COLORS.length] ?? '#7dd3fc'
|
|
27
|
-
}
|
|
28
|
-
|
|
29
19
|
export function createPrimaryWorktree(projectPath: string, now: string): WorktreeRecord {
|
|
30
20
|
const id = createPrefixedId('worktree')
|
|
31
21
|
return {
|
|
@@ -76,7 +76,7 @@ export const GitPaneHeader = memo(function GitPaneHeader({
|
|
|
76
76
|
</box>
|
|
77
77
|
) : null}
|
|
78
78
|
{showToggle ? (
|
|
79
|
-
<box flexDirection="row" gap={1} onMouseDown={toggleListMode}>
|
|
79
|
+
<box flexDirection="row" gap={1} paddingLeft={1} onMouseDown={toggleListMode}>
|
|
80
80
|
<text
|
|
81
81
|
selectable={false}
|
|
82
82
|
fg={fileListMode === 'tree' ? t.primary : t.textMuted}
|
|
@@ -10,7 +10,7 @@ import type { SessionRecord, SessionStatus, WorktreeRecord } from '../../../../s
|
|
|
10
10
|
|
|
11
11
|
import { useAppStore } from '../../../../state/app-store'
|
|
12
12
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
13
|
-
import { formatDivergence
|
|
13
|
+
import { formatDivergence } from '../../../../state/session-worktrees'
|
|
14
14
|
// eslint-disable-next-line no-duplicate-imports
|
|
15
15
|
import { IDLE_SESSION_STATUS } from '../../../../state/types'
|
|
16
16
|
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
@@ -199,6 +199,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
|
|
|
199
199
|
isActiveItem={workspaceIsActiveItem}
|
|
200
200
|
inCurrentGroup={isCurrentSession}
|
|
201
201
|
primaryWorktree={primaryWorktree}
|
|
202
|
+
hasExtraWorktrees={extraWorktrees.length > 0}
|
|
202
203
|
status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
|
|
203
204
|
dragging={draggingId === session.id}
|
|
204
205
|
contentWidth={contentWidth}
|
|
@@ -210,7 +211,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
|
|
|
210
211
|
onDragCancel={cancelDrag}
|
|
211
212
|
/>
|
|
212
213
|
)
|
|
213
|
-
for (const worktree of extraWorktrees) {
|
|
214
|
+
for (const [wtIdx, worktree] of extraWorktrees.entries()) {
|
|
214
215
|
rows.push(
|
|
215
216
|
<WorktreeRow
|
|
216
217
|
key={`wt:${worktree.id}`}
|
|
@@ -219,6 +220,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
|
|
|
219
220
|
sessionIndex={sessionIndex}
|
|
220
221
|
isActiveItem={isCurrentSession && worktree.id === session.activeWorktreeId}
|
|
221
222
|
inCurrentGroup={isCurrentSession}
|
|
223
|
+
isLast={wtIdx === extraWorktrees.length - 1}
|
|
222
224
|
/>
|
|
223
225
|
)
|
|
224
226
|
}
|
|
@@ -250,6 +252,8 @@ interface WorkspaceRowProps {
|
|
|
250
252
|
inCurrentGroup: boolean
|
|
251
253
|
/** The session's primary worktree — its git branch is shown as the workspace's anchor identity. */
|
|
252
254
|
primaryWorktree: WorktreeRecord | undefined
|
|
255
|
+
/** True when at least one non-primary worktree follows — used to draw the tree continuator. */
|
|
256
|
+
hasExtraWorktrees: boolean
|
|
253
257
|
status: SessionStatus
|
|
254
258
|
dragging: boolean
|
|
255
259
|
contentWidth: number
|
|
@@ -265,6 +269,7 @@ interface WorkspaceRowProps {
|
|
|
265
269
|
const WorkspaceRow = memo(function WorkspaceRow({
|
|
266
270
|
contentWidth,
|
|
267
271
|
dragging,
|
|
272
|
+
hasExtraWorktrees,
|
|
268
273
|
inCurrentGroup,
|
|
269
274
|
isActiveItem,
|
|
270
275
|
marginTop,
|
|
@@ -332,16 +337,10 @@ const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
332
337
|
[session.id, session.name]
|
|
333
338
|
)
|
|
334
339
|
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
? (primaryWorktree.color ?? getWorktreeColor(primaryWorktree.id))
|
|
340
|
-
: t.textMuted
|
|
341
|
-
// Working/waiting indicator overrides the colored bar — the user needs to
|
|
342
|
-
// see assistant activity from across the room.
|
|
343
|
-
let leadingGlyph = '▍'
|
|
344
|
-
let leadingColor = barColor
|
|
340
|
+
// Neutral muted marker; working/waiting overrides it. Keeping a glyph
|
|
341
|
+
// in the slot avoids name shifts when state indicators come and go.
|
|
342
|
+
let leadingGlyph = '•'
|
|
343
|
+
let leadingColor = t.textMuted
|
|
345
344
|
if (showWaiting) {
|
|
346
345
|
leadingGlyph = '?'
|
|
347
346
|
leadingColor = waitingColor
|
|
@@ -379,7 +378,7 @@ const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
379
378
|
<text fg={leadingColor} selectable={false} wrapMode="none">
|
|
380
379
|
{leadingGlyph}
|
|
381
380
|
</text>
|
|
382
|
-
<text fg={
|
|
381
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
383
382
|
{' '}
|
|
384
383
|
{nameLabel}
|
|
385
384
|
</text>
|
|
@@ -387,7 +386,7 @@ const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
387
386
|
{showBranch ? (
|
|
388
387
|
<box flexDirection="row">
|
|
389
388
|
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
390
|
-
{' '}
|
|
389
|
+
{hasExtraWorktrees ? '│ ' : ' '}
|
|
391
390
|
{'\u{e702}'} {branchLabel}
|
|
392
391
|
{divergenceText !== '' ? ` ${divergenceText}` : ''}
|
|
393
392
|
</text>
|
|
@@ -6,6 +6,7 @@ import type { SessionRecord, WorktreeRecord } from '../../../../state/types'
|
|
|
6
6
|
|
|
7
7
|
import { useAppStore } from '../../../../state/app-store'
|
|
8
8
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
9
|
+
import { formatDivergence } from '../../../../state/session-worktrees'
|
|
9
10
|
import { useTheme } from '../../../theme'
|
|
10
11
|
import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
|
|
11
12
|
|
|
@@ -18,11 +19,14 @@ interface WorktreeRowProps {
|
|
|
18
19
|
isActiveItem: boolean
|
|
19
20
|
/** True when this row's workspace is the current session (selection scope). */
|
|
20
21
|
inCurrentGroup: boolean
|
|
22
|
+
/** True when this is the last non-primary worktree of its workspace. Drives └─ vs ├─. */
|
|
23
|
+
isLast: boolean
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export const WorktreeRow = memo(function WorktreeRow({
|
|
24
27
|
inCurrentGroup,
|
|
25
28
|
isActiveItem,
|
|
29
|
+
isLast,
|
|
26
30
|
session,
|
|
27
31
|
sessionIndex,
|
|
28
32
|
worktree,
|
|
@@ -30,6 +34,7 @@ export const WorktreeRow = memo(function WorktreeRow({
|
|
|
30
34
|
const t = useTheme()
|
|
31
35
|
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
32
36
|
const isCurrentSession = session.id === currentSessionId
|
|
37
|
+
const divergence = useAppStore((s) => s.worktreeDivergence[worktree.id])
|
|
33
38
|
|
|
34
39
|
const handleMouseDown = useCallback(
|
|
35
40
|
(event: OtuiMouseEvent) => {
|
|
@@ -70,23 +75,40 @@ export const WorktreeRow = memo(function WorktreeRow({
|
|
|
70
75
|
bgColor = t.backgroundPanel
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
const connector = isLast ? '└─' : '├─'
|
|
79
|
+
const branchCont = isLast ? ' ' : '│ '
|
|
80
|
+
const branchText = worktree.branch ?? ''
|
|
81
|
+
const showBranch = branchText !== ''
|
|
82
|
+
const divergenceText = formatDivergence(divergence)
|
|
83
|
+
|
|
73
84
|
return (
|
|
74
85
|
<ContextMenuBox
|
|
75
86
|
id={`sidebar-wt-${worktree.id}`}
|
|
76
|
-
flexDirection="
|
|
87
|
+
flexDirection="column"
|
|
88
|
+
flexShrink={0}
|
|
77
89
|
paddingLeft={1}
|
|
78
90
|
paddingRight={1}
|
|
79
|
-
alignItems="center"
|
|
80
91
|
backgroundColor={bgColor}
|
|
81
92
|
rightClickMenu={rightClickMenu}
|
|
82
93
|
onMouseDown={handleMouseDown}
|
|
83
94
|
>
|
|
84
|
-
<
|
|
85
|
-
{
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
{
|
|
89
|
-
|
|
95
|
+
<box flexDirection="row" alignItems="center">
|
|
96
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
97
|
+
{connector}{' '}
|
|
98
|
+
</text>
|
|
99
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
100
|
+
{worktree.name}
|
|
101
|
+
</text>
|
|
102
|
+
</box>
|
|
103
|
+
{showBranch ? (
|
|
104
|
+
<box flexDirection="row">
|
|
105
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
106
|
+
{branchCont}
|
|
107
|
+
{'\u{e702}'} {branchText}
|
|
108
|
+
{divergenceText !== '' ? ` ${divergenceText}` : ''}
|
|
109
|
+
</text>
|
|
110
|
+
</box>
|
|
111
|
+
) : null}
|
|
90
112
|
</ContextMenuBox>
|
|
91
113
|
)
|
|
92
114
|
})
|
|
@@ -8,6 +8,7 @@ import type { FocusMode, TabSession, TerminalSnapshot, TerminalSpan } from '../.
|
|
|
8
8
|
|
|
9
9
|
import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
|
|
10
10
|
import { logInputDebug } from '../../../debug/input-log'
|
|
11
|
+
import { useAppStore } from '../../../state/app-store'
|
|
11
12
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
|
|
12
13
|
import { type ContextMenuItem, openContextMenu } from '../../context-menu/controller'
|
|
13
14
|
import { resolvePaletteIndex } from '../../host-palette'
|
|
@@ -39,10 +40,16 @@ interface TerminalPaneProps {
|
|
|
39
40
|
function getTitle(
|
|
40
41
|
tab: TabSession | undefined,
|
|
41
42
|
isActive: boolean,
|
|
42
|
-
focusMode: TerminalPaneProps['focusMode']
|
|
43
|
+
focusMode: TerminalPaneProps['focusMode'],
|
|
44
|
+
emptyContext: { workspaceName: string; worktreeName: string }
|
|
43
45
|
): string {
|
|
44
46
|
if (!tab) {
|
|
45
|
-
|
|
47
|
+
const { workspaceName, worktreeName } = emptyContext
|
|
48
|
+
if (workspaceName === '' && worktreeName === '') return 'No active workspace'
|
|
49
|
+
if (worktreeName === '' || worktreeName === workspaceName) {
|
|
50
|
+
return `${workspaceName} · no tabs`
|
|
51
|
+
}
|
|
52
|
+
return `${workspaceName} / ${worktreeName} · no tabs`
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
if (isActive && focusMode === 'terminal-input') {
|
|
@@ -146,6 +153,26 @@ export function TerminalPane({
|
|
|
146
153
|
const setContentBox = usePaneSizeReport(tabId, !!tab, onMeasure)
|
|
147
154
|
const editorBg = t.background
|
|
148
155
|
const paneIsActive = isActive ?? true
|
|
156
|
+
// These are only used when this pane is rendered without a tab (the
|
|
157
|
+
// top-level pane on a worktree with zero tabs). Selectors return plain
|
|
158
|
+
// strings so re-renders are cheap and bounded to actual name changes.
|
|
159
|
+
const emptyWorkspaceName = useAppStore((s) => {
|
|
160
|
+
const id = s.currentSessionId
|
|
161
|
+
if (id == null || id === '') return ''
|
|
162
|
+
return s.sessions.find((sess) => sess.id === id)?.name ?? ''
|
|
163
|
+
})
|
|
164
|
+
const emptyWorktreeName = useAppStore((s) => {
|
|
165
|
+
const id = s.currentSessionId
|
|
166
|
+
if (id == null || id === '') return ''
|
|
167
|
+
const session = s.sessions.find((sess) => sess.id === id)
|
|
168
|
+
if (!session) return ''
|
|
169
|
+
const activeId =
|
|
170
|
+
session.activeWorktreeId != null && session.activeWorktreeId !== ''
|
|
171
|
+
? session.activeWorktreeId
|
|
172
|
+
: session.worktrees?.[0]?.id
|
|
173
|
+
if (activeId == null || activeId === '') return ''
|
|
174
|
+
return session.worktrees?.find((w) => w.id === activeId)?.name ?? ''
|
|
175
|
+
})
|
|
149
176
|
const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
|
|
150
177
|
const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
|
|
151
178
|
const rightClickMenu = useMemo<ContextMenuItem[] | undefined>(
|
|
@@ -341,7 +368,10 @@ export function TerminalPane({
|
|
|
341
368
|
<ContextMenuBox
|
|
342
369
|
border
|
|
343
370
|
borderColor={getBorderColor(paneIsActive, focusMode)}
|
|
344
|
-
title={getTitle(tab, paneIsActive, focusMode
|
|
371
|
+
title={getTitle(tab, paneIsActive, focusMode, {
|
|
372
|
+
workspaceName: emptyWorkspaceName,
|
|
373
|
+
worktreeName: emptyWorktreeName,
|
|
374
|
+
})}
|
|
345
375
|
padding={0}
|
|
346
376
|
flexDirection="column"
|
|
347
377
|
flexGrow={1}
|
|
@@ -356,6 +386,19 @@ export function TerminalPane({
|
|
|
356
386
|
<box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column">
|
|
357
387
|
<text fg={t.textMuted}>· · ·</text>
|
|
358
388
|
<text fg={t.textMuted}> </text>
|
|
389
|
+
{emptyWorkspaceName !== '' ? (
|
|
390
|
+
<box flexDirection="row">
|
|
391
|
+
<text fg={t.text}>{emptyWorkspaceName}</text>
|
|
392
|
+
{emptyWorktreeName !== '' && emptyWorktreeName !== emptyWorkspaceName ? (
|
|
393
|
+
<>
|
|
394
|
+
<text fg={t.textMuted}> / </text>
|
|
395
|
+
<text fg={t.text}>{emptyWorktreeName}</text>
|
|
396
|
+
</>
|
|
397
|
+
) : null}
|
|
398
|
+
</box>
|
|
399
|
+
) : null}
|
|
400
|
+
<text fg={t.textMuted}>has no tabs</text>
|
|
401
|
+
<text fg={t.textMuted}> </text>
|
|
359
402
|
<box flexDirection="row">
|
|
360
403
|
<text fg={t.textMuted}>Press </text>
|
|
361
404
|
<text fg={t.primary}>Ctrl+n</text>
|
|
@@ -220,7 +220,9 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
|
220
220
|
}, [])
|
|
221
221
|
|
|
222
222
|
if (!bar.visible && !forceVisible) return null
|
|
223
|
-
|
|
223
|
+
// Keep the bar visible even with zero entries — when the active worktree
|
|
224
|
+
// has no tabs, the lone "+" affordance is what tells the user "you're in
|
|
225
|
+
// an empty worktree, click here to start one".
|
|
224
226
|
|
|
225
227
|
const isFocused = focusMode === 'terminal-input' || focusMode === 'navigation'
|
|
226
228
|
|