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