@brimveyn/aimux 1.23.5 → 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/side-effects.ts +4 -0
- package/src/app-runtime/tab-actions.ts +14 -3
- package/src/app-runtime/workspace-actions.ts +49 -1
- package/src/app.tsx +18 -1
- package/src/git/worktree.ts +17 -1
- package/src/input/modes/types.ts +1 -0
- package/src/pty/command-registry.ts +99 -2
- package/src/state/actions.ts +1 -0
- package/src/state/project-persistence.ts +2 -0
- package/src/state/reducers/tab-state.ts +15 -0
- package/src/state/types.ts +21 -0
- package/src/ui/components/layout/sidebar/workspace-row.tsx +14 -0
- 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",
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
startExistingTab,
|
|
66
66
|
} from './tab-actions'
|
|
67
67
|
import {
|
|
68
|
+
hibernateWorkspace,
|
|
68
69
|
isForceableWorkspaceDeleteError,
|
|
69
70
|
runDeleteWorkspace,
|
|
70
71
|
runMoveWorkspace,
|
|
@@ -390,6 +391,9 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
390
391
|
backend.disposeSession(effect.tabId)
|
|
391
392
|
return
|
|
392
393
|
}
|
|
394
|
+
case 'hibernate-workspace':
|
|
395
|
+
hibernateWorkspace(ctx, effect.workspaceId)
|
|
396
|
+
return
|
|
393
397
|
case 'restart-tab':
|
|
394
398
|
restartTabSession(
|
|
395
399
|
backend,
|
|
@@ -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,
|
|
@@ -214,6 +214,50 @@ function disposeWorkspaceTabs(ctx: SideEffectContext, workspaceId: string): void
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
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
|
+
|
|
217
261
|
export async function runDeleteWorkspace(
|
|
218
262
|
ctx: SideEffectContext,
|
|
219
263
|
projectId: string,
|
|
@@ -415,7 +459,11 @@ export function removeWorkspaceRecordFromProject(
|
|
|
415
459
|
}
|
|
416
460
|
|
|
417
461
|
export function isForceableWorkspaceDeleteError(message: string): boolean {
|
|
418
|
-
|
|
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(
|
|
419
467
|
message
|
|
420
468
|
)
|
|
421
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 }
|
|
@@ -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/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,
|
|
@@ -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
|
@@ -151,6 +151,8 @@ export interface PersistedTabSnapshot {
|
|
|
151
151
|
assistant: AssistantId
|
|
152
152
|
title: string
|
|
153
153
|
command: string
|
|
154
|
+
/** Optional and additive: an older build ignores it and spawns fresh. */
|
|
155
|
+
sessionId?: string
|
|
154
156
|
status: Exclude<LegacyPersistedTabStatus, 'disconnected'>
|
|
155
157
|
buffer: string
|
|
156
158
|
viewport?: TerminalSnapshot
|
|
@@ -246,6 +248,25 @@ export interface TabSession {
|
|
|
246
248
|
viewport?: TerminalSnapshot
|
|
247
249
|
terminalModes: TerminalModeState
|
|
248
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
|
|
249
270
|
errorMessage?: string
|
|
250
271
|
exitCode?: number
|
|
251
272
|
workspaceId?: string
|
|
@@ -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 (
|
|
@@ -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>
|