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.
- 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 +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -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 +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -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/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -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 +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -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/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -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 +383 -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 +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -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 +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- 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)
|