@brimveyn/aimux 1.23.4 → 1.23.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 +2 -2
- package/src/app-runtime/navigation-actions.ts +4 -1
- package/src/app-runtime/side-effects.ts +9 -0
- package/src/app-runtime/tab-actions.ts +14 -3
- package/src/app-runtime/workspace-actions.ts +60 -1
- package/src/app.tsx +18 -1
- package/src/git/worktree.ts +17 -1
- package/src/input/modes/types.ts +2 -0
- package/src/pty/command-registry.ts +99 -2
- package/src/settings/live.ts +6 -1
- package/src/settings/sections/status-bar.ts +10 -0
- package/src/state/actions.ts +1 -0
- package/src/state/project-persistence.ts +2 -0
- package/src/state/project-workspaces.ts +23 -3
- package/src/state/reducers/tab-state.ts +15 -0
- package/src/state/types.ts +27 -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/sidebar/workspace-row.tsx +14 -0
- package/src/ui/components/layout/status-bar.tsx +19 -15
- package/src/ui/components/layout/terminal-pane.tsx +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.6",
|
|
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.7",
|
|
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
|
}
|
|
@@ -65,10 +65,12 @@ import {
|
|
|
65
65
|
startExistingTab,
|
|
66
66
|
} from './tab-actions'
|
|
67
67
|
import {
|
|
68
|
+
hibernateWorkspace,
|
|
68
69
|
isForceableWorkspaceDeleteError,
|
|
69
70
|
runDeleteWorkspace,
|
|
70
71
|
runMoveWorkspace,
|
|
71
72
|
setProjectDefaultBaseRef,
|
|
73
|
+
toggleProjectCollapsed,
|
|
72
74
|
} from './workspace-actions'
|
|
73
75
|
import { launchPendingWorkspace, startWorkspaceCreation } from './workspace-launch'
|
|
74
76
|
|
|
@@ -389,6 +391,9 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
389
391
|
backend.disposeSession(effect.tabId)
|
|
390
392
|
return
|
|
391
393
|
}
|
|
394
|
+
case 'hibernate-workspace':
|
|
395
|
+
hibernateWorkspace(ctx, effect.workspaceId)
|
|
396
|
+
return
|
|
392
397
|
case 'restart-tab':
|
|
393
398
|
restartTabSession(
|
|
394
399
|
backend,
|
|
@@ -586,6 +591,10 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
586
591
|
setProjectDefaultBaseRef(ctx, effect.projectId, effect.baseRef)
|
|
587
592
|
return
|
|
588
593
|
}
|
|
594
|
+
case 'toggle-project-collapsed': {
|
|
595
|
+
toggleProjectCollapsed(ctx, effect.projectId)
|
|
596
|
+
return
|
|
597
|
+
}
|
|
589
598
|
case 'ask-agent-for-setup-script': {
|
|
590
599
|
handleAskAgentForSetupScriptEffect(ctx)
|
|
591
600
|
return
|
|
@@ -5,6 +5,7 @@ import { logInputDebug } from '../debug/input-log'
|
|
|
5
5
|
import { createPrefixedId } from '../platform/id'
|
|
6
6
|
import {
|
|
7
7
|
assistantAcceptsPromptArg,
|
|
8
|
+
buildAssistantSessionArgs,
|
|
8
9
|
getAllAssistantOptions,
|
|
9
10
|
getAssistantOption,
|
|
10
11
|
isCommandAvailable,
|
|
@@ -65,6 +66,10 @@ export function createTabSession(
|
|
|
65
66
|
buffer: '',
|
|
66
67
|
command: customCommand ?? option.command,
|
|
67
68
|
id: createTabId(),
|
|
69
|
+
// Minted up front, before the CLI has ever run, so the very first spawn can
|
|
70
|
+
// claim it. Only for assistants that can be told their session id — a shell
|
|
71
|
+
// tab would just carry a uuid nothing reads.
|
|
72
|
+
sessionId: option.session ? crypto.randomUUID() : undefined,
|
|
68
73
|
status: 'starting',
|
|
69
74
|
terminalModes: createDefaultTerminalModes(),
|
|
70
75
|
title: option.label,
|
|
@@ -75,7 +80,7 @@ export function createTabSession(
|
|
|
75
80
|
/** Everything `startTabSession` needs from the side-effect context. */
|
|
76
81
|
type StartTabSessionContext = Pick<
|
|
77
82
|
SideEffectContext,
|
|
78
|
-
'backend' | 'clearStartupGrace' | 'dispatch' | 'startStartupGrace'
|
|
83
|
+
'backend' | 'clearStartupGrace' | 'dispatch' | 'startStartupGrace' | 'state'
|
|
79
84
|
>
|
|
80
85
|
|
|
81
86
|
export interface StartTabSessionOptions {
|
|
@@ -95,7 +100,7 @@ export interface StartTabSessionOptions {
|
|
|
95
100
|
|
|
96
101
|
export function startTabSession(
|
|
97
102
|
ctx: StartTabSessionContext,
|
|
98
|
-
tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command' | 'workspaceId'>,
|
|
103
|
+
tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command' | 'sessionId' | 'workspaceId'>,
|
|
99
104
|
{ autoRenameCandidate = true, cols, cwd, extraArgs, rows }: StartTabSessionOptions
|
|
100
105
|
): void {
|
|
101
106
|
const { backend, clearStartupGrace, dispatch } = ctx
|
|
@@ -111,6 +116,12 @@ export function startTabSession(
|
|
|
111
116
|
ctx.startStartupGrace(tab.id, STARTUP_GRACE_MS)
|
|
112
117
|
|
|
113
118
|
const { args, executable } = parseCommand(tab.command)
|
|
119
|
+
// Ahead of `extraArgs` because that is where an initial prompt goes, and the
|
|
120
|
+
// prompt is positional — a flag after it would be read as part of it.
|
|
121
|
+
const sessionArgs =
|
|
122
|
+
tab.sessionId == null
|
|
123
|
+
? []
|
|
124
|
+
: buildAssistantSessionArgs(tab.assistant, ctx.state.customCommands, tab.sessionId)
|
|
114
125
|
|
|
115
126
|
if (!isCommandAvailable(executable)) {
|
|
116
127
|
clearStartupGrace(tab.id)
|
|
@@ -123,7 +134,7 @@ export function startTabSession(
|
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
backend.createSession({
|
|
126
|
-
args:
|
|
137
|
+
args: [...args, ...sessionArgs, ...(extraArgs ?? [])],
|
|
127
138
|
assistant: tab.assistant,
|
|
128
139
|
autoRenameCandidate,
|
|
129
140
|
cols,
|
|
@@ -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
|
}
|
|
@@ -203,6 +214,50 @@ function disposeWorkspaceTabs(ctx: SideEffectContext, workspaceId: string): void
|
|
|
203
214
|
}
|
|
204
215
|
}
|
|
205
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Put every idle assistant tab in a workspace to sleep: kill the PTY, keep the
|
|
219
|
+
* frozen screen, resume on focus. The RAM a workspace holds is its assistant
|
|
220
|
+
* processes — several hundred MB each — and none of it is aimux's own.
|
|
221
|
+
*
|
|
222
|
+
* A tab mid-turn is never touched. The lot is deliberately partial rather than
|
|
223
|
+
* all-or-nothing: one busy tab should not veto the other three, and the toast
|
|
224
|
+
* says what stayed up so the skip is never silent.
|
|
225
|
+
*
|
|
226
|
+
* ponytail: `activity` comes from reading the assistant's screen, so it sees a
|
|
227
|
+
* turn in progress but not a background task the assistant spawned. Manual-only
|
|
228
|
+
* for exactly that reason — you know what you left running; a timer would not.
|
|
229
|
+
*/
|
|
230
|
+
export function hibernateWorkspace(ctx: SideEffectContext, workspaceId: string): void {
|
|
231
|
+
const tabs = ctx.state.tabs.filter(
|
|
232
|
+
(tab) => tab.workspaceId === workspaceId && tab.role !== 'setup'
|
|
233
|
+
)
|
|
234
|
+
const sleepable = tabs.filter(
|
|
235
|
+
(tab) => (tab.status === 'running' || tab.status === 'starting') && tab.activity === 'idle'
|
|
236
|
+
)
|
|
237
|
+
const busy = tabs.filter(
|
|
238
|
+
(tab) => (tab.status === 'running' || tab.status === 'starting') && tab.activity !== 'idle'
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
for (const tab of sleepable) {
|
|
242
|
+
ctx.clearIdleTimer(tab.id)
|
|
243
|
+
ctx.clearStartupGrace(tab.id)
|
|
244
|
+
ctx.backend.disposeSession(tab.id)
|
|
245
|
+
ctx.dispatch({ tabId: tab.id, type: 'hibernate-tab' })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (sleepable.length === 0) {
|
|
249
|
+
toast.info(
|
|
250
|
+
busy.length > 0 ? `Nothing to hibernate — ${busy.length} tab(s) busy` : 'Nothing to hibernate'
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
toast.success(
|
|
255
|
+
busy.length > 0
|
|
256
|
+
? `Hibernated ${sleepable.length} tab(s), skipped ${busy.length} busy`
|
|
257
|
+
: `Hibernated ${sleepable.length} tab(s)`
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
206
261
|
export async function runDeleteWorkspace(
|
|
207
262
|
ctx: SideEffectContext,
|
|
208
263
|
projectId: string,
|
|
@@ -404,7 +459,11 @@ export function removeWorkspaceRecordFromProject(
|
|
|
404
459
|
}
|
|
405
460
|
|
|
406
461
|
export function isForceableWorkspaceDeleteError(message: string): boolean {
|
|
407
|
-
|
|
462
|
+
// `force delete` is the marker `removeGitWorktree` appends to every refusal
|
|
463
|
+
// git raises: matching git's own wording never worked, since a broken worktree
|
|
464
|
+
// link fatals differently ("is not a working tree", "validation failed", …)
|
|
465
|
+
// depending on which part of the link broke.
|
|
466
|
+
return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes|force delete/i.test(
|
|
408
467
|
message
|
|
409
468
|
)
|
|
410
469
|
}
|
package/src/app.tsx
CHANGED
|
@@ -49,7 +49,11 @@ import { ALL_SETTING_ROWS } from './settings/sections'
|
|
|
49
49
|
import { hydrateSettings } from './settings/settings-store'
|
|
50
50
|
import { aiUsageStore } from './state/ai-usage-store'
|
|
51
51
|
import { appStore, useAppStore } from './state/app-store'
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
runSideEffectGlobal,
|
|
54
|
+
setActiveDispatch,
|
|
55
|
+
setActiveSideEffectRunner,
|
|
56
|
+
} from './state/dispatch-ref'
|
|
53
57
|
import { findMostRecentProject, loadProjectCatalog } from './state/project-catalog'
|
|
54
58
|
import { getActiveWorkspacePath } from './state/project-workspaces'
|
|
55
59
|
import { loadSnippetCatalog, mergeConfigSnippets } from './state/snippet-catalog'
|
|
@@ -484,6 +488,19 @@ export function App({
|
|
|
484
488
|
|
|
485
489
|
useSetupRunner(state, sideEffectCtx)
|
|
486
490
|
|
|
491
|
+
// Waking is the counterpart of hibernating, and focus is the only signal it
|
|
492
|
+
// needs: you looked at the tab, so you want it back. Keyed on the id as well
|
|
493
|
+
// as the flag so switching between two sleeping tabs wakes the second one.
|
|
494
|
+
// `restart-tab` already does the whole job — dispose, reset, respawn — and the
|
|
495
|
+
// respawn now carries `--resume`, so the conversation comes back with it.
|
|
496
|
+
const wakeTabRef = useRef(activeTab)
|
|
497
|
+
wakeTabRef.current = activeTab
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
const tab = wakeTabRef.current
|
|
500
|
+
if (tab?.hibernated !== true) return
|
|
501
|
+
runSideEffectGlobal({ tab, type: 'restart-tab' })
|
|
502
|
+
}, [activeTab?.id, activeTab?.hibernated])
|
|
503
|
+
|
|
487
504
|
function processKeyResult(result: KeyResult, modeId: ModeId): void {
|
|
488
505
|
for (const action of result.actions) {
|
|
489
506
|
dispatch(action)
|
package/src/git/worktree.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { $ } from 'bun'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
|
+
import { rm } from 'node:fs/promises'
|
|
3
4
|
|
|
4
5
|
import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
|
|
5
6
|
|
|
@@ -227,7 +228,22 @@ export async function removeGitWorktree({
|
|
|
227
228
|
await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
|
|
228
229
|
return
|
|
229
230
|
}
|
|
230
|
-
|
|
231
|
+
// git refuses on states it cannot repair — a half-finished removal or a moved
|
|
232
|
+
// repo leaves a directory git no longer links to ("is not a working tree"),
|
|
233
|
+
// and no retry ever fixes it, so the row was undeletable forever. Force means
|
|
234
|
+
// "delete it regardless": finish the job ourselves. The path guard above
|
|
235
|
+
// already pinned the target inside the Aimux worktree root.
|
|
236
|
+
if (force) {
|
|
237
|
+
await rm(targetPath, { force: true, recursive: true })
|
|
238
|
+
await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
// Every refusal git can raise here is force-recoverable — the directory lives
|
|
242
|
+
// under the Aimux worktree root and nothing else owns it — so the message
|
|
243
|
+
// carries the offer, and `isForceableWorkspaceDeleteError` keys off that
|
|
244
|
+
// phrase instead of trying to enumerate git's wording.
|
|
245
|
+
const stderr = result.stderr.toString().trim() || 'failed to remove git worktree'
|
|
246
|
+
throw new Error(`${stderr} — force delete to remove it anyway`)
|
|
231
247
|
}
|
|
232
248
|
|
|
233
249
|
export async function listGitWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
|
package/src/input/modes/types.ts
CHANGED
|
@@ -108,6 +108,7 @@ export type SideEffect =
|
|
|
108
108
|
keepConflicts?: boolean
|
|
109
109
|
}
|
|
110
110
|
| { type: 'load-workspace-move-stats' }
|
|
111
|
+
| { type: 'hibernate-workspace'; workspaceId: string }
|
|
111
112
|
| { type: 'toggle-transparent' }
|
|
112
113
|
| { type: 'toggle-mode' }
|
|
113
114
|
| { type: 'open-file-in-editor'; path: string }
|
|
@@ -116,6 +117,7 @@ export type SideEffect =
|
|
|
116
117
|
| { type: 'stop-setup' }
|
|
117
118
|
| { type: 'configure-setup-script'; projectId?: string }
|
|
118
119
|
| { type: 'set-project-default-base-ref'; projectId: string; baseRef: string }
|
|
120
|
+
| { type: 'toggle-project-collapsed'; projectId: string }
|
|
119
121
|
| { type: 'ask-agent-for-setup-script' }
|
|
120
122
|
| { type: 'promote-setup-tab' }
|
|
121
123
|
/** Toggle a checkbox, cycle an enum, run a row's action — whatever the row is. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
2
3
|
|
|
3
4
|
import type { AssistantId } from '../state/types'
|
|
4
5
|
|
|
@@ -18,12 +19,41 @@ export interface AssistantModelSpec {
|
|
|
18
19
|
buildEffortArgs?: (effort: string) => string[]
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* How an assistant names a resumable conversation on the command line. Same
|
|
24
|
+
* philosophy as `AssistantModelSpec`: the vendor's flag syntax lives on the
|
|
25
|
+
* assistant definition, not in a resolver.
|
|
26
|
+
*
|
|
27
|
+
* Two builders because the two directions are different flags, not one flag
|
|
28
|
+
* with two meanings — `claude --session-id <uuid>` *claims* an id for a new
|
|
29
|
+
* conversation and errors with "Session ID … is already in use" if it exists,
|
|
30
|
+
* while `claude --resume <uuid>` refuses an id that does not. An assistant that
|
|
31
|
+
* can resume but cannot be told its id up front (codex: `codex resume <id>`,
|
|
32
|
+
* with no way to fix the id at spawn) has no entry here — its id would have to
|
|
33
|
+
* be discovered after the fact, which is a different mechanism.
|
|
34
|
+
*/
|
|
35
|
+
export interface AssistantSessionSpec {
|
|
36
|
+
/** Args that pin a fresh conversation to `sessionId`. */
|
|
37
|
+
buildSessionArgs: (sessionId: string) => string[]
|
|
38
|
+
/** Args that reopen the conversation `sessionId` names. */
|
|
39
|
+
buildResumeArgs: (sessionId: string) => string[]
|
|
40
|
+
/**
|
|
41
|
+
* Whether the vendor has a stored conversation under this id. Asking the
|
|
42
|
+
* filesystem is what lets one spawn path serve both directions: an id with a
|
|
43
|
+
* transcript resumes, an id without one is claimed fresh. Without it, a tab
|
|
44
|
+
* you opened and never typed into would resume into "No conversation found"
|
|
45
|
+
* and exit — and an exiting PTY takes the tab with it.
|
|
46
|
+
*/
|
|
47
|
+
hasConversation: (sessionId: string) => boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
21
50
|
export interface AssistantOption {
|
|
22
51
|
id: AssistantId
|
|
23
52
|
label: string
|
|
24
53
|
command: string
|
|
25
54
|
description: string
|
|
26
55
|
model?: AssistantModelSpec
|
|
56
|
+
session?: AssistantSessionSpec
|
|
27
57
|
/**
|
|
28
58
|
* The CLI starts an interactive session with a positional prompt argument
|
|
29
59
|
* (`claude "…"`, `codex "…"`). When it does, handing the prompt over at spawn
|
|
@@ -41,6 +71,23 @@ export interface AssistantOption {
|
|
|
41
71
|
acceptsPromptArg?: boolean
|
|
42
72
|
}
|
|
43
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A machine that has never run `claude` has no `~/.claude/projects`, and
|
|
76
|
+
* `scanSync` reports a missing cwd by throwing ENOENT rather than yielding
|
|
77
|
+
* nothing. "No directory" and "directory without this transcript" are the same
|
|
78
|
+
* answer here — there is no conversation to resume — so the throw is folded back
|
|
79
|
+
* into the false it should have been. Without this, the very first assistant tab
|
|
80
|
+
* on a fresh install dies at spawn.
|
|
81
|
+
*/
|
|
82
|
+
function hasClaudeTranscript(sessionId: string): boolean {
|
|
83
|
+
const cwd = join(homedir(), '.claude', 'projects')
|
|
84
|
+
try {
|
|
85
|
+
return new Bun.Glob(`*/${sessionId}.jsonl`).scanSync({ cwd }).next().done !== true
|
|
86
|
+
} catch {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
44
91
|
const DEFAULT_SHELL =
|
|
45
92
|
process.env.SHELL != null && process.env.SHELL !== '' ? process.env.SHELL : 'sh'
|
|
46
93
|
const SHELL_NAME = DEFAULT_SHELL.split('/').pop() ?? 'shell'
|
|
@@ -56,6 +103,14 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
|
|
|
56
103
|
buildEffortArgs: (effort) => ['--effort', effort],
|
|
57
104
|
buildModelArgs: (model) => ['--model', model],
|
|
58
105
|
},
|
|
106
|
+
session: {
|
|
107
|
+
buildResumeArgs: (sessionId) => ['--resume', sessionId],
|
|
108
|
+
buildSessionArgs: (sessionId) => ['--session-id', sessionId],
|
|
109
|
+
// `~/.claude/projects/<cwd-slug>/<uuid>.jsonl`. Globbing the slug away
|
|
110
|
+
// beats deriving it: the uuid alone is unique, so we never have to model
|
|
111
|
+
// how the vendor mangles a path into a directory name.
|
|
112
|
+
hasConversation: (sessionId) => hasClaudeTranscript(sessionId),
|
|
113
|
+
},
|
|
59
114
|
},
|
|
60
115
|
{
|
|
61
116
|
acceptsPromptArg: true,
|
|
@@ -149,11 +204,53 @@ export function assistantAcceptsPromptArg(
|
|
|
149
204
|
): boolean {
|
|
150
205
|
const option = getAllAssistantOptions(customCommands).find((entry) => entry.id === assistant)
|
|
151
206
|
if (option?.acceptsPromptArg !== true) return false
|
|
152
|
-
|
|
207
|
+
return runsVendorProgram(option, customCommands)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether the configured command still runs the program the assistant's flags
|
|
212
|
+
* were written for. Extra flags are fine; a wrapper script is not, because any
|
|
213
|
+
* flag we append lands on the wrapper rather than the vendor CLI.
|
|
214
|
+
*/
|
|
215
|
+
function runsVendorProgram(
|
|
216
|
+
option: AssistantOption,
|
|
217
|
+
customCommands: Record<string, string>
|
|
218
|
+
): boolean {
|
|
219
|
+
const custom = customCommands[option.id]
|
|
153
220
|
if (custom == null || custom === '') return true
|
|
154
221
|
return basename(parseCommand(custom).executable) === option.command
|
|
155
222
|
}
|
|
156
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The args that tie a spawn to `sessionId` — resume when the vendor already has
|
|
226
|
+
* a conversation under that id, claim it otherwise.
|
|
227
|
+
*
|
|
228
|
+
* One function for both directions on purpose: every spawn site (new tab,
|
|
229
|
+
* split, Ctrl+r, waking a hibernated tab) wants the same thing — "be this
|
|
230
|
+
* conversation" — and the filesystem, not the call site, is what knows whether
|
|
231
|
+
* that conversation exists yet. A caller that had to pick would get it wrong
|
|
232
|
+
* exactly once: on the tab that was opened and never typed into.
|
|
233
|
+
*
|
|
234
|
+
* Empty for an assistant with no session support, and for a custom command
|
|
235
|
+
* that already bakes in its own session flags — doubling `--resume` is an error
|
|
236
|
+
* the vendor would report at spawn, long after the useful stack is gone.
|
|
237
|
+
*/
|
|
238
|
+
export function buildAssistantSessionArgs(
|
|
239
|
+
assistant: AssistantId,
|
|
240
|
+
customCommands: Record<string, string>,
|
|
241
|
+
sessionId: string
|
|
242
|
+
): string[] {
|
|
243
|
+
if (sessionId === '') return []
|
|
244
|
+
const option = getAllAssistantOptions(customCommands).find((entry) => entry.id === assistant)
|
|
245
|
+
if (!option?.session) return []
|
|
246
|
+
if (!runsVendorProgram(option, customCommands)) return []
|
|
247
|
+
const custom = customCommands[assistant] ?? ''
|
|
248
|
+
if (/(^|\s)(-r|--resume|--session-id|--continue|-c)(\s|$)/.test(custom)) return []
|
|
249
|
+
return option.session.hasConversation(sessionId)
|
|
250
|
+
? option.session.buildResumeArgs(sessionId)
|
|
251
|
+
: option.session.buildSessionArgs(sessionId)
|
|
252
|
+
}
|
|
253
|
+
|
|
157
254
|
export function isCommandAvailable(command: string): boolean {
|
|
158
255
|
return Bun.which(command) !== null
|
|
159
256
|
}
|
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,
|
package/src/state/actions.ts
CHANGED
|
@@ -153,6 +153,7 @@ export type TabAction =
|
|
|
153
153
|
| { type: 'reorder-active-tab'; delta: number }
|
|
154
154
|
| { type: 'reorder-tabs'; orderedTabIds: string[] }
|
|
155
155
|
| { type: 'reset-tab-project'; tabId: string }
|
|
156
|
+
| { type: 'hibernate-tab'; tabId: string }
|
|
156
157
|
| {
|
|
157
158
|
type: 'rename-tab'
|
|
158
159
|
tabId: string
|
|
@@ -62,6 +62,7 @@ export function serializeProject(state: AppState): ProjectSnapshotV1 {
|
|
|
62
62
|
errorMessage: tab.errorMessage,
|
|
63
63
|
exitCode: tab.exitCode,
|
|
64
64
|
id: tab.id,
|
|
65
|
+
sessionId: tab.sessionId,
|
|
65
66
|
status: tab.status === 'disconnected' ? 'running' : tab.status,
|
|
66
67
|
terminalModes: tab.terminalModes,
|
|
67
68
|
title: tab.title,
|
|
@@ -129,6 +130,7 @@ export function restoreTabsFromProject(
|
|
|
129
130
|
errorMessage: tab.errorMessage,
|
|
130
131
|
exitCode: tab.exitCode,
|
|
131
132
|
id: tab.id,
|
|
133
|
+
sessionId: tab.sessionId,
|
|
132
134
|
status: forceDisconnected ? getDisconnectedStatus(tab.status) : tab.status,
|
|
133
135
|
terminalModes: tab.terminalModes,
|
|
134
136
|
title: tab.title,
|
|
@@ -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
|
+
}
|
|
@@ -531,6 +531,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
531
531
|
buffer: '',
|
|
532
532
|
errorMessage: undefined,
|
|
533
533
|
exitCode: undefined,
|
|
534
|
+
hibernated: undefined,
|
|
534
535
|
status: 'starting',
|
|
535
536
|
terminalModes: createDefaultTerminalModes(),
|
|
536
537
|
viewport: undefined,
|
|
@@ -538,6 +539,20 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
538
539
|
},
|
|
539
540
|
action.tabId
|
|
540
541
|
)
|
|
542
|
+
case 'hibernate-tab':
|
|
543
|
+
// Deliberately keeps `buffer` and `viewport`: the frozen screen is the
|
|
544
|
+
// whole point — you should still see what the assistant last said. That is
|
|
545
|
+
// the one thing separating this from `reset-tab-project`, which wipes both
|
|
546
|
+
// because it is about to draw a live PTY over them.
|
|
547
|
+
return {
|
|
548
|
+
...state,
|
|
549
|
+
tabs: updateTab(state.tabs, action.tabId, (tab) => ({
|
|
550
|
+
...tab,
|
|
551
|
+
activity: 'idle',
|
|
552
|
+
hibernated: true,
|
|
553
|
+
status: 'disconnected',
|
|
554
|
+
})),
|
|
555
|
+
}
|
|
541
556
|
case 'append-tab-buffer':
|
|
542
557
|
return {
|
|
543
558
|
...state,
|
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
|
|
@@ -153,6 +151,8 @@ export interface PersistedTabSnapshot {
|
|
|
153
151
|
assistant: AssistantId
|
|
154
152
|
title: string
|
|
155
153
|
command: string
|
|
154
|
+
/** Optional and additive: an older build ignores it and spawns fresh. */
|
|
155
|
+
sessionId?: string
|
|
156
156
|
status: Exclude<LegacyPersistedTabStatus, 'disconnected'>
|
|
157
157
|
buffer: string
|
|
158
158
|
viewport?: TerminalSnapshot
|
|
@@ -226,6 +226,12 @@ export interface ProjectRecord {
|
|
|
226
226
|
* answers. Unset means the repo's default branch.
|
|
227
227
|
*/
|
|
228
228
|
defaultBaseRef?: string
|
|
229
|
+
/**
|
|
230
|
+
* Folded in the sidebar: the project's workspace rows are hidden, bar the one
|
|
231
|
+
* the cursor is on. Persisted, because a fold you have to redo on every start
|
|
232
|
+
* is worse than no fold at all.
|
|
233
|
+
*/
|
|
234
|
+
collapsed?: boolean
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
export interface ProjectBarState {
|
|
@@ -242,6 +248,25 @@ export interface TabSession {
|
|
|
242
248
|
viewport?: TerminalSnapshot
|
|
243
249
|
terminalModes: TerminalModeState
|
|
244
250
|
command: string
|
|
251
|
+
/**
|
|
252
|
+
* Names the assistant conversation this tab owns, so a respawn reopens it
|
|
253
|
+
* instead of starting over. Minted when the tab is created and handed to the
|
|
254
|
+
* CLI at spawn — we pick the id rather than discover it afterwards, because
|
|
255
|
+
* discovery cannot tell two tabs sharing a cwd apart.
|
|
256
|
+
*
|
|
257
|
+
* Absent on tabs created before this existed, and on assistants with no
|
|
258
|
+
* session support; both spawn exactly as they always did.
|
|
259
|
+
*/
|
|
260
|
+
sessionId?: string
|
|
261
|
+
/**
|
|
262
|
+
* Deliberately put to sleep: the PTY is gone, the frozen viewport stays, and
|
|
263
|
+
* focusing the tab resumes it. Distinct from the `disconnected` it rides on —
|
|
264
|
+
* that also means "restored from a snapshot after a restart" — and only used
|
|
265
|
+
* to tell the two apart in the sidebar glyph, the pane overlay, and the wake.
|
|
266
|
+
* Ephemeral — never persisted, never on the wire: a restart turns every tab
|
|
267
|
+
* disconnected anyway, and Ctrl+r resumes them just the same.
|
|
268
|
+
*/
|
|
269
|
+
hibernated?: boolean
|
|
245
270
|
errorMessage?: string
|
|
246
271
|
exitCode?: number
|
|
247
272
|
workspaceId?: string
|
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}`} />
|
|
@@ -62,6 +62,10 @@ export const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
62
62
|
// Only the working case animates, so the timer is off for every other row —
|
|
63
63
|
// and off entirely when a sprite is drawing this row instead.
|
|
64
64
|
const spinner = useBusySpinner(activity.working && sprite === null)
|
|
65
|
+
// A primitive, so the selector stays referentially stable across renders.
|
|
66
|
+
const hasSleepingTabs = useAppStore((s) =>
|
|
67
|
+
s.tabs.some((tab) => tab.workspaceId === workspace.id && tab.hibernated === true)
|
|
68
|
+
)
|
|
65
69
|
|
|
66
70
|
const handleMouseDown = useCallback(
|
|
67
71
|
(event: OtuiMouseEvent) => {
|
|
@@ -102,6 +106,10 @@ export const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
102
106
|
}),
|
|
103
107
|
],
|
|
104
108
|
]
|
|
109
|
+
entries.push([
|
|
110
|
+
'Hibernate',
|
|
111
|
+
() => runSideEffectGlobal({ type: 'hibernate-workspace', workspaceId: workspace.id }),
|
|
112
|
+
])
|
|
105
113
|
if (workspace.source !== 'primary') {
|
|
106
114
|
entries.push([
|
|
107
115
|
'Remove workspace',
|
|
@@ -184,6 +192,12 @@ export const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
184
192
|
} else if (activity.done) {
|
|
185
193
|
statusGlyph = '● '
|
|
186
194
|
statusColor = t.success
|
|
195
|
+
} else if (hasSleepingTabs) {
|
|
196
|
+
// Last, because anything else is news and this is the absence of it. Without
|
|
197
|
+
// a marker a hibernated workspace is indistinguishable from an idle one, and
|
|
198
|
+
// you would not know which rows still hold a running assistant.
|
|
199
|
+
statusGlyph = 'z '
|
|
200
|
+
statusColor = t.textMuted
|
|
187
201
|
}
|
|
188
202
|
|
|
189
203
|
return (
|
|
@@ -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
|
}
|
|
@@ -573,7 +573,11 @@ export function TerminalPane({
|
|
|
573
573
|
// re-introducing the shifted-content / dead-row bug.
|
|
574
574
|
<box position="absolute" bottom={0} left={0} backgroundColor={editorBg}>
|
|
575
575
|
{tab?.status === 'disconnected' ? (
|
|
576
|
-
<text fg={t.warning}>
|
|
576
|
+
<text fg={t.warning}>
|
|
577
|
+
{tab.hibernated === true
|
|
578
|
+
? 'Hibernated. Resuming…'
|
|
579
|
+
: 'Restored snapshot. Press Ctrl+r to restart this tab.'}
|
|
580
|
+
</text>
|
|
577
581
|
) : null}
|
|
578
582
|
{tab?.errorMessage != null && tab?.errorMessage !== '' ? (
|
|
579
583
|
<text fg={t.error}>{tab.errorMessage}</text>
|