scc-cli 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

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