scc-cli 1.4.0__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 (112) 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 +259 -0
  8. scc_cli/cli_admin.py +683 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1400 -0
  16. scc_cli/cli_org.py +1433 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +858 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +603 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1082 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1405 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/compute.py +377 -0
  46. scc_cli/marketplace/constants.py +87 -0
  47. scc_cli/marketplace/managed.py +135 -0
  48. scc_cli/marketplace/materialize.py +723 -0
  49. scc_cli/marketplace/normalize.py +548 -0
  50. scc_cli/marketplace/render.py +238 -0
  51. scc_cli/marketplace/resolve.py +459 -0
  52. scc_cli/marketplace/schema.py +502 -0
  53. scc_cli/marketplace/sync.py +257 -0
  54. scc_cli/marketplace/team_cache.py +195 -0
  55. scc_cli/marketplace/team_fetch.py +688 -0
  56. scc_cli/marketplace/trust.py +244 -0
  57. scc_cli/models/__init__.py +41 -0
  58. scc_cli/models/exceptions.py +273 -0
  59. scc_cli/models/plugin_audit.py +434 -0
  60. scc_cli/org_templates.py +269 -0
  61. scc_cli/output_mode.py +167 -0
  62. scc_cli/panels.py +113 -0
  63. scc_cli/platform.py +350 -0
  64. scc_cli/profiles.py +1034 -0
  65. scc_cli/remote.py +443 -0
  66. scc_cli/schemas/__init__.py +1 -0
  67. scc_cli/schemas/org-v1.schema.json +456 -0
  68. scc_cli/schemas/team-config.v1.schema.json +163 -0
  69. scc_cli/sessions.py +425 -0
  70. scc_cli/setup.py +582 -0
  71. scc_cli/source_resolver.py +470 -0
  72. scc_cli/stats.py +378 -0
  73. scc_cli/stores/__init__.py +13 -0
  74. scc_cli/stores/exception_store.py +251 -0
  75. scc_cli/subprocess_utils.py +88 -0
  76. scc_cli/teams.py +339 -0
  77. scc_cli/templates/__init__.py +2 -0
  78. scc_cli/templates/org/__init__.py +0 -0
  79. scc_cli/templates/org/minimal.json +19 -0
  80. scc_cli/templates/org/reference.json +74 -0
  81. scc_cli/templates/org/strict.json +38 -0
  82. scc_cli/templates/org/teams.json +42 -0
  83. scc_cli/templates/statusline.sh +75 -0
  84. scc_cli/theme.py +348 -0
  85. scc_cli/ui/__init__.py +124 -0
  86. scc_cli/ui/branding.py +68 -0
  87. scc_cli/ui/chrome.py +395 -0
  88. scc_cli/ui/dashboard/__init__.py +62 -0
  89. scc_cli/ui/dashboard/_dashboard.py +669 -0
  90. scc_cli/ui/dashboard/loaders.py +369 -0
  91. scc_cli/ui/dashboard/models.py +184 -0
  92. scc_cli/ui/dashboard/orchestrator.py +337 -0
  93. scc_cli/ui/formatters.py +443 -0
  94. scc_cli/ui/gate.py +350 -0
  95. scc_cli/ui/help.py +157 -0
  96. scc_cli/ui/keys.py +521 -0
  97. scc_cli/ui/list_screen.py +431 -0
  98. scc_cli/ui/picker.py +700 -0
  99. scc_cli/ui/prompts.py +200 -0
  100. scc_cli/ui/wizard.py +490 -0
  101. scc_cli/update.py +680 -0
  102. scc_cli/utils/__init__.py +39 -0
  103. scc_cli/utils/fixit.py +264 -0
  104. scc_cli/utils/fuzzy.py +124 -0
  105. scc_cli/utils/locks.py +101 -0
  106. scc_cli/utils/ttl.py +376 -0
  107. scc_cli/validate.py +455 -0
  108. scc_cli-1.4.0.dist-info/METADATA +369 -0
  109. scc_cli-1.4.0.dist-info/RECORD +112 -0
  110. scc_cli-1.4.0.dist-info/WHEEL +4 -0
  111. scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
  112. scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,669 @@
1
+ """Dashboard component for interactive tabbed view.
2
+
3
+ This module contains the Dashboard class that provides the interactive
4
+ tabbed interface for SCC resources. It handles:
5
+ - Tab state management and navigation
6
+ - List rendering within each tab
7
+ - Details pane with responsive layout
8
+ - Action handling and state updates
9
+
10
+ The underscore prefix signals this is an internal implementation module.
11
+ Public API is exported via __init__.py.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from rich.console import Group, RenderableType
19
+ from rich.live import Live
20
+ from rich.padding import Padding
21
+ from rich.table import Table
22
+ from rich.text import Text
23
+
24
+ # Import config for standalone mode detection
25
+ from ... import config as scc_config
26
+ from ...theme import Indicators
27
+ from ..chrome import Chrome, ChromeConfig, FooterHint
28
+ from ..keys import (
29
+ Action,
30
+ ActionType,
31
+ KeyReader,
32
+ RefreshRequested,
33
+ SessionResumeRequested,
34
+ StartRequested,
35
+ TeamSwitchRequested,
36
+ )
37
+ from ..list_screen import ListItem
38
+ from .models import TAB_ORDER, DashboardState, DashboardTab
39
+
40
+
41
+ class Dashboard:
42
+ """Interactive tabbed dashboard for SCC resources.
43
+
44
+ The Dashboard provides a unified view of SCC resources organized by tabs.
45
+ It handles tab switching, navigation within tabs, and rendering.
46
+
47
+ Attributes:
48
+ state: Current dashboard state (tabs, active tab, list state).
49
+ """
50
+
51
+ def __init__(self, state: DashboardState) -> None:
52
+ """Initialize dashboard.
53
+
54
+ Args:
55
+ state: Initial dashboard state with tab data.
56
+ """
57
+ self.state = state
58
+ from ...console import get_err_console
59
+
60
+ self._console = get_err_console()
61
+ # Track last layout mode for hysteresis (prevents flip-flop at resize boundary)
62
+ self._last_side_by_side: bool | None = None
63
+
64
+ def run(self) -> None:
65
+ """Run the interactive dashboard.
66
+
67
+ Blocks until the user quits (q or Esc).
68
+ """
69
+ # Use custom_keys for dashboard-specific actions that aren't in DEFAULT_KEY_MAP
70
+ # This allows 'r' to be a filter char in pickers but REFRESH in dashboard
71
+ # 'n' (new session) is also screen-specific to avoid global key conflicts
72
+ reader = KeyReader(custom_keys={"r": "refresh", "n": "new_session"}, enable_filter=True)
73
+
74
+ with Live(
75
+ self._render(),
76
+ console=self._console,
77
+ auto_refresh=False, # Manual refresh for instant response
78
+ transient=True,
79
+ ) as live:
80
+ while True:
81
+ # Pass filter_active based on actual filter state, not always True
82
+ # When filter is empty, j/k navigate; when typing, j/k become filter chars
83
+ action = reader.read(filter_active=bool(self.state.list_state.filter_query))
84
+
85
+ # Help overlay dismissal: any key while help is visible just closes help
86
+ # This is the standard pattern for modal overlays in Rich Live applications
87
+ if self.state.help_visible:
88
+ self.state.help_visible = False
89
+ live.update(self._render(), refresh=True)
90
+ continue # Consume the keypress (don't process it further)
91
+
92
+ result = self._handle_action(action)
93
+ if result is False:
94
+ return
95
+
96
+ # Refresh if action changed state OR handler requests refresh
97
+ needs_refresh = result is True or action.state_changed
98
+ if needs_refresh:
99
+ live.update(self._render(), refresh=True)
100
+
101
+ def _render(self) -> RenderableType:
102
+ """Render the current dashboard state.
103
+
104
+ Uses responsive layout when details pane is open:
105
+ - ≥110 columns: side-by-side (list | details)
106
+ - <110 columns: stacked (list above details)
107
+ - Status tab: details auto-hidden via render rule
108
+
109
+ Help overlay is rendered INSIDE the Live context to avoid scroll artifacts.
110
+ When help_visible is True, the help panel overlays the normal content.
111
+ """
112
+ # If help overlay is visible, render it instead of normal content
113
+ # This renders INSIDE the Live context, avoiding scroll artifacts
114
+ if self.state.help_visible:
115
+ from ..help import HelpMode, render_help_content
116
+
117
+ return render_help_content(HelpMode.DASHBOARD)
118
+
119
+ list_body = self._render_list_body()
120
+ config = self._get_chrome_config()
121
+ chrome = Chrome(config)
122
+
123
+ # Check if details should be shown (render rule: not on Status tab)
124
+ show_details = self.state.details_open and self.state.active_tab != DashboardTab.STATUS
125
+
126
+ body: RenderableType = list_body
127
+ if show_details and not self.state.is_placeholder_selected():
128
+ # Render details pane content
129
+ details = self._render_details_pane()
130
+
131
+ # Responsive layout with hysteresis to prevent flip-flop at resize boundary
132
+ # Thresholds: ≥112 → side-by-side, ≤108 → stacked, 109-111 → maintain previous
133
+ terminal_width = self._console.size.width
134
+ if terminal_width >= 112:
135
+ side_by_side = True
136
+ elif terminal_width <= 108:
137
+ side_by_side = False
138
+ elif self._last_side_by_side is not None:
139
+ # In dead zone (109-111): maintain previous layout
140
+ side_by_side = self._last_side_by_side
141
+ else:
142
+ # First render in dead zone: default to stacked (conservative)
143
+ side_by_side = False
144
+
145
+ self._last_side_by_side = side_by_side
146
+ body = self._render_split_view(list_body, details, side_by_side=side_by_side)
147
+
148
+ return chrome.render(body, search_query=self.state.list_state.filter_query)
149
+
150
+ def _render_list_body(self) -> Text:
151
+ """Render the list content for the active tab."""
152
+ text = Text()
153
+ filtered = self.state.list_state.filtered_items
154
+ visible = self.state.list_state.visible_items
155
+
156
+ if not filtered:
157
+ text.append("No items", style="dim italic")
158
+ else:
159
+ for i, item in enumerate(visible):
160
+ actual_index = self.state.list_state.scroll_offset + i
161
+ is_cursor = actual_index == self.state.list_state.cursor
162
+
163
+ if is_cursor:
164
+ text.append(f"{Indicators.get('CURSOR')} ", style="cyan bold")
165
+ else:
166
+ text.append(" ")
167
+
168
+ label_style = "bold" if is_cursor else ""
169
+ text.append(item.label, style=label_style)
170
+
171
+ if item.description:
172
+ text.append(f" {item.description}", style="dim")
173
+
174
+ text.append("\n")
175
+
176
+ # Render status message if present (transient toast)
177
+ if self.state.status_message:
178
+ text.append("\n")
179
+ text.append(f"{Indicators.get('INFO_ICON')} ", style="yellow")
180
+ text.append(self.state.status_message, style="yellow")
181
+ text.append("\n")
182
+
183
+ return text
184
+
185
+ def _render_split_view(
186
+ self,
187
+ list_body: RenderableType,
188
+ details: RenderableType,
189
+ *,
190
+ side_by_side: bool,
191
+ ) -> RenderableType:
192
+ """Render list and details in split view.
193
+
194
+ Uses consistent padding and separators for smooth transitions
195
+ between side-by-side and stacked layouts.
196
+
197
+ Args:
198
+ list_body: The list content.
199
+ details: The details pane content.
200
+ side_by_side: If True, render columns; otherwise stack vertically.
201
+
202
+ Returns:
203
+ Combined renderable.
204
+ """
205
+ # Wrap details in consistent padding for visual balance
206
+ padded_details = Padding(details, (0, 0, 0, 1)) # Left padding
207
+
208
+ if side_by_side:
209
+ # Use Table.grid for side-by-side with vertical separator
210
+ # Table handles row height automatically (no fixed separator height)
211
+ table = Table.grid(expand=True, padding=(0, 1))
212
+ table.add_column("list", ratio=1, no_wrap=False)
213
+ table.add_column("sep", width=1, style="dim", justify="center")
214
+ table.add_column("details", ratio=1, no_wrap=False)
215
+
216
+ # Single vertical bar - Rich expands it to match row height
217
+ table.add_row(list_body, Indicators.get("VERTICAL_LINE"), padded_details)
218
+ return table
219
+ else:
220
+ # Stacked: list above details with thin separator
221
+ # Use same visual weight as side-by-side for smooth switching
222
+ separator = Text(Indicators.get("HORIZONTAL_LINE") * 30, style="dim")
223
+ return Group(
224
+ list_body,
225
+ Text(""), # Blank line for spacing
226
+ separator,
227
+ Text(""), # Blank line for spacing
228
+ padded_details,
229
+ )
230
+
231
+ def _render_details_pane(self) -> RenderableType:
232
+ """Render details pane content for the current item.
233
+
234
+ Content varies by active tab:
235
+ - Containers: ID, status, profile, workspace, commands
236
+ - Sessions: name, path, branch, last_used, resume command
237
+ - Worktrees: path, branch, dirty status, start command
238
+
239
+ Returns:
240
+ Details pane as Rich renderable.
241
+ """
242
+ current = self.state.list_state.current_item
243
+ if not current:
244
+ return Text("No item selected", style="dim italic")
245
+
246
+ tab = self.state.active_tab
247
+
248
+ if tab == DashboardTab.CONTAINERS:
249
+ return self._render_container_details(current)
250
+ elif tab == DashboardTab.SESSIONS:
251
+ return self._render_session_details(current)
252
+ elif tab == DashboardTab.WORKTREES:
253
+ return self._render_worktree_details(current)
254
+ else:
255
+ return Text("Details not available", style="dim")
256
+
257
+ def _render_container_details(self, item: ListItem[Any]) -> RenderableType:
258
+ """Render details for a container item using structured key/value table."""
259
+ # Header
260
+ header = Text()
261
+ header.append("Container Details\n", style="bold cyan")
262
+ header.append(Indicators.get("HORIZONTAL_LINE") * 20, style="dim")
263
+
264
+ # Key/value table
265
+ table = Table.grid(padding=(0, 1))
266
+ table.add_column("key", style="dim", width=10)
267
+ table.add_column("value")
268
+
269
+ table.add_row("Name", Text(item.label, style="bold"))
270
+
271
+ # Short container ID
272
+ container_id = item.value[:12] if len(item.value) > 12 else item.value
273
+ table.add_row("ID", container_id)
274
+
275
+ # Parse description into fields if available
276
+ if item.description:
277
+ parts = item.description.split(" ")
278
+ if len(parts) >= 1 and parts[0]:
279
+ table.add_row("Profile", parts[0])
280
+ if len(parts) >= 2 and parts[1]:
281
+ table.add_row("Workspace", parts[1])
282
+ if len(parts) >= 3 and parts[2]:
283
+ table.add_row("Status", parts[2])
284
+
285
+ # Commands section
286
+ commands = Text()
287
+ commands.append("\nCommands\n", style="dim")
288
+ commands.append(f" docker exec -it {item.label} bash\n", style="cyan")
289
+
290
+ return Group(header, table, commands)
291
+
292
+ def _render_session_details(self, item: ListItem[Any]) -> RenderableType:
293
+ """Render details for a session item using structured key/value table.
294
+
295
+ Uses the raw session dict stored in item.value for field access.
296
+ """
297
+ session = item.value
298
+
299
+ # Header
300
+ header = Text()
301
+ header.append("Session Details\n", style="bold cyan")
302
+ header.append(Indicators.get("HORIZONTAL_LINE") * 20, style="dim")
303
+
304
+ # Key/value table
305
+ table = Table.grid(padding=(0, 1))
306
+ table.add_column("key", style="dim", width=10)
307
+ table.add_column("value")
308
+
309
+ table.add_row("Name", Text(item.label, style="bold"))
310
+
311
+ # Read fields directly from session dict (with None protection)
312
+ if session.get("team"):
313
+ table.add_row("Team", str(session["team"]))
314
+ if session.get("branch"):
315
+ table.add_row("Branch", str(session["branch"]))
316
+ if session.get("workspace"):
317
+ table.add_row("Workspace", str(session["workspace"]))
318
+ if session.get("last_used"):
319
+ table.add_row("Last Used", str(session["last_used"]))
320
+
321
+ # Commands section with None protection and helpful tips
322
+ commands = Text()
323
+ commands.append("\nCommands\n", style="dim")
324
+
325
+ container_name = session.get("container_name")
326
+ session_id = session.get("id")
327
+
328
+ if container_name:
329
+ # Container is available - show resume command
330
+ commands.append(f" scc resume {container_name}\n", style="cyan")
331
+ elif session_id:
332
+ # Session exists but container stopped - show restart tip
333
+ commands.append(" Container stopped. Start new session:\n", style="dim italic")
334
+ commands.append(
335
+ f" scc start --workspace {session.get('workspace', '.')}\n", style="cyan"
336
+ )
337
+ else:
338
+ # Minimal session info - generic tip
339
+ commands.append(" Start session: scc start\n", style="cyan dim")
340
+
341
+ return Group(header, table, commands)
342
+
343
+ def _render_worktree_details(self, item: ListItem[Any]) -> RenderableType:
344
+ """Render details for a worktree item using structured key/value table."""
345
+ # Header
346
+ header = Text()
347
+ header.append("Worktree Details\n", style="bold cyan")
348
+ header.append(Indicators.get("HORIZONTAL_LINE") * 20, style="dim")
349
+
350
+ # Key/value table
351
+ table = Table.grid(padding=(0, 1))
352
+ table.add_column("key", style="dim", width=10)
353
+ table.add_column("value")
354
+
355
+ table.add_row("Name", Text(item.label, style="bold"))
356
+ table.add_row("Path", item.value)
357
+
358
+ # Parse description into fields (branch *modified (current))
359
+ if item.description:
360
+ parts = item.description.split(" ")
361
+ for part in parts:
362
+ if part.startswith("(") and part.endswith(")"):
363
+ table.add_row("Status", Text(part, style="green"))
364
+ elif part == "*modified":
365
+ table.add_row("Changes", Text("Modified", style="yellow"))
366
+ elif part:
367
+ table.add_row("Branch", part)
368
+
369
+ # Commands section
370
+ commands = Text()
371
+ commands.append("\nCommands\n", style="dim")
372
+ commands.append(f" scc start {item.value}\n", style="cyan")
373
+
374
+ return Group(header, table, commands)
375
+
376
+ def _get_placeholder_tip(self, value: str | dict[str, Any]) -> str:
377
+ """Get contextual help tip for placeholder items.
378
+
379
+ Returns actionable guidance for empty/error states.
380
+
381
+ Args:
382
+ value: Either a string placeholder key or a dict with "_placeholder" key.
383
+ """
384
+ tips: dict[str, str] = {
385
+ # Container placeholders (first-time user friendly)
386
+ "no_containers": (
387
+ "No containers running. Press 'n' to start a new session, "
388
+ "or run `scc start <path>` from the terminal."
389
+ ),
390
+ # Session placeholders (first-time user friendly)
391
+ "no_sessions": ("No sessions recorded yet. Press 'n' to create your first session!"),
392
+ # Worktree placeholders
393
+ "no_worktrees": (
394
+ "Not in a git repository. Navigate to a git repo to see worktrees, "
395
+ "or run `git init` to initialize one."
396
+ ),
397
+ "no_git": ("Not in a git repository. Run `git init` or clone a repo first."),
398
+ # Error placeholders (actionable doctor suggestion)
399
+ "error": (
400
+ "Unable to load data. Run `scc doctor` to check Docker status and diagnose issues."
401
+ ),
402
+ "config_error": ("Configuration issue detected. Run `scc doctor` to diagnose and fix."),
403
+ }
404
+
405
+ # Extract placeholder key from dict if needed
406
+ placeholder_key = value
407
+ if isinstance(value, dict):
408
+ placeholder_key = value.get("_placeholder", "")
409
+
410
+ return tips.get(str(placeholder_key), "No details available for this item.")
411
+
412
+ def _compute_footer_hints(self, standalone: bool, show_details: bool) -> tuple[FooterHint, ...]:
413
+ """Compute context-aware footer hints based on current state.
414
+
415
+ Hints reflect available actions for the current selection:
416
+ - Details open: "Esc close"
417
+ - Status tab: No Enter action (info-only)
418
+ - Startable placeholder: "Enter start"
419
+ - Non-startable placeholder: No Enter hint
420
+ - Real item: "Enter details"
421
+
422
+ Args:
423
+ standalone: Whether running in standalone mode (dims team hint).
424
+ show_details: Whether the details pane is currently showing.
425
+
426
+ Returns:
427
+ Tuple of FooterHint objects for the chrome footer.
428
+ """
429
+ hints: list[FooterHint] = [FooterHint("↑↓", "navigate")]
430
+
431
+ # Determine primary action hint based on context
432
+ if show_details:
433
+ # Details pane is open
434
+ if self.state.active_tab == DashboardTab.SESSIONS:
435
+ # Sessions tab: Enter resumes, Esc closes
436
+ hints.append(FooterHint("Enter", "resume"))
437
+ hints.append(FooterHint("Esc", "close"))
438
+ elif self.state.active_tab == DashboardTab.STATUS:
439
+ # Status tab has no actionable items - no Enter hint
440
+ pass
441
+ elif self.state.is_placeholder_selected():
442
+ # Check if placeholder is startable
443
+ current = self.state.list_state.current_item
444
+ is_startable = False
445
+ if current:
446
+ if isinstance(current.value, str):
447
+ is_startable = current.value in {"no_containers", "no_sessions"}
448
+ elif isinstance(current.value, dict):
449
+ is_startable = current.value.get("_startable", False)
450
+
451
+ if is_startable:
452
+ hints.append(FooterHint("Enter", "start"))
453
+ # Non-startable placeholders get no Enter hint
454
+ else:
455
+ # Real item selected - show details action
456
+ hints.append(FooterHint("Enter", "details"))
457
+
458
+ # Tab navigation and refresh
459
+ hints.append(FooterHint("Tab", "switch tab"))
460
+ hints.append(FooterHint("r", "refresh"))
461
+
462
+ # Global actions
463
+ hints.append(FooterHint("t", "teams", dimmed=standalone))
464
+ hints.append(FooterHint("q", "quit"))
465
+ hints.append(FooterHint("?", "help"))
466
+
467
+ return tuple(hints)
468
+
469
+ def _get_chrome_config(self) -> ChromeConfig:
470
+ """Get chrome configuration for current state."""
471
+ tab_names = [tab.display_name for tab in TAB_ORDER]
472
+ active_index = TAB_ORDER.index(self.state.active_tab)
473
+ standalone = scc_config.is_standalone_mode()
474
+
475
+ # Render rule: auto-hide details on Status tab (no state mutation)
476
+ show_details = self.state.details_open and self.state.active_tab != DashboardTab.STATUS
477
+
478
+ # Compute dynamic footer hints based on current context
479
+ footer_hints = self._compute_footer_hints(standalone, show_details)
480
+
481
+ return ChromeConfig.for_dashboard(
482
+ tab_names,
483
+ active_index,
484
+ standalone=standalone,
485
+ details_open=show_details,
486
+ custom_hints=footer_hints,
487
+ )
488
+
489
+ def _handle_action(self, action: Action[None]) -> bool | None:
490
+ """Handle an action and update state.
491
+
492
+ Returns:
493
+ True to force refresh (state changed by us, not action).
494
+ False to exit dashboard.
495
+ None to continue (refresh only if action.state_changed).
496
+ """
497
+ # Selective status clearing: only clear on navigation/filter/tab actions
498
+ # This preserves toast messages during non-state-changing actions (e.g., help)
499
+ status_clearing_actions = {
500
+ ActionType.NAVIGATE_UP,
501
+ ActionType.NAVIGATE_DOWN,
502
+ ActionType.TAB_NEXT,
503
+ ActionType.TAB_PREV,
504
+ ActionType.FILTER_CHAR,
505
+ ActionType.FILTER_DELETE,
506
+ }
507
+ # Also clear status on 'r' (refresh), which is a CUSTOM action in dashboard
508
+ is_refresh_action = action.action_type == ActionType.CUSTOM and action.custom_key == "r"
509
+ if self.state.status_message and (
510
+ action.action_type in status_clearing_actions or is_refresh_action
511
+ ):
512
+ self.state.status_message = None
513
+
514
+ match action.action_type:
515
+ case ActionType.NAVIGATE_UP:
516
+ self.state.list_state.move_cursor(-1)
517
+
518
+ case ActionType.NAVIGATE_DOWN:
519
+ self.state.list_state.move_cursor(1)
520
+
521
+ case ActionType.TAB_NEXT:
522
+ self.state = self.state.next_tab()
523
+
524
+ case ActionType.TAB_PREV:
525
+ self.state = self.state.prev_tab()
526
+
527
+ case ActionType.FILTER_CHAR:
528
+ if action.filter_char:
529
+ self.state.list_state.add_filter_char(action.filter_char)
530
+
531
+ case ActionType.FILTER_DELETE:
532
+ self.state.list_state.delete_filter_char()
533
+
534
+ case ActionType.CANCEL:
535
+ # ESC precedence: details → filter → no-op
536
+ if self.state.details_open:
537
+ self.state.details_open = False
538
+ return True # Refresh to hide details
539
+ if self.state.list_state.filter_query:
540
+ self.state.list_state.clear_filter()
541
+ return True # Refresh to show unfiltered list
542
+ return None # No-op
543
+
544
+ case ActionType.QUIT:
545
+ return False
546
+
547
+ case ActionType.SELECT:
548
+ # On Status tab, Enter triggers different actions based on item
549
+ if self.state.active_tab == DashboardTab.STATUS:
550
+ current = self.state.list_state.current_item
551
+ if current:
552
+ # Team row: same behavior as 't' key
553
+ if current.value == "team":
554
+ if scc_config.is_standalone_mode():
555
+ self.state.status_message = (
556
+ "Teams require org mode. Run `scc setup` to configure."
557
+ )
558
+ return True # Refresh to show message
559
+ raise TeamSwitchRequested()
560
+
561
+ # Resource rows: drill down to corresponding tab
562
+ tab_mapping: dict[str, DashboardTab] = {
563
+ "containers": DashboardTab.CONTAINERS,
564
+ "sessions": DashboardTab.SESSIONS,
565
+ "worktrees": DashboardTab.WORKTREES,
566
+ }
567
+ target_tab = tab_mapping.get(current.value)
568
+ if target_tab:
569
+ # Clear filter on drill-down (avoids confusion)
570
+ self.state.list_state.clear_filter()
571
+ self.state = self.state.switch_tab(target_tab)
572
+ return True # Refresh to show new tab
573
+ else:
574
+ # Resource tabs handling (Containers, Worktrees, Sessions)
575
+ current = self.state.list_state.current_item
576
+
577
+ # All resource tabs: toggle details pane on first Enter
578
+ if self.state.details_open:
579
+ # Sessions tab: Enter in details pane resumes the session
580
+ if (
581
+ self.state.active_tab == DashboardTab.SESSIONS
582
+ and current
583
+ and not self.state.is_placeholder_selected()
584
+ and isinstance(current.value, dict)
585
+ and not current.value.get("_placeholder")
586
+ ):
587
+ raise SessionResumeRequested(
588
+ session=current.value,
589
+ return_to=self.state.active_tab.name,
590
+ )
591
+ # All tabs (including Sessions without valid session):
592
+ # Close details
593
+ self.state.details_open = False
594
+ return True
595
+ elif not self.state.is_placeholder_selected():
596
+ # Open details (only for real items, not placeholders)
597
+ self.state.details_open = True
598
+ return True
599
+ else:
600
+ # Placeholder or empty state: handle appropriately
601
+ if current:
602
+ # Check if this is a startable placeholder
603
+ # (containers/sessions empty → user can start a new session)
604
+ is_startable = False
605
+ reason = ""
606
+
607
+ # String placeholders (containers, worktrees)
608
+ if isinstance(current.value, str):
609
+ startable_strings = {"no_containers", "no_sessions"}
610
+ if current.value in startable_strings:
611
+ is_startable = True
612
+ reason = current.value
613
+
614
+ # Dict placeholders (sessions tab uses dicts)
615
+ elif isinstance(current.value, dict):
616
+ if current.value.get("_startable"):
617
+ is_startable = True
618
+ reason = current.value.get("_placeholder", "unknown")
619
+
620
+ if is_startable:
621
+ # Uses .name (stable identifier) not .value (display string)
622
+ raise StartRequested(
623
+ return_to=self.state.active_tab.name,
624
+ reason=reason,
625
+ )
626
+ else:
627
+ # Non-startable placeholders show a tip
628
+ self.state.status_message = self._get_placeholder_tip(current.value)
629
+ elif self.state.list_state.filter_query:
630
+ # Filter has no matches
631
+ self.state.status_message = (
632
+ f"No matches for '{self.state.list_state.filter_query}'. "
633
+ "Press Esc to clear filter."
634
+ )
635
+ else:
636
+ # Truly empty list (shouldn't happen normally)
637
+ self.state.status_message = "No items available."
638
+ return True
639
+
640
+ case ActionType.TEAM_SWITCH:
641
+ # In standalone mode, show guidance instead of switching
642
+ if scc_config.is_standalone_mode():
643
+ self.state.status_message = (
644
+ "Teams require org mode. Run `scc setup` to configure."
645
+ )
646
+ return True # Refresh to show message
647
+ # Bubble up to orchestrator for consistent team switching
648
+ raise TeamSwitchRequested()
649
+
650
+ case ActionType.HELP:
651
+ # Show help overlay INSIDE the Live context (avoids scroll artifacts)
652
+ # The overlay is rendered in _render() and dismissed on next keypress
653
+ self.state.help_visible = True
654
+ return True # Refresh to show help overlay
655
+
656
+ case ActionType.CUSTOM:
657
+ # Handle dashboard-specific custom keys (not in DEFAULT_KEY_MAP)
658
+ if action.custom_key == "r":
659
+ # User pressed 'r' - signal orchestrator to reload tab data
660
+ # Uses .name (stable identifier) not .value (display string)
661
+ raise RefreshRequested(return_to=self.state.active_tab.name)
662
+ elif action.custom_key == "n":
663
+ # User pressed 'n' - start new session (skip any resume prompts)
664
+ raise StartRequested(
665
+ return_to=self.state.active_tab.name,
666
+ reason="dashboard_new_session",
667
+ )
668
+
669
+ return None