scc-cli 1.5.3__py3-none-any.whl

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.

Potentially problematic release.


This version of scc-cli might be problematic. Click here for more details.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,452 @@
1
+ """Data loading functions for dashboard tabs.
2
+
3
+ This module contains functions to load data for each dashboard tab:
4
+ - Status: System overview (team, organization, counts)
5
+ - Containers: Docker containers managed by SCC
6
+ - Sessions: Recent Claude sessions
7
+ - Worktrees: Git worktrees in current repository
8
+
9
+ Each loader function returns a TabData instance ready for display.
10
+ Loaders handle errors gracefully, returning placeholder items on failure.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from ..list_screen import ListItem
18
+ from .models import DashboardTab, TabData
19
+
20
+
21
+ def _load_status_tab_data() -> TabData:
22
+ """Load Status tab data showing system overview.
23
+
24
+ The Status tab displays:
25
+ - Current team and organization info
26
+ - Sync status with remote config
27
+ - Resource counts for quick overview
28
+ - Optional enhancements (statusline)
29
+
30
+ Returns:
31
+ TabData with status summary items.
32
+ """
33
+ # Import here to avoid circular imports
34
+ from ... import config, docker, sessions
35
+ from ...docker import core as docker_core
36
+
37
+ items: list[ListItem[str]] = []
38
+
39
+ # Start new session (primary action)
40
+ items.append(
41
+ ListItem(
42
+ value="start_session",
43
+ label="Start new session",
44
+ description="Launch Claude in a workspace",
45
+ )
46
+ )
47
+
48
+ # Load current team info
49
+ try:
50
+ user_config = config.load_user_config()
51
+ team = user_config.get("selected_profile")
52
+ org_source = user_config.get("organization_source")
53
+
54
+ if team:
55
+ items.append(
56
+ ListItem(
57
+ value="team",
58
+ label="Team",
59
+ description=f"{team} (Enter to switch)",
60
+ )
61
+ )
62
+ else:
63
+ items.append(
64
+ ListItem(
65
+ value="team",
66
+ label="Team",
67
+ description="No team selected (Enter to choose)",
68
+ )
69
+ )
70
+
71
+ # Organization/sync status
72
+ if org_source and isinstance(org_source, dict):
73
+ org_url = org_source.get("url", "")
74
+ if org_url:
75
+ # Extract domain for display
76
+ domain = org_url.replace("https://", "").replace("http://", "").split("/")[0]
77
+ org_name = None
78
+ try:
79
+ org_config = config.load_cached_org_config()
80
+ if org_config:
81
+ org_name = org_config.get("organization", {}).get("name")
82
+ except Exception:
83
+ org_name = None
84
+
85
+ desc = domain
86
+ if org_name:
87
+ desc = f"{org_name} ({domain})"
88
+
89
+ items.append(
90
+ ListItem(
91
+ value="organization",
92
+ label="Organization",
93
+ description=desc,
94
+ )
95
+ )
96
+ elif user_config.get("standalone"):
97
+ items.append(
98
+ ListItem(
99
+ value="organization",
100
+ label="Mode",
101
+ description="Standalone (no remote config)",
102
+ )
103
+ )
104
+
105
+ except Exception:
106
+ items.append(
107
+ ListItem(
108
+ value="config_error",
109
+ label="Configuration",
110
+ description="Error loading config",
111
+ )
112
+ )
113
+
114
+ # Load container count
115
+ try:
116
+ containers = docker_core.list_scc_containers()
117
+ running = sum(1 for c in containers if "Up" in c.status)
118
+ total = len(containers)
119
+ items.append(
120
+ ListItem(
121
+ value="containers",
122
+ label="Containers",
123
+ description=f"{running} running, {total} total",
124
+ )
125
+ )
126
+ except Exception:
127
+ items.append(
128
+ ListItem(
129
+ value="containers",
130
+ label="Containers",
131
+ description="Unable to query Docker",
132
+ )
133
+ )
134
+
135
+ # Load session count
136
+ try:
137
+ recent_sessions = sessions.list_recent(limit=100)
138
+ session_count = len(recent_sessions)
139
+ items.append(
140
+ ListItem(
141
+ value="sessions",
142
+ label="Sessions",
143
+ description=f"{session_count} recorded",
144
+ )
145
+ )
146
+ except Exception:
147
+ items.append(
148
+ ListItem(
149
+ value="sessions",
150
+ label="Sessions",
151
+ description="Error loading sessions",
152
+ )
153
+ )
154
+
155
+ # Check statusline status (optional enhancement)
156
+ try:
157
+ settings = docker.get_sandbox_settings()
158
+ has_statusline = settings is not None and "statusLine" in settings
159
+ if has_statusline:
160
+ items.append(
161
+ ListItem(
162
+ value="statusline_installed",
163
+ label="Statusline",
164
+ description="✓ Installed",
165
+ )
166
+ )
167
+ else:
168
+ items.append(
169
+ ListItem(
170
+ value="statusline_not_installed",
171
+ label="Statusline",
172
+ description="Not installed - Enter to install",
173
+ governance_status="warning",
174
+ )
175
+ )
176
+ except Exception:
177
+ # Docker not available - don't show statusline option
178
+ pass
179
+
180
+ return TabData(
181
+ tab=DashboardTab.STATUS,
182
+ title="Status",
183
+ items=items,
184
+ count_active=len(items),
185
+ count_total=len(items),
186
+ )
187
+
188
+
189
+ def _load_containers_tab_data() -> TabData:
190
+ """Load Containers tab data showing SCC-managed containers.
191
+
192
+ Returns:
193
+ TabData with container list items.
194
+ """
195
+ from ...docker import core as docker_core
196
+
197
+ items: list[ListItem[str]] = []
198
+
199
+ try:
200
+ containers = docker_core.list_scc_containers()
201
+ running_count = 0
202
+
203
+ for container in containers:
204
+ is_running = "Up" in container.status
205
+ if is_running:
206
+ running_count += 1
207
+
208
+ # Build description from available info
209
+ desc_parts = []
210
+ if container.profile:
211
+ desc_parts.append(container.profile)
212
+ if container.workspace:
213
+ # Show just the workspace name
214
+ workspace_name = container.workspace.split("/")[-1]
215
+ desc_parts.append(workspace_name)
216
+ if container.status:
217
+ # Simplify status (e.g., "Up 2 hours" → "Up 2h")
218
+ status_short = container.status.replace(" hours", "h").replace(" hour", "h")
219
+ status_short = status_short.replace(" minutes", "m").replace(" minute", "m")
220
+ status_short = status_short.replace(" days", "d").replace(" day", "d")
221
+ desc_parts.append(status_short)
222
+
223
+ items.append(
224
+ ListItem(
225
+ value=container.id,
226
+ label=container.name,
227
+ description=" ".join(desc_parts),
228
+ )
229
+ )
230
+
231
+ if not items:
232
+ items.append(
233
+ ListItem(
234
+ value="no_containers",
235
+ label="No containers",
236
+ description="Run 'scc start' to create one",
237
+ )
238
+ )
239
+
240
+ return TabData(
241
+ tab=DashboardTab.CONTAINERS,
242
+ title="Containers",
243
+ items=items,
244
+ count_active=running_count,
245
+ count_total=len(containers),
246
+ )
247
+
248
+ except Exception:
249
+ return TabData(
250
+ tab=DashboardTab.CONTAINERS,
251
+ title="Containers",
252
+ items=[
253
+ ListItem(
254
+ value="error",
255
+ label="Error",
256
+ description="Unable to query Docker",
257
+ )
258
+ ],
259
+ count_active=0,
260
+ count_total=0,
261
+ )
262
+
263
+
264
+ def _load_sessions_tab_data() -> TabData:
265
+ """Load Sessions tab data showing recent Claude sessions.
266
+
267
+ Returns:
268
+ TabData with session list items. Each ListItem.value contains
269
+ the raw session dict for access in the details pane.
270
+ """
271
+ from ... import sessions
272
+
273
+ items: list[ListItem[dict[str, Any]]] = []
274
+
275
+ try:
276
+ recent = sessions.list_recent(limit=20)
277
+
278
+ for session in recent:
279
+ name = session.get("name", "Unnamed")
280
+ desc_parts = []
281
+
282
+ if session.get("team"):
283
+ desc_parts.append(str(session["team"]))
284
+ if session.get("branch"):
285
+ desc_parts.append(str(session["branch"]))
286
+ if session.get("last_used"):
287
+ desc_parts.append(str(session["last_used"]))
288
+
289
+ # Store full session dict for details pane access
290
+ items.append(
291
+ ListItem(
292
+ value=session,
293
+ label=name,
294
+ description=" ".join(desc_parts),
295
+ )
296
+ )
297
+
298
+ if not items:
299
+ # Placeholder with sentinel dict (startable: True enables Enter action)
300
+ items.append(
301
+ ListItem(
302
+ value={"_placeholder": "no_sessions", "_startable": True},
303
+ label="No sessions",
304
+ description="Press Enter to start",
305
+ )
306
+ )
307
+
308
+ return TabData(
309
+ tab=DashboardTab.SESSIONS,
310
+ title="Sessions",
311
+ items=items,
312
+ count_active=len(recent),
313
+ count_total=len(recent),
314
+ )
315
+
316
+ except Exception:
317
+ return TabData(
318
+ tab=DashboardTab.SESSIONS,
319
+ title="Sessions",
320
+ items=[
321
+ ListItem(
322
+ value="error",
323
+ label="Error",
324
+ description="Unable to load sessions",
325
+ )
326
+ ],
327
+ count_active=0,
328
+ count_total=0,
329
+ )
330
+
331
+
332
+ def _load_worktrees_tab_data(verbose: bool = False) -> TabData:
333
+ """Load Worktrees tab data showing git worktrees.
334
+
335
+ Worktrees are loaded from the current working directory if it's a git repo.
336
+
337
+ Args:
338
+ verbose: If True, fetch git status for each worktree (slower but shows
339
+ staged/modified/untracked counts with +N/!N/?N indicators).
340
+
341
+ Returns:
342
+ TabData with worktree list items.
343
+ """
344
+ import os
345
+ from pathlib import Path
346
+
347
+ from ... import git
348
+
349
+ items: list[ListItem[str]] = []
350
+
351
+ try:
352
+ cwd = Path(os.getcwd())
353
+ worktrees = git.list_worktrees(cwd)
354
+ current_count = 0
355
+
356
+ # If verbose, fetch status for each worktree
357
+ if verbose:
358
+ for wt in worktrees:
359
+ staged, modified, untracked, timed_out = git.get_worktree_status(wt.path)
360
+ wt.staged_count = staged
361
+ wt.modified_count = modified
362
+ wt.untracked_count = untracked
363
+ wt.status_timed_out = timed_out
364
+ wt.has_changes = (staged + modified + untracked) > 0
365
+
366
+ for wt in worktrees:
367
+ if wt.is_current:
368
+ current_count += 1
369
+
370
+ desc_parts = []
371
+ if wt.branch:
372
+ desc_parts.append(wt.branch)
373
+
374
+ # Show status markers when verbose
375
+ if verbose:
376
+ if wt.status_timed_out:
377
+ desc_parts.append("…") # Timeout indicator
378
+ else:
379
+ status_parts = []
380
+ if wt.staged_count > 0:
381
+ status_parts.append(f"+{wt.staged_count}")
382
+ if wt.modified_count > 0:
383
+ status_parts.append(f"!{wt.modified_count}")
384
+ if wt.untracked_count > 0:
385
+ status_parts.append(f"?{wt.untracked_count}")
386
+ if status_parts:
387
+ desc_parts.append(" ".join(status_parts))
388
+ elif not wt.has_changes:
389
+ desc_parts.append(".") # Clean indicator
390
+ elif wt.has_changes:
391
+ desc_parts.append("*modified")
392
+
393
+ if wt.is_current:
394
+ desc_parts.append("(current)")
395
+
396
+ items.append(
397
+ ListItem(
398
+ value=wt.path,
399
+ label=Path(wt.path).name,
400
+ description=" ".join(desc_parts),
401
+ )
402
+ )
403
+
404
+ if not items:
405
+ items.append(
406
+ ListItem(
407
+ value="no_worktrees",
408
+ label="No worktrees",
409
+ description="Press 'w' recent | 'i' init | 'c' clone",
410
+ )
411
+ )
412
+
413
+ return TabData(
414
+ tab=DashboardTab.WORKTREES,
415
+ title="Worktrees",
416
+ items=items,
417
+ count_active=current_count,
418
+ count_total=len(worktrees),
419
+ )
420
+
421
+ except Exception:
422
+ return TabData(
423
+ tab=DashboardTab.WORKTREES,
424
+ title="Worktrees",
425
+ items=[
426
+ ListItem(
427
+ value="no_git",
428
+ label="Not available",
429
+ description="Press 'w' recent | 'i' init | 'c' clone",
430
+ )
431
+ ],
432
+ count_active=0,
433
+ count_total=0,
434
+ )
435
+
436
+
437
+ def _load_all_tab_data(verbose_worktrees: bool = False) -> dict[DashboardTab, TabData]:
438
+ """Load data for all dashboard tabs.
439
+
440
+ Args:
441
+ verbose_worktrees: If True, fetch git status for each worktree
442
+ (shows +N/!N/?N indicators but takes longer).
443
+
444
+ Returns:
445
+ Dictionary mapping each tab to its data.
446
+ """
447
+ return {
448
+ DashboardTab.STATUS: _load_status_tab_data(),
449
+ DashboardTab.CONTAINERS: _load_containers_tab_data(),
450
+ DashboardTab.SESSIONS: _load_sessions_tab_data(),
451
+ DashboardTab.WORKTREES: _load_worktrees_tab_data(verbose=verbose_worktrees),
452
+ }
@@ -0,0 +1,185 @@
1
+ """Data models for the dashboard module.
2
+
3
+ This module contains the core data structures used by the dashboard:
4
+ - DashboardTab: Enum for available tabs
5
+ - TabData: Content for a single tab
6
+ - DashboardState: State management for the dashboard
7
+
8
+ These models are intentionally simple dataclasses with no external dependencies
9
+ beyond the UI layer, enabling clean separation and testability.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass
16
+ from enum import Enum, auto
17
+ from typing import Any
18
+
19
+ from ..list_screen import ListItem, ListState
20
+
21
+
22
+ class DashboardTab(Enum):
23
+ """Available dashboard tabs.
24
+
25
+ Each tab represents a major resource category in SCC.
26
+ Tabs are displayed in definition order (Status first, Worktrees last).
27
+ """
28
+
29
+ STATUS = auto()
30
+ CONTAINERS = auto()
31
+ SESSIONS = auto()
32
+ WORKTREES = auto()
33
+
34
+ @property
35
+ def display_name(self) -> str:
36
+ """Human-readable name for display in chrome."""
37
+ names = {
38
+ DashboardTab.STATUS: "Status",
39
+ DashboardTab.CONTAINERS: "Containers",
40
+ DashboardTab.SESSIONS: "Sessions",
41
+ DashboardTab.WORKTREES: "Worktrees",
42
+ }
43
+ return names[self]
44
+
45
+
46
+ # Ordered list for tab cycling
47
+ TAB_ORDER: tuple[DashboardTab, ...] = (
48
+ DashboardTab.STATUS,
49
+ DashboardTab.CONTAINERS,
50
+ DashboardTab.SESSIONS,
51
+ DashboardTab.WORKTREES,
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class TabData:
57
+ """Data for a single dashboard tab.
58
+
59
+ Attributes:
60
+ tab: The tab identifier.
61
+ title: Display title for the tab content area.
62
+ items: List items to display in this tab. Value type varies by tab:
63
+ - Containers/Worktrees: str (container ID or worktree name)
64
+ - Sessions: dict[str, Any] (full session data for details pane)
65
+ count_active: Number of active items (e.g., running containers).
66
+ count_total: Total number of items.
67
+ """
68
+
69
+ tab: DashboardTab
70
+ title: str
71
+ items: Sequence[ListItem[Any]]
72
+ count_active: int
73
+ count_total: int
74
+
75
+ @property
76
+ def subtitle(self) -> str:
77
+ """Generate subtitle from counts."""
78
+ if self.count_active == self.count_total:
79
+ return f"{self.count_total} total"
80
+ return f"{self.count_active} active, {self.count_total} total"
81
+
82
+
83
+ @dataclass
84
+ class DashboardState:
85
+ """State for the tabbed dashboard view.
86
+
87
+ Manages which tab is active and provides methods for tab navigation.
88
+ Each tab switch resets the list state for the new tab.
89
+
90
+ Attributes:
91
+ active_tab: Currently active tab.
92
+ tabs: Mapping from tab to its data.
93
+ list_state: Navigation state for the current tab's list.
94
+ status_message: Transient message to display (cleared on next action).
95
+ details_open: Whether the details pane is visible.
96
+ help_visible: Whether the help overlay is shown (rendered inside Live).
97
+ """
98
+
99
+ active_tab: DashboardTab
100
+ tabs: dict[DashboardTab, TabData]
101
+ list_state: ListState[str]
102
+ status_message: str | None = None
103
+ details_open: bool = False
104
+ help_visible: bool = False
105
+ verbose_worktrees: bool = False # Toggle for worktree status display
106
+
107
+ @property
108
+ def current_tab_data(self) -> TabData:
109
+ """Get data for the currently active tab."""
110
+ return self.tabs[self.active_tab]
111
+
112
+ def is_placeholder_selected(self) -> bool:
113
+ """Check if the current selection is a placeholder row.
114
+
115
+ Placeholder rows represent empty states or errors (e.g., "No containers",
116
+ "Error loading sessions") and shouldn't show details.
117
+
118
+ Placeholders can be identified by:
119
+ - String value matching known placeholder names (containers, worktrees)
120
+ - Dict value with "_placeholder" key (sessions)
121
+
122
+ Returns:
123
+ True if current item is a placeholder, False otherwise.
124
+ """
125
+ current = self.list_state.current_item
126
+ if not current:
127
+ return True # No item = treat as placeholder
128
+
129
+ # Known placeholder string values from tab data loaders
130
+ placeholder_values = {
131
+ "no_containers",
132
+ "no_sessions",
133
+ "no_worktrees",
134
+ "no_git",
135
+ "error",
136
+ "config_error",
137
+ }
138
+
139
+ # Check string placeholders (must be string type first - dicts are unhashable)
140
+ if isinstance(current.value, str) and current.value in placeholder_values:
141
+ return True
142
+
143
+ # Check dict placeholders (sessions tab uses dicts)
144
+ if isinstance(current.value, dict) and "_placeholder" in current.value:
145
+ return True
146
+
147
+ return False
148
+
149
+ def switch_tab(self, tab: DashboardTab) -> DashboardState:
150
+ """Create new state with different active tab.
151
+
152
+ Resets list state (cursor, filter) for the new tab.
153
+
154
+ Args:
155
+ tab: Tab to switch to.
156
+
157
+ Returns:
158
+ New DashboardState with the specified tab active.
159
+ """
160
+ new_list_state = ListState(items=self.tabs[tab].items)
161
+ return DashboardState(
162
+ active_tab=tab,
163
+ tabs=self.tabs,
164
+ list_state=new_list_state,
165
+ )
166
+
167
+ def next_tab(self) -> DashboardState:
168
+ """Switch to the next tab (wraps around).
169
+
170
+ Returns:
171
+ New DashboardState with next tab active.
172
+ """
173
+ current_index = TAB_ORDER.index(self.active_tab)
174
+ next_index = (current_index + 1) % len(TAB_ORDER)
175
+ return self.switch_tab(TAB_ORDER[next_index])
176
+
177
+ def prev_tab(self) -> DashboardState:
178
+ """Switch to the previous tab (wraps around).
179
+
180
+ Returns:
181
+ New DashboardState with previous tab active.
182
+ """
183
+ current_index = TAB_ORDER.index(self.active_tab)
184
+ prev_index = (current_index - 1) % len(TAB_ORDER)
185
+ return self.switch_tab(TAB_ORDER[prev_index])