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