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
scc_cli/ui/picker.py ADDED
@@ -0,0 +1,763 @@
1
+ """Interactive picker functions for selection workflows.
2
+
3
+ This module provides high-level picker functions that compose ListScreen
4
+ with domain formatters to create complete selection experiences. Each picker
5
+ handles:
6
+ - Loading data from domain sources
7
+ - Converting to display items via formatters
8
+ - Running the interactive selection loop
9
+ - Returning the selected domain object(s)
10
+
11
+ Supports both single-selection and multi-selection modes:
12
+ - Single: pick_team(), pick_container(), pick_session(), pick_worktree(), pick_context()
13
+ - Multi: pick_containers()
14
+
15
+ Example:
16
+ >>> from scc_cli.ui.picker import pick_team, pick_containers
17
+ >>>
18
+ >>> # Single-select: Show team picker
19
+ >>> team = pick_team(available_teams, current_team="platform")
20
+ >>> if team is not None:
21
+ ... print(f"Selected: {team['name']}")
22
+ >>>
23
+ >>> # Multi-select: Show container picker for stopping
24
+ >>> containers = pick_containers(running_containers)
25
+ >>> if containers:
26
+ ... for c in containers:
27
+ ... stop_container(c)
28
+
29
+ The pickers respect the interactivity gate and should only be called
30
+ after verifying is_interactive_allowed() returns True.
31
+
32
+ Global hotkeys:
33
+ - 't': Raises TeamSwitchRequested to allow callers to redirect to team selection
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from collections.abc import Sequence
39
+ from enum import Enum
40
+ from typing import TYPE_CHECKING, Any, TypeVar
41
+
42
+ from rich.live import Live
43
+ from rich.text import Text
44
+
45
+ from ..contexts import normalize_path
46
+ from ..theme import Indicators
47
+ from .chrome import Chrome, ChromeConfig
48
+ from .formatters import (
49
+ format_container,
50
+ format_context,
51
+ format_session,
52
+ format_team,
53
+ format_worktree,
54
+ )
55
+ from .keys import BACK, ActionType, KeyReader, TeamSwitchRequested
56
+ from .list_screen import ListItem, ListMode, ListScreen, ListState
57
+
58
+ # Re-export for backwards compatibility
59
+ __all__ = [
60
+ "QuickResumeResult",
61
+ "TeamSwitchRequested",
62
+ "NEW_SESSION_SENTINEL",
63
+ "SWITCH_TEAM_SENTINEL",
64
+ ]
65
+
66
+ if TYPE_CHECKING:
67
+ from rich.console import RenderableType
68
+
69
+ from ..contexts import WorkContext
70
+
71
+ # Type variable for generic picker return types
72
+ T = TypeVar("T")
73
+
74
+
75
+ class QuickResumeResult(Enum):
76
+ """Result of the Quick Resume picker interaction.
77
+
78
+ This enum distinguishes between five distinct user intents:
79
+ - SELECTED: User pressed Enter to resume the highlighted context
80
+ - NEW_SESSION: User pressed 'n' OR selected the "New Session" virtual entry
81
+ - BACK: User pressed Esc to go back to the previous screen
82
+ - CANCELLED: User pressed 'q' to quit the application entirely
83
+ - TOGGLE_ALL_TEAMS: User pressed 'a' to toggle all-teams view
84
+ """
85
+
86
+ SELECTED = "selected"
87
+ NEW_SESSION = "new_session"
88
+ BACK = "back"
89
+ CANCELLED = "cancelled"
90
+ TOGGLE_ALL_TEAMS = "toggle_all_teams"
91
+
92
+
93
+ # Sentinel values for virtual entries
94
+ NEW_SESSION_SENTINEL = object()
95
+ SWITCH_TEAM_SENTINEL = object()
96
+
97
+
98
+ def pick_team(
99
+ teams: Sequence[dict[str, Any]],
100
+ *,
101
+ current_team: str | None = None,
102
+ title: str = "Select Team",
103
+ subtitle: str | None = None,
104
+ ) -> dict[str, Any] | None:
105
+ """Show interactive team picker.
106
+
107
+ Display a list of teams with the current team marked. User can navigate
108
+ with arrow keys, filter by typing, and select with Enter. Escape or 'q'
109
+ cancels the selection.
110
+
111
+ Args:
112
+ teams: Sequence of team dicts with 'name' and optional 'description'.
113
+ current_team: Name of currently selected team (marked with checkmark).
114
+ title: Title shown in chrome header.
115
+ subtitle: Optional subtitle for additional context.
116
+
117
+ Returns:
118
+ Selected team dict, or None if cancelled.
119
+
120
+ Example:
121
+ >>> teams = [
122
+ ... {"name": "platform", "description": "Platform team"},
123
+ ... {"name": "backend", "description": "Backend team"},
124
+ ... ]
125
+ >>> result = pick_team(teams, current_team="platform")
126
+ >>> if result:
127
+ ... print(f"Switching to: {result['name']}")
128
+ """
129
+ if not teams:
130
+ return None
131
+
132
+ # Convert teams to list items using formatter
133
+ items: list[ListItem[dict[str, Any]]] = [
134
+ format_team(team, current_team=current_team) for team in teams
135
+ ]
136
+
137
+ return _run_single_select_picker(
138
+ items=items,
139
+ title=title,
140
+ subtitle=subtitle or f"{len(teams)} teams available",
141
+ )
142
+
143
+
144
+ def pick_container(
145
+ containers: Sequence[Any],
146
+ *,
147
+ title: str = "Select Container",
148
+ subtitle: str | None = None,
149
+ ) -> Any | None:
150
+ """Show interactive container picker (single-select).
151
+
152
+ Display a list of containers with status and workspace info. User can
153
+ navigate with arrow keys, filter by typing, and select with Enter.
154
+
155
+ Args:
156
+ containers: Sequence of ContainerInfo objects.
157
+ title: Title shown in chrome header.
158
+ subtitle: Optional subtitle for additional context.
159
+
160
+ Returns:
161
+ Selected ContainerInfo, or None if cancelled.
162
+ """
163
+ if not containers:
164
+ return None
165
+
166
+ # Convert containers to list items using formatter
167
+ items = [format_container(container) for container in containers]
168
+
169
+ return _run_single_select_picker(
170
+ items=items,
171
+ title=title,
172
+ subtitle=subtitle or f"{len(containers)} containers",
173
+ )
174
+
175
+
176
+ def pick_containers(
177
+ containers: Sequence[Any],
178
+ *,
179
+ title: str = "Select Containers",
180
+ subtitle: str | None = None,
181
+ require_selection: bool = False,
182
+ ) -> list[Any]:
183
+ """Show interactive container picker (multi-select).
184
+
185
+ Display a list of containers with checkboxes. User can:
186
+ - Navigate with arrow keys (↑↓ or j/k)
187
+ - Toggle selection with Space
188
+ - Toggle all with 'a'
189
+ - Filter by typing
190
+ - Confirm selection with Enter
191
+
192
+ Args:
193
+ containers: Sequence of ContainerInfo objects.
194
+ title: Title shown in chrome header.
195
+ subtitle: Optional subtitle for additional context.
196
+ require_selection: If True, return empty list only if no containers.
197
+ If False, empty selection on Enter returns empty list.
198
+
199
+ Returns:
200
+ List of selected ContainerInfo objects (may be empty if cancelled).
201
+
202
+ Example:
203
+ >>> running = get_running_containers()
204
+ >>> to_stop = pick_containers(running, title="Stop Containers")
205
+ >>> for container in to_stop:
206
+ ... docker.stop(container.id)
207
+ """
208
+ if not containers:
209
+ return []
210
+
211
+ # Convert containers to list items using formatter
212
+ items = [format_container(container) for container in containers]
213
+
214
+ # Use ListScreen in MULTI_SELECT mode
215
+ screen = ListScreen(
216
+ items,
217
+ title=title,
218
+ mode=ListMode.MULTI_SELECT,
219
+ )
220
+
221
+ result = screen.run()
222
+
223
+ # Handle cancellation (None) vs empty selection ([])
224
+ if result is None:
225
+ return []
226
+
227
+ # In MULTI_SELECT mode, result is list[ContainerInfo]
228
+ # Type narrowing: if not None, it's a list in multi-select mode
229
+ if isinstance(result, list):
230
+ return result
231
+ return []
232
+
233
+
234
+ def pick_session(
235
+ sessions: Sequence[dict[str, Any]],
236
+ *,
237
+ title: str = "Select Session",
238
+ subtitle: str | None = None,
239
+ ) -> dict[str, Any] | None:
240
+ """Show interactive session picker.
241
+
242
+ Display a list of sessions with team, branch, and last used info.
243
+ User can navigate with arrow keys, filter by typing, and select with Enter.
244
+
245
+ Args:
246
+ sessions: Sequence of session dicts.
247
+ title: Title shown in chrome header.
248
+ subtitle: Optional subtitle for additional context.
249
+
250
+ Returns:
251
+ Selected session dict, or None if cancelled.
252
+ """
253
+ if not sessions:
254
+ return None
255
+
256
+ # Convert sessions to list items using formatter
257
+ items = [format_session(session) for session in sessions]
258
+
259
+ return _run_single_select_picker(
260
+ items=items,
261
+ title=title,
262
+ subtitle=subtitle or f"{len(sessions)} sessions",
263
+ )
264
+
265
+
266
+ def pick_worktree(
267
+ worktrees: Sequence[Any],
268
+ *,
269
+ title: str = "Select Worktree",
270
+ subtitle: str | None = None,
271
+ initial_filter: str = "",
272
+ ) -> Any | None:
273
+ """Show interactive worktree picker.
274
+
275
+ Display a list of git worktrees with branch and status info.
276
+ User can navigate with arrow keys, filter by typing, and select with Enter.
277
+
278
+ Args:
279
+ worktrees: Sequence of WorktreeInfo objects.
280
+ title: Title shown in chrome header.
281
+ subtitle: Optional subtitle for additional context.
282
+ initial_filter: Pre-populate the filter query (for prefilled pickers).
283
+
284
+ Returns:
285
+ Selected WorktreeInfo, or None if cancelled.
286
+ """
287
+ if not worktrees:
288
+ return None
289
+
290
+ # Convert worktrees to list items using formatter
291
+ items = [format_worktree(worktree) for worktree in worktrees]
292
+
293
+ return _run_single_select_picker(
294
+ items=items,
295
+ title=title,
296
+ subtitle=subtitle or f"{len(worktrees)} worktrees",
297
+ initial_filter=initial_filter,
298
+ )
299
+
300
+
301
+ def pick_context(
302
+ contexts: Sequence[WorkContext],
303
+ *,
304
+ title: str = "Recent Contexts",
305
+ subtitle: str | None = None,
306
+ ) -> WorkContext | None:
307
+ """Show interactive context picker for quick resume.
308
+
309
+ Display a list of recent work contexts (team + repo + worktree) with
310
+ pinned items first, then sorted by recency. User can filter by typing,
311
+ navigate with arrow keys, and select with Enter.
312
+
313
+ This is the primary entry point for the "context-first" UX pattern,
314
+ allowing developers to quickly resume where they left off.
315
+
316
+ Args:
317
+ contexts: Sequence of WorkContext objects (typically from load_recent_contexts).
318
+ title: Title shown in chrome header.
319
+ subtitle: Optional subtitle for additional context.
320
+
321
+ Returns:
322
+ Selected WorkContext, or None if cancelled.
323
+
324
+ Example:
325
+ >>> from scc_cli.contexts import load_recent_contexts
326
+ >>> from scc_cli.ui.picker import pick_context
327
+ >>>
328
+ >>> contexts = load_recent_contexts(limit=10)
329
+ >>> selected = pick_context(contexts)
330
+ >>> if selected:
331
+ ... # Resume work in the selected context
332
+ ... start_session(selected.team, selected.worktree_path)
333
+ """
334
+ if not contexts:
335
+ return None
336
+
337
+ # Convert contexts to list items using formatter
338
+ items = [format_context(context) for context in contexts]
339
+
340
+ return _run_single_select_picker(
341
+ items=items,
342
+ title=title,
343
+ subtitle=subtitle or f"{len(contexts)} recent contexts",
344
+ )
345
+
346
+
347
+ def pick_context_quick_resume(
348
+ contexts: Sequence[WorkContext],
349
+ *,
350
+ title: str = "Quick Resume",
351
+ subtitle: str | None = None,
352
+ standalone: bool = False,
353
+ current_branch: str | None = None,
354
+ context_label: str | None = None,
355
+ effective_team: str | None = None,
356
+ ) -> tuple[QuickResumeResult, WorkContext | None]:
357
+ """Show Quick Resume picker with 5-way result semantics.
358
+
359
+ The picker always shows "New Session" as the first (default) option.
360
+ This picker distinguishes between five user intents:
361
+ - Enter on "New Session": Start fresh (NEW_SESSION)
362
+ - Enter on context: Resume the selected context (SELECTED)
363
+ - n: Explicitly start a new session (NEW_SESSION)
364
+ - a: Toggle all-teams view (TOGGLE_ALL_TEAMS)
365
+ - Esc: Go back to previous screen (BACK)
366
+ - q: Cancel the entire wizard (CANCELLED)
367
+
368
+ Args:
369
+ contexts: Sequence of WorkContext objects.
370
+ title: Title shown in chrome header.
371
+ subtitle: Optional subtitle (defaults to showing Esc hint).
372
+ standalone: If True, dim the "t teams" hint (not available without org).
373
+ current_branch: Current git branch from CWD, used to highlight
374
+ contexts matching this branch with a ★ indicator.
375
+ context_label: Optional context label (e.g., "Team: platform") shown in header.
376
+ effective_team: The effective team for display in "New Session" label.
377
+
378
+ Returns:
379
+ Tuple of (QuickResumeResult, selected WorkContext or None).
380
+
381
+ Example:
382
+ >>> from scc_cli.contexts import load_recent_contexts
383
+ >>> from scc_cli.ui.picker import pick_context_quick_resume, QuickResumeResult
384
+ >>>
385
+ >>> contexts = load_recent_contexts(limit=10)
386
+ >>> result, context = pick_context_quick_resume(contexts, standalone=True)
387
+ >>> match result:
388
+ ... case QuickResumeResult.SELECTED:
389
+ ... resume_session(context)
390
+ ... case QuickResumeResult.NEW_SESSION:
391
+ ... start_new_session()
392
+ ... case QuickResumeResult.TOGGLE_ALL_TEAMS:
393
+ ... # Reload with all teams filter
394
+ ... case QuickResumeResult.BACK:
395
+ ... continue # Go back to previous screen
396
+ ... case QuickResumeResult.CANCELLED:
397
+ ... return # Exit wizard
398
+ """
399
+ # Query running containers for status indicators
400
+ running_workspaces = _get_running_workspaces()
401
+
402
+ # Build "New Session" virtual entry as first item (always default)
403
+ team_label = effective_team or "standalone" if not standalone else "standalone"
404
+ new_session_desc = "Start fresh"
405
+ if not contexts:
406
+ new_session_desc = "No sessions yet — press Enter to start"
407
+ new_session_item: ListItem[WorkContext | object] = ListItem(
408
+ label=f"➕ New session ({team_label})",
409
+ description=new_session_desc,
410
+ value=NEW_SESSION_SENTINEL,
411
+ )
412
+
413
+ switch_team_item: ListItem[WorkContext | object] | None = None
414
+ if not standalone:
415
+ switch_team_item = ListItem(
416
+ label="👥 Switch team",
417
+ description="Choose a different team",
418
+ value=SWITCH_TEAM_SENTINEL,
419
+ )
420
+
421
+ # Convert contexts to list items with status and branch indicators
422
+ context_items = [
423
+ format_context(
424
+ context,
425
+ is_running=str(context.worktree_path) in running_workspaces,
426
+ is_current_branch=(
427
+ current_branch is not None and context.worktree_name == current_branch
428
+ ),
429
+ )
430
+ for context in contexts
431
+ ]
432
+
433
+ # New Session is always first (and default selection)
434
+ # Build combined list manually to handle type variance
435
+ items: list[ListItem[Any]] = [new_session_item]
436
+ if switch_team_item is not None:
437
+ items.append(switch_team_item)
438
+ items.extend(context_items)
439
+
440
+ return _run_quick_resume_picker(
441
+ items=items,
442
+ title=title,
443
+ subtitle=subtitle,
444
+ standalone=standalone,
445
+ context_label=context_label,
446
+ )
447
+
448
+
449
+ def _get_running_workspaces() -> set[str]:
450
+ """Get set of workspace paths with running containers.
451
+
452
+ Paths are normalized (resolved symlinks, expanded ~) via normalize_path()
453
+ to ensure consistent comparison with context.worktree_path.
454
+
455
+ Returns an empty set if Docker is not available or on error.
456
+ This allows the picker to work without Docker status indicators.
457
+ """
458
+ try:
459
+ from ..docker import list_scc_containers
460
+
461
+ containers = list_scc_containers()
462
+ # Normalize paths using the same function as WorkContext for consistency
463
+ return {
464
+ str(normalize_path(c.workspace))
465
+ for c in containers
466
+ if c.workspace and c.status.startswith("Up")
467
+ }
468
+ except Exception:
469
+ # Docker not available or error - return empty set
470
+ return set()
471
+
472
+
473
+ def _run_single_select_picker(
474
+ items: list[ListItem[T]],
475
+ *,
476
+ title: str,
477
+ subtitle: str | None = None,
478
+ standalone: bool = False,
479
+ allow_back: bool = False,
480
+ context_label: str | None = None,
481
+ initial_filter: str = "",
482
+ ) -> T | None:
483
+ """Run the interactive single-selection picker loop.
484
+
485
+ This is the core picker implementation that handles:
486
+ - Rendering the list with chrome
487
+ - Processing keyboard input
488
+ - Managing navigation and filtering state
489
+ - Returning selection on Enter, BACK on Esc (if allow_back), or None on quit
490
+
491
+ Args:
492
+ items: List items to display.
493
+ title: Title for chrome header.
494
+ subtitle: Optional subtitle.
495
+ standalone: If True, dim the "t teams" hint (not available without org).
496
+ allow_back: If True, Esc returns BACK sentinel (for sub-screens).
497
+ If False, Esc returns None (for top-level screens).
498
+ context_label: Optional context label (e.g., "Team: platform") shown in header.
499
+ initial_filter: Pre-populate the filter query (for prefilled pickers).
500
+
501
+ Returns:
502
+ Value from selected item, BACK if allow_back and Esc pressed, or None if quit.
503
+ """
504
+ if not items:
505
+ return None
506
+
507
+ from ..console import get_err_console
508
+
509
+ console = get_err_console()
510
+ state = ListState(items=items, filter_query=initial_filter)
511
+ reader = KeyReader(enable_filter=True)
512
+
513
+ def render() -> RenderableType:
514
+ """Render current picker state."""
515
+ # Build list body
516
+ body = Text()
517
+ visible = state.visible_items
518
+
519
+ if not state.filtered_items:
520
+ body.append("No matches", style="dim italic")
521
+ return _wrap_in_chrome(body, title, subtitle, state.filter_query)
522
+
523
+ for i, item in enumerate(visible):
524
+ actual_index = state.scroll_offset + i
525
+ is_cursor = actual_index == state.cursor
526
+
527
+ # Cursor indicator
528
+ if is_cursor:
529
+ body.append(f"{Indicators.get('CURSOR')} ", style="cyan bold")
530
+ else:
531
+ body.append(" ")
532
+
533
+ # Label with governance styling
534
+ label_style = "bold" if is_cursor else ""
535
+ if item.governance_status == "blocked":
536
+ label_style += " red"
537
+ elif item.governance_status == "warning":
538
+ label_style += " yellow"
539
+
540
+ body.append(item.label, style=label_style.strip())
541
+
542
+ # Description
543
+ if item.description:
544
+ body.append(f" {item.description}", style="dim")
545
+
546
+ body.append("\n")
547
+
548
+ return _wrap_in_chrome(body, title, subtitle, state.filter_query)
549
+
550
+ def _wrap_in_chrome(
551
+ body: Text, title: str, subtitle: str | None, filter_query: str
552
+ ) -> RenderableType:
553
+ """Wrap body content in chrome."""
554
+ # Capture `standalone` from outer scope for proper footer hint dimming
555
+ config = ChromeConfig.for_picker(title, subtitle, standalone=standalone)
556
+ if context_label:
557
+ config = config.with_context(context_label)
558
+ chrome = Chrome(config)
559
+ return chrome.render(body, search_query=filter_query)
560
+
561
+ # Run the picker loop
562
+ with Live(
563
+ render(),
564
+ console=console,
565
+ auto_refresh=False, # Manual refresh for instant response
566
+ transient=True,
567
+ ) as live:
568
+ while True:
569
+ action = reader.read(filter_active=bool(state.filter_query))
570
+
571
+ match action.action_type:
572
+ case ActionType.NAVIGATE_UP:
573
+ state.move_cursor(-1)
574
+
575
+ case ActionType.NAVIGATE_DOWN:
576
+ state.move_cursor(1)
577
+
578
+ case ActionType.SELECT:
579
+ # Return selected value
580
+ current = state.current_item
581
+ if current is not None:
582
+ return current.value
583
+ return None
584
+
585
+ case ActionType.CANCEL:
586
+ # Esc: back to previous screen (if allowed) or cancel
587
+ if allow_back:
588
+ return BACK # type: ignore[return-value]
589
+ return None
590
+
591
+ case ActionType.QUIT:
592
+ # q: always quit entirely
593
+ return None
594
+
595
+ case ActionType.TEAM_SWITCH:
596
+ # Signal caller to redirect to team selection
597
+ raise TeamSwitchRequested()
598
+
599
+ case ActionType.FILTER_CHAR:
600
+ if action.filter_char:
601
+ state.add_filter_char(action.filter_char)
602
+
603
+ case ActionType.FILTER_DELETE:
604
+ state.delete_filter_char()
605
+
606
+ case ActionType.NOOP:
607
+ pass # Unrecognized key - no action needed
608
+
609
+ if action.state_changed:
610
+ live.update(render(), refresh=True)
611
+
612
+
613
+ def _run_quick_resume_picker(
614
+ items: list[ListItem[T]],
615
+ *,
616
+ title: str,
617
+ subtitle: str | None = None,
618
+ standalone: bool = False,
619
+ context_label: str | None = None,
620
+ ) -> tuple[QuickResumeResult, WorkContext | None]:
621
+ """Run the Quick Resume picker with 5-way result semantics.
622
+
623
+ Unlike the standard single-select picker, this distinguishes between:
624
+ - Enter on "New Session": Start fresh (NEW_SESSION)
625
+ - Enter on context: Resume the selected context (SELECTED)
626
+ - n: Explicitly start a new session (NEW_SESSION)
627
+ - a: Toggle all-teams view (TOGGLE_ALL_TEAMS)
628
+ - Esc: Go back to previous screen (BACK)
629
+ - q: Cancel the entire wizard (CANCELLED)
630
+
631
+ Args:
632
+ items: List items to display (first item should be "New Session" sentinel).
633
+ title: Title for chrome header.
634
+ subtitle: Optional subtitle.
635
+ standalone: If True, dim the "t teams" hint (not available without org).
636
+ context_label: Optional context label (e.g., "Team: platform") shown in header.
637
+
638
+ Returns:
639
+ Tuple of (QuickResumeResult, selected WorkContext or None).
640
+ Returns None for context when NEW_SESSION is selected.
641
+ """
642
+ if not items:
643
+ return (QuickResumeResult.NEW_SESSION, None)
644
+
645
+ from ..console import get_err_console
646
+
647
+ console = get_err_console()
648
+ state = ListState(items=items)
649
+ # Custom keys: 'n' for new session, 'a' for toggle all teams
650
+ reader = KeyReader(
651
+ custom_keys={"n": "new_session", "a": "toggle_all_teams"},
652
+ enable_filter=True,
653
+ )
654
+
655
+ def render() -> RenderableType:
656
+ """Render current picker state."""
657
+ body = Text()
658
+ visible = state.visible_items
659
+
660
+ if not state.filtered_items:
661
+ body.append("No matches", style="dim italic")
662
+ return _wrap_quick_resume_chrome(body, title, subtitle, state.filter_query)
663
+
664
+ for i, item in enumerate(visible):
665
+ actual_index = state.scroll_offset + i
666
+ is_cursor = actual_index == state.cursor
667
+
668
+ # Cursor indicator
669
+ if is_cursor:
670
+ body.append(f"{Indicators.get('CURSOR')} ", style="cyan bold")
671
+ else:
672
+ body.append(" ")
673
+
674
+ # Label with governance styling
675
+ label_style = "bold" if is_cursor else ""
676
+ if item.governance_status == "blocked":
677
+ label_style += " red"
678
+ elif item.governance_status == "warning":
679
+ label_style += " yellow"
680
+
681
+ body.append(item.label, style=label_style.strip())
682
+
683
+ # Description
684
+ if item.description:
685
+ body.append(f" {item.description}", style="dim")
686
+
687
+ body.append("\n")
688
+
689
+ return _wrap_quick_resume_chrome(body, title, subtitle, state.filter_query)
690
+
691
+ def _wrap_quick_resume_chrome(
692
+ body: Text, title: str, subtitle: str | None, filter_query: str
693
+ ) -> RenderableType:
694
+ """Wrap body content in Quick Resume chrome with truthful hints."""
695
+ config = ChromeConfig.for_quick_resume(title, subtitle, standalone=standalone)
696
+ if context_label:
697
+ config = config.with_context(context_label)
698
+ chrome = Chrome(config)
699
+ return chrome.render(body, search_query=filter_query)
700
+
701
+ # Run the picker loop
702
+ with Live(
703
+ render(),
704
+ console=console,
705
+ auto_refresh=False,
706
+ transient=True,
707
+ ) as live:
708
+ while True:
709
+ action = reader.read(filter_active=bool(state.filter_query))
710
+
711
+ match action.action_type:
712
+ case ActionType.NAVIGATE_UP:
713
+ state.move_cursor(-1)
714
+
715
+ case ActionType.NAVIGATE_DOWN:
716
+ state.move_cursor(1)
717
+
718
+ case ActionType.SELECT:
719
+ # Enter = select current item
720
+ current = state.current_item
721
+ if current is not None:
722
+ # Check for virtual entries first
723
+ if current.value is NEW_SESSION_SENTINEL:
724
+ return (QuickResumeResult.NEW_SESSION, None)
725
+ if current.value is SWITCH_TEAM_SENTINEL:
726
+ raise TeamSwitchRequested()
727
+ # Otherwise it's a WorkContext
728
+ # Type ignore: we know context_items contain WorkContext values
729
+ return (QuickResumeResult.SELECTED, current.value) # type: ignore[return-value]
730
+ return (QuickResumeResult.NEW_SESSION, None)
731
+
732
+ case ActionType.CUSTOM:
733
+ # Handle screen-specific custom keys
734
+ if action.custom_key == "new_session":
735
+ # n = explicitly start new session (skip resume)
736
+ return (QuickResumeResult.NEW_SESSION, None)
737
+ elif action.custom_key == "toggle_all_teams":
738
+ # a = toggle all teams view (caller handles reload)
739
+ return (QuickResumeResult.TOGGLE_ALL_TEAMS, None)
740
+
741
+ case ActionType.CANCEL:
742
+ # Esc = go back to previous screen
743
+ return (QuickResumeResult.BACK, None)
744
+
745
+ case ActionType.QUIT:
746
+ # q = quit app entirely
747
+ return (QuickResumeResult.CANCELLED, None)
748
+
749
+ case ActionType.TEAM_SWITCH:
750
+ raise TeamSwitchRequested()
751
+
752
+ case ActionType.FILTER_CHAR:
753
+ if action.filter_char:
754
+ state.add_filter_char(action.filter_char)
755
+
756
+ case ActionType.FILTER_DELETE:
757
+ state.delete_filter_char()
758
+
759
+ case ActionType.NOOP:
760
+ pass # Unrecognized key - no action needed
761
+
762
+ if action.state_changed:
763
+ live.update(render(), refresh=True)