@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.23.5",
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.6",
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: extraArgs ? [...args, ...extraArgs] : 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
- return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes/i.test(
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 { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
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)
@@ -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
- throw new Error(result.stderr.toString().trim() || 'failed to remove git worktree')
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[]> {
@@ -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 { basename } from 'node:path'
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
- const custom = customCommands[assistant]
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
  }
@@ -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,
@@ -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}>Restored snapshot. Press Ctrl+r to restart this project.</text>
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>