scc-cli 1.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +706 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1454 -0
- scc_cli/cli_org.py +1428 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +892 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +604 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1074 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1521 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +257 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +260 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +588 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +382 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +677 -0
- scc_cli/ui/dashboard/loaders.py +395 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +390 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +538 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +675 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.1.dist-info/METADATA +369 -0
- scc_cli-1.4.1.dist-info/RECORD +113 -0
- scc_cli-1.4.1.dist-info/WHEEL +4 -0
- scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.1.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)
|