@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.23.4",
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.5",
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
- for (const workspace of project.workspaces ?? []) {
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: extraArgs ? [...args, ...extraArgs] : 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
- 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(
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 { 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 }
@@ -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 { 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
  }
@@ -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,
@@ -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.workspaces.find((workspace) => workspace.id === project.activeWorkspaceId) ??
302
- project.workspaces[0]
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,
@@ -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
@@ -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, ProjectStatus } from '../../../../state/types'
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 { getPrimaryWorkspace } from '../../../../state/project-workspaces'
14
- // eslint-disable-next-line no-duplicate-imports
15
- import { IDLE_PROJECT_STATUS } from '../../../../state/types'
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
- * U+2699, not the nerd-font gear: its Emoji_Presentation is No, so a conforming
36
- * terminal draws it text-style in one cell and no font has to be installed for
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 rawActiveWorkspaceId = currentProject?.activeWorkspaceId
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 workspaces) {
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
- // Neutral muted marker, and a question when a workspace of this project needs
461
- // an answer. No progress indicator: a heading can only say "somewhere below",
462
- // and the row that actually knows is one line down. Keeping a glyph in the slot
463
- // avoids name shifts when the question comes and goes — which is also why the
464
- // gap after it is part of the glyph.
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 fg={leadingColor} selectable={false} wrapMode="none">
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
- <box
205
- height={1}
206
- flexShrink={0}
207
- flexDirection="row"
208
- paddingLeft={ROW2_INDENT}
209
- paddingRight={1}
210
- overflow="hidden"
211
- >
212
- {ambient !== '' ? (
213
- <text fg={t.textMuted} wrapMode="none" selectable={false}>
214
- {ambient}
215
- </text>
216
- ) : null}
217
- </box>
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}>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>