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/gate.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Interactivity gate - policy enforcement for interactive UI.
|
|
2
|
+
|
|
3
|
+
This module implements the interactivity decision system that determines
|
|
4
|
+
whether interactive UI (pickers, lists, prompts) can be shown. It enforces
|
|
5
|
+
a strict priority order to ensure predictable behavior in all contexts.
|
|
6
|
+
|
|
7
|
+
Priority Order (highest to lowest):
|
|
8
|
+
1. JSON mode (--json) → Always False
|
|
9
|
+
2. Explicit --no-interactive flag → Always False
|
|
10
|
+
3. CI environment detection → False
|
|
11
|
+
4. Non-TTY stdin → False
|
|
12
|
+
5. Explicit --interactive flag → True (if TTY available)
|
|
13
|
+
6. Default → True (if TTY available)
|
|
14
|
+
|
|
15
|
+
Fast Fail Validation:
|
|
16
|
+
Conflicting flags (--json with --interactive/--select) raise UsageError
|
|
17
|
+
immediately rather than silently ignoring the interactive flag.
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
>>> ctx = InteractivityContext.create(json_mode=False)
|
|
21
|
+
>>> if ctx.allows_prompt():
|
|
22
|
+
... result = pick_team(teams)
|
|
23
|
+
... else:
|
|
24
|
+
... raise UsageError("--team-name required")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from enum import Enum, auto
|
|
34
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
35
|
+
|
|
36
|
+
from rich.console import Console
|
|
37
|
+
|
|
38
|
+
from ..exit_codes import EXIT_SUCCESS, EXIT_USAGE
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
T = TypeVar("T")
|
|
44
|
+
|
|
45
|
+
# Console for error output
|
|
46
|
+
_stderr_console = Console(stderr=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InteractivityMode(Enum):
|
|
50
|
+
"""Resolved interactivity mode after gate evaluation."""
|
|
51
|
+
|
|
52
|
+
INTERACTIVE = auto() # Full interactive UI allowed
|
|
53
|
+
NON_INTERACTIVE = auto() # Text output only, fail on missing selection
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_ci_environment() -> bool:
|
|
57
|
+
"""Detect if running in a CI environment.
|
|
58
|
+
|
|
59
|
+
Checks common CI environment variables set by various CI systems:
|
|
60
|
+
- CI (GitHub Actions, GitLab CI, CircleCI, Travis CI, etc.)
|
|
61
|
+
- CONTINUOUS_INTEGRATION (Travis CI)
|
|
62
|
+
- BUILD_NUMBER (Jenkins)
|
|
63
|
+
- GITHUB_ACTIONS (GitHub Actions specific)
|
|
64
|
+
- GITLAB_CI (GitLab CI specific)
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True if CI environment detected, False otherwise.
|
|
68
|
+
"""
|
|
69
|
+
ci_vars = ["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "GITHUB_ACTIONS", "GITLAB_CI"]
|
|
70
|
+
|
|
71
|
+
for var in ci_vars:
|
|
72
|
+
value = os.getenv(var, "").lower()
|
|
73
|
+
if value in ("1", "true", "yes"):
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_tty_available() -> bool:
|
|
80
|
+
"""Check if stdin is a TTY (terminal).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if stdin is a terminal, False if piped/redirected.
|
|
84
|
+
"""
|
|
85
|
+
return sys.stdin.isatty()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_mode_flags(
|
|
89
|
+
*,
|
|
90
|
+
json_mode: bool = False,
|
|
91
|
+
interactive: bool = False,
|
|
92
|
+
select: bool = False,
|
|
93
|
+
dashboard: bool = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Validate that mode flags don't conflict - fail fast if they do.
|
|
96
|
+
|
|
97
|
+
This validation should be called as early as possible after option parsing,
|
|
98
|
+
before any UI/gating code runs. It ensures users get immediate feedback
|
|
99
|
+
about conflicting flags rather than silent behavior.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
json_mode: Whether --json flag is set.
|
|
103
|
+
interactive: Whether --interactive/-i flag is set.
|
|
104
|
+
select: Whether --select flag is set.
|
|
105
|
+
dashboard: Whether --dashboard flag is set.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
UsageError: If JSON mode is combined with any interactive flag.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> validate_mode_flags(json_mode=True, interactive=True)
|
|
112
|
+
Traceback (most recent call last):
|
|
113
|
+
...
|
|
114
|
+
UsageError: Cannot use --json with --interactive
|
|
115
|
+
"""
|
|
116
|
+
from ..errors import UsageError
|
|
117
|
+
|
|
118
|
+
if not json_mode:
|
|
119
|
+
return # No conflict possible without JSON mode
|
|
120
|
+
|
|
121
|
+
# Collect all conflicting flags
|
|
122
|
+
conflicts: list[str] = []
|
|
123
|
+
if interactive:
|
|
124
|
+
conflicts.append("--interactive")
|
|
125
|
+
if select:
|
|
126
|
+
conflicts.append("--select")
|
|
127
|
+
if dashboard:
|
|
128
|
+
conflicts.append("--dashboard")
|
|
129
|
+
|
|
130
|
+
if conflicts:
|
|
131
|
+
flags_str = ", ".join(conflicts)
|
|
132
|
+
raise UsageError(
|
|
133
|
+
user_message=f"Cannot use --json with {flags_str}",
|
|
134
|
+
suggested_action=(
|
|
135
|
+
"Remove one of the conflicting flags. "
|
|
136
|
+
"Use --json for machine-readable output OR interactive flags for user prompts, not both."
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def is_interactive_allowed(
|
|
142
|
+
*,
|
|
143
|
+
json_mode: bool = False,
|
|
144
|
+
no_interactive_flag: bool = False,
|
|
145
|
+
interactive_flag: bool = False,
|
|
146
|
+
) -> bool:
|
|
147
|
+
"""Check if interactive UI is allowed based on priority order.
|
|
148
|
+
|
|
149
|
+
Priority (from highest to lowest):
|
|
150
|
+
1. JSON mode (--json) → False
|
|
151
|
+
2. Explicit --no-interactive flag → False
|
|
152
|
+
3. CI environment detection → False
|
|
153
|
+
4. Non-TTY stdin → False
|
|
154
|
+
5. Explicit --interactive flag → True (if TTY available)
|
|
155
|
+
6. Default → True (if TTY available)
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
json_mode: Whether --json flag is set.
|
|
159
|
+
no_interactive_flag: Whether --no-interactive flag is set.
|
|
160
|
+
interactive_flag: Whether --interactive flag is set.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
True if interactive prompts are permitted, False otherwise.
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> is_interactive_allowed(json_mode=True)
|
|
167
|
+
False
|
|
168
|
+
>>> is_interactive_allowed() # in TTY, no CI
|
|
169
|
+
True
|
|
170
|
+
"""
|
|
171
|
+
# Priority 1: JSON mode always blocks
|
|
172
|
+
if json_mode:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
# Priority 2: Explicit --no-interactive blocks
|
|
176
|
+
if no_interactive_flag:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
# Priority 3: CI environment blocks
|
|
180
|
+
if _is_ci_environment():
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
# Priority 4: Non-TTY blocks
|
|
184
|
+
if not _is_tty_available():
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
# Priority 5 & 6: TTY available, allow interactive
|
|
188
|
+
# (--interactive flag doesn't change anything here since TTY is required anyway)
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class InteractivityContext:
|
|
194
|
+
"""Immutable context for interactivity decisions throughout a command.
|
|
195
|
+
|
|
196
|
+
Create once at command entry point, pass to all UI functions.
|
|
197
|
+
This ensures consistent behavior across all UI components.
|
|
198
|
+
|
|
199
|
+
Attributes:
|
|
200
|
+
mode: Resolved interactivity mode (INTERACTIVE or NON_INTERACTIVE).
|
|
201
|
+
is_json_output: Whether --json flag is set (for output formatting).
|
|
202
|
+
force_yes: Whether --yes flag is set (skip confirmations).
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
mode: InteractivityMode
|
|
206
|
+
is_json_output: bool
|
|
207
|
+
force_yes: bool
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def create(
|
|
211
|
+
cls,
|
|
212
|
+
*,
|
|
213
|
+
json_mode: bool = False,
|
|
214
|
+
no_interactive: bool = False,
|
|
215
|
+
force_interactive: bool = False,
|
|
216
|
+
force_yes: bool = False,
|
|
217
|
+
) -> InteractivityContext:
|
|
218
|
+
"""Create context from command-line flags.
|
|
219
|
+
|
|
220
|
+
This is the primary factory method for creating InteractivityContext.
|
|
221
|
+
Call once at the entry point of a command and pass down to all UI functions.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
json_mode: Whether --json flag is set.
|
|
225
|
+
no_interactive: Whether --no-interactive flag is set.
|
|
226
|
+
force_interactive: Whether --interactive/-i flag is set.
|
|
227
|
+
force_yes: Whether --yes/-y flag is set.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Configured InteractivityContext with resolved mode.
|
|
231
|
+
"""
|
|
232
|
+
allowed = is_interactive_allowed(
|
|
233
|
+
json_mode=json_mode,
|
|
234
|
+
no_interactive_flag=no_interactive,
|
|
235
|
+
interactive_flag=force_interactive,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
mode = InteractivityMode.INTERACTIVE if allowed else InteractivityMode.NON_INTERACTIVE
|
|
239
|
+
|
|
240
|
+
return cls(
|
|
241
|
+
mode=mode,
|
|
242
|
+
is_json_output=json_mode,
|
|
243
|
+
force_yes=force_yes,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def allows_prompt(self) -> bool:
|
|
247
|
+
"""Whether interactive prompts are permitted.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
True if interactive UI can be shown, False if must use explicit args.
|
|
251
|
+
"""
|
|
252
|
+
return self.mode == InteractivityMode.INTERACTIVE and not self.is_json_output
|
|
253
|
+
|
|
254
|
+
def requires_confirmation(self) -> bool:
|
|
255
|
+
"""Whether destructive actions need confirmation.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
True if confirmation dialog should be shown, False if --yes bypasses it.
|
|
259
|
+
"""
|
|
260
|
+
return not self.force_yes and self.allows_prompt()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def require_selection_or_prompt(
|
|
264
|
+
selection: T | None,
|
|
265
|
+
picker_fn: Callable[[], T | None],
|
|
266
|
+
arg_name: str,
|
|
267
|
+
*,
|
|
268
|
+
ctx: InteractivityContext,
|
|
269
|
+
) -> T:
|
|
270
|
+
"""Get selection from explicit arg, picker, or fail with usage error.
|
|
271
|
+
|
|
272
|
+
This is the primary entry point for commands that support both
|
|
273
|
+
explicit arguments and interactive selection. It implements the
|
|
274
|
+
"graceful degradation" pattern:
|
|
275
|
+
1. If explicit value provided, use it (works in all modes)
|
|
276
|
+
2. If no value but interactive allowed, show picker
|
|
277
|
+
3. If no value and not interactive, fail with helpful error
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
selection: Explicit value from command-line argument (None if not provided).
|
|
281
|
+
picker_fn: Function to call for interactive selection.
|
|
282
|
+
arg_name: Name of the argument for error messages (e.g., "team-name").
|
|
283
|
+
ctx: Interactivity context with mode and flags.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
The selected value (from arg or picker).
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
SystemExit: With EXIT_USAGE if selection required but interactive forbidden.
|
|
290
|
+
SystemExit: With EXIT_SUCCESS (0) if user cancels picker.
|
|
291
|
+
|
|
292
|
+
Example:
|
|
293
|
+
>>> ctx = InteractivityContext.create(json_mode=False)
|
|
294
|
+
>>> team = require_selection_or_prompt(
|
|
295
|
+
... selection=args.team_name,
|
|
296
|
+
... picker_fn=lambda: pick_team(teams),
|
|
297
|
+
... arg_name="team-name",
|
|
298
|
+
... ctx=ctx,
|
|
299
|
+
... )
|
|
300
|
+
"""
|
|
301
|
+
# Case 1: Explicit selection provided - use it directly
|
|
302
|
+
if selection is not None:
|
|
303
|
+
return selection
|
|
304
|
+
|
|
305
|
+
# Case 2: No selection, but interactive allowed - show picker
|
|
306
|
+
if ctx.allows_prompt():
|
|
307
|
+
result = picker_fn()
|
|
308
|
+
if result is None:
|
|
309
|
+
# User cancelled - exit cleanly
|
|
310
|
+
raise SystemExit(EXIT_SUCCESS)
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
# Case 3: No selection and not interactive - fail with helpful error
|
|
314
|
+
_print_missing_selection_error(arg_name, ctx)
|
|
315
|
+
raise SystemExit(EXIT_USAGE)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _print_missing_selection_error(arg_name: str, ctx: InteractivityContext) -> None:
|
|
319
|
+
"""Print helpful error message for missing selection.
|
|
320
|
+
|
|
321
|
+
This function implements the standard "what/why/next" error pattern:
|
|
322
|
+
- What: Clear problem statement (red Error: prefix)
|
|
323
|
+
- Why: Context-aware explanation (dim text explaining the cause)
|
|
324
|
+
- Next: Actionable steps (bold header with bullet points)
|
|
325
|
+
|
|
326
|
+
All user-facing validation errors in the UI module should follow
|
|
327
|
+
this pattern for consistency.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
arg_name: Name of the missing argument.
|
|
331
|
+
ctx: Context for determining why interactive is blocked.
|
|
332
|
+
"""
|
|
333
|
+
# Determine why interactive is blocked
|
|
334
|
+
if ctx.is_json_output:
|
|
335
|
+
why = "In JSON output mode, explicit arguments are required."
|
|
336
|
+
elif _is_ci_environment():
|
|
337
|
+
why = "In CI environment, interactive prompts are disabled."
|
|
338
|
+
elif not _is_tty_available():
|
|
339
|
+
why = "Input is not from a terminal, interactive prompts unavailable."
|
|
340
|
+
else:
|
|
341
|
+
why = "Interactive mode is disabled."
|
|
342
|
+
|
|
343
|
+
_stderr_console.print(f"[red]Error:[/red] Missing required argument: --{arg_name}")
|
|
344
|
+
_stderr_console.print()
|
|
345
|
+
_stderr_console.print(f"[dim]{why}[/dim]")
|
|
346
|
+
_stderr_console.print()
|
|
347
|
+
_stderr_console.print("[bold]Next steps:[/bold]")
|
|
348
|
+
_stderr_console.print(f" • Run with --{arg_name} <value> to specify explicitly")
|
|
349
|
+
_stderr_console.print(" • Run in a TTY without --json for interactive selection")
|
|
350
|
+
_stderr_console.print(f" • Run 'scc {arg_name.split('-')[0]} list' to see available options")
|
scc_cli/ui/help.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Help overlay for interactive UI screens.
|
|
2
|
+
|
|
3
|
+
Provides mode-aware help that shows only keys relevant to the current screen.
|
|
4
|
+
The overlay is triggered by pressing '?' and dismissed by any key.
|
|
5
|
+
|
|
6
|
+
Key categories shown per mode:
|
|
7
|
+
- ALL: Navigation (↑↓/j/k), typing to filter, backspace, t for teams
|
|
8
|
+
- PICKER: Enter to select, Esc to cancel
|
|
9
|
+
- MULTI_SELECT: Space to toggle, a to toggle all, Enter to confirm, Esc to cancel
|
|
10
|
+
- DASHBOARD: Tab/Shift+Tab for tabs, Enter for details, q to quit
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
>>> from scc_cli.ui.help import show_help_overlay
|
|
14
|
+
>>> from scc_cli.ui.list_screen import ListMode
|
|
15
|
+
>>> show_help_overlay(ListMode.SINGLE_SELECT)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from enum import Enum, auto
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from ..theme import Indicators
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from rich.console import Console, RenderableType
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HelpMode(Enum):
|
|
34
|
+
"""Screen mode for help overlay customization."""
|
|
35
|
+
|
|
36
|
+
PICKER = auto() # Single-select picker (team, worktree, etc.)
|
|
37
|
+
MULTI_SELECT = auto() # Multi-select list (containers, etc.)
|
|
38
|
+
DASHBOARD = auto() # Tabbed dashboard view
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Mapping from HelpMode enum to string mode names used in KEYBINDING_DOCS
|
|
42
|
+
_MODE_NAMES: dict[HelpMode, str] = {
|
|
43
|
+
HelpMode.PICKER: "PICKER",
|
|
44
|
+
HelpMode.MULTI_SELECT: "MULTI_SELECT",
|
|
45
|
+
HelpMode.DASHBOARD: "DASHBOARD",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_help_entries(mode: HelpMode) -> list[tuple[str, str]]:
|
|
50
|
+
"""Get help entries filtered for a specific mode.
|
|
51
|
+
|
|
52
|
+
This function uses KEYBINDING_DOCS from keys.py as the single source
|
|
53
|
+
of truth for keybinding documentation.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
mode: The current screen mode.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of (key, description) tuples for the given mode.
|
|
60
|
+
"""
|
|
61
|
+
from .keys import get_keybindings_for_mode
|
|
62
|
+
|
|
63
|
+
mode_name = _MODE_NAMES[mode]
|
|
64
|
+
return get_keybindings_for_mode(mode_name)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_help_entries_grouped(mode: HelpMode) -> dict[str, list[tuple[str, str]]]:
|
|
68
|
+
"""Get help entries grouped by section for a specific mode.
|
|
69
|
+
|
|
70
|
+
This function uses KEYBINDING_DOCS from keys.py as the single source
|
|
71
|
+
of truth for keybinding documentation.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
mode: The current screen mode.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Dict mapping section names to lists of (key, description) tuples.
|
|
78
|
+
"""
|
|
79
|
+
from .keys import get_keybindings_grouped_by_section
|
|
80
|
+
|
|
81
|
+
mode_name = _MODE_NAMES[mode]
|
|
82
|
+
return get_keybindings_grouped_by_section(mode_name)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_help_content(mode: HelpMode) -> RenderableType:
|
|
86
|
+
"""Render help content for a given mode with section headers.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
mode: The current screen mode.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A Rich renderable with the help content organized by section.
|
|
93
|
+
"""
|
|
94
|
+
from rich.console import Group
|
|
95
|
+
|
|
96
|
+
grouped = get_help_entries_grouped(mode)
|
|
97
|
+
|
|
98
|
+
renderables: list[RenderableType] = []
|
|
99
|
+
|
|
100
|
+
for section_name, entries in grouped.items():
|
|
101
|
+
# Section header
|
|
102
|
+
section_header = Text()
|
|
103
|
+
sep = Indicators.get("HORIZONTAL_LINE")
|
|
104
|
+
section_header.append(f"{sep}{sep}{sep} {section_name} ", style="dim")
|
|
105
|
+
section_header.append(sep * max(0, 30 - len(section_name)), style="dim")
|
|
106
|
+
renderables.append(section_header)
|
|
107
|
+
|
|
108
|
+
# Section table
|
|
109
|
+
table = Table(show_header=False, box=None, padding=(0, 2, 0, 0))
|
|
110
|
+
table.add_column("Key", style="cyan bold", width=12)
|
|
111
|
+
table.add_column("Action", style="dim")
|
|
112
|
+
|
|
113
|
+
for key, desc in entries:
|
|
114
|
+
table.add_row(key, desc)
|
|
115
|
+
|
|
116
|
+
renderables.append(table)
|
|
117
|
+
renderables.append(Text("")) # Spacing between sections
|
|
118
|
+
|
|
119
|
+
# Mode indicator
|
|
120
|
+
mode_display = {
|
|
121
|
+
HelpMode.PICKER: "Picker",
|
|
122
|
+
HelpMode.MULTI_SELECT: "Multi-Select",
|
|
123
|
+
HelpMode.DASHBOARD: "Dashboard",
|
|
124
|
+
}.get(mode, "Unknown")
|
|
125
|
+
|
|
126
|
+
footer = Text()
|
|
127
|
+
footer.append("Press any key to dismiss", style="dim italic")
|
|
128
|
+
renderables.append(footer)
|
|
129
|
+
|
|
130
|
+
return Panel(
|
|
131
|
+
Group(*renderables),
|
|
132
|
+
title=f"[bold]Keyboard Shortcuts[/bold] {Indicators.get('VERTICAL_LINE')} {mode_display}",
|
|
133
|
+
title_align="left",
|
|
134
|
+
border_style="blue",
|
|
135
|
+
padding=(1, 2),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def show_help_overlay(mode: HelpMode, console: Console | None = None) -> None:
|
|
140
|
+
"""Display help overlay and wait for any key to dismiss.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
mode: The current screen mode (affects which keys are shown).
|
|
144
|
+
console: Optional console to use. If None, creates a new one.
|
|
145
|
+
"""
|
|
146
|
+
if console is None:
|
|
147
|
+
from ..console import get_err_console
|
|
148
|
+
|
|
149
|
+
console = get_err_console()
|
|
150
|
+
|
|
151
|
+
content = render_help_content(mode)
|
|
152
|
+
console.print(content)
|
|
153
|
+
|
|
154
|
+
# Wait for any key to dismiss
|
|
155
|
+
from .keys import read_key
|
|
156
|
+
|
|
157
|
+
read_key()
|