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/keys.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""Key mapping and input handling for interactive UI.
|
|
2
|
+
|
|
3
|
+
This module provides the input layer for the interactive UI system,
|
|
4
|
+
translating raw keyboard input (via readchar) into semantic Action
|
|
5
|
+
objects that ListScreen and other components can process.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Cross-platform key reading via readchar
|
|
9
|
+
- Vim-style navigation (j/k) in addition to arrow keys
|
|
10
|
+
- Customizable key maps for different list modes
|
|
11
|
+
- Type-to-filter support for printable characters
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> key = read_key()
|
|
15
|
+
>>> action = map_key_to_action(key, mode=ListMode.SINGLE_SELECT)
|
|
16
|
+
>>> if action.action_type == ActionType.SELECT:
|
|
17
|
+
... return action.result
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import Enum, auto
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
25
|
+
|
|
26
|
+
import readchar
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TeamSwitchRequested(Exception): # noqa: N818
|
|
35
|
+
"""Raised when user presses 't' to switch teams.
|
|
36
|
+
|
|
37
|
+
This exception allows interactive components to signal that the user wants to
|
|
38
|
+
switch teams without selecting an item. The caller should catch this and
|
|
39
|
+
redirect to team selection.
|
|
40
|
+
|
|
41
|
+
Note: Named without 'Error' suffix because this is a control flow signal
|
|
42
|
+
(like StopIteration), not an error condition.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StatuslineInstallRequested(Exception): # noqa: N818
|
|
49
|
+
"""Raised when user confirms statusline installation.
|
|
50
|
+
|
|
51
|
+
This is a control flow signal that allows the dashboard to request
|
|
52
|
+
statusline installation without coupling to CLI logic.
|
|
53
|
+
|
|
54
|
+
The orchestrator (run_dashboard) catches this and runs the install flow.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
return_to: Tab name to restore after flow.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, return_to: str = "") -> None:
|
|
61
|
+
self.return_to = return_to
|
|
62
|
+
super().__init__()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class StartRequested(Exception): # noqa: N818
|
|
66
|
+
"""Raised when user wants to start a new session from dashboard.
|
|
67
|
+
|
|
68
|
+
This is a control flow signal (like TeamSwitchRequested) that allows
|
|
69
|
+
the dashboard to request the start wizard without coupling to CLI logic.
|
|
70
|
+
|
|
71
|
+
The orchestrator (run_dashboard) catches this and runs the start flow.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
return_to: Tab name to restore after flow (e.g., "CONTAINERS").
|
|
75
|
+
Uses enum .name (stable identifier), not .value (display string).
|
|
76
|
+
reason: Context for logging/toast (e.g., "no_containers").
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, return_to: str = "", reason: str = "") -> None:
|
|
80
|
+
self.return_to = return_to
|
|
81
|
+
self.reason = reason
|
|
82
|
+
super().__init__(reason)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RefreshRequested(Exception): # noqa: N818
|
|
86
|
+
"""Raised when user requests data refresh via 'r' key.
|
|
87
|
+
|
|
88
|
+
This is a control flow signal that allows the dashboard to request
|
|
89
|
+
a data reload without directly calling data loading functions.
|
|
90
|
+
|
|
91
|
+
The orchestrator catches this and reloads tab data.
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
return_to: Tab name to restore after refresh.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, return_to: str = "") -> None:
|
|
98
|
+
self.return_to = return_to
|
|
99
|
+
super().__init__()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SessionResumeRequested(Exception): # noqa: N818
|
|
103
|
+
"""Raised when user presses Enter on a session to resume it.
|
|
104
|
+
|
|
105
|
+
This is a control flow signal that allows the dashboard to request
|
|
106
|
+
resuming a specific session without coupling to CLI logic.
|
|
107
|
+
|
|
108
|
+
The orchestrator (run_dashboard) catches this and calls the resume flow.
|
|
109
|
+
|
|
110
|
+
Attributes:
|
|
111
|
+
session: Session dict containing workspace, team, name, etc.
|
|
112
|
+
return_to: Tab name to restore after flow (e.g., "SESSIONS").
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, session: dict[str, Any], return_to: str = "") -> None:
|
|
116
|
+
self.session = session
|
|
117
|
+
self.return_to = return_to
|
|
118
|
+
super().__init__()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RecentWorkspacesRequested(Exception): # noqa: N818
|
|
122
|
+
"""Raised when user presses 'w' to open recent workspaces picker.
|
|
123
|
+
|
|
124
|
+
This is a control flow signal that allows the dashboard to request
|
|
125
|
+
showing the recent workspaces picker without coupling to picker logic.
|
|
126
|
+
|
|
127
|
+
The orchestrator catches this and shows the picker.
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
return_to: Tab name to restore after flow (e.g., "WORKTREES").
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self, return_to: str = "") -> None:
|
|
134
|
+
self.return_to = return_to
|
|
135
|
+
super().__init__()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class GitInitRequested(Exception): # noqa: N818
|
|
139
|
+
"""Raised when user presses 'i' to initialize a git repository.
|
|
140
|
+
|
|
141
|
+
This is a control flow signal that allows the dashboard to request
|
|
142
|
+
git initialization without coupling to git logic.
|
|
143
|
+
|
|
144
|
+
The orchestrator catches this and runs the init flow.
|
|
145
|
+
|
|
146
|
+
Attributes:
|
|
147
|
+
return_to: Tab name to restore after flow (e.g., "WORKTREES").
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, return_to: str = "") -> None:
|
|
151
|
+
self.return_to = return_to
|
|
152
|
+
super().__init__()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class CreateWorktreeRequested(Exception): # noqa: N818
|
|
156
|
+
"""Raised when user presses 'c' to create a worktree (or clone if not git).
|
|
157
|
+
|
|
158
|
+
This is a control flow signal that allows the dashboard to request
|
|
159
|
+
worktree creation or clone flow based on context.
|
|
160
|
+
|
|
161
|
+
The orchestrator catches this and runs the appropriate flow.
|
|
162
|
+
|
|
163
|
+
Attributes:
|
|
164
|
+
return_to: Tab name to restore after flow (e.g., "WORKTREES").
|
|
165
|
+
is_git_repo: Whether the current directory is a git repository.
|
|
166
|
+
If True, create worktree; if False, run clone flow.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, return_to: str = "", is_git_repo: bool = True) -> None:
|
|
170
|
+
self.return_to = return_to
|
|
171
|
+
self.is_git_repo = is_git_repo
|
|
172
|
+
super().__init__()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class VerboseToggleRequested(Exception): # noqa: N818
|
|
176
|
+
"""Raised when user presses 'v' to toggle verbose worktree status.
|
|
177
|
+
|
|
178
|
+
This is a control flow signal that allows the dashboard to request
|
|
179
|
+
a data reload with the verbose flag toggled.
|
|
180
|
+
|
|
181
|
+
The orchestrator catches this and reloads with the new verbose setting.
|
|
182
|
+
|
|
183
|
+
Attributes:
|
|
184
|
+
return_to: Tab name to restore after refresh (e.g., "WORKTREES").
|
|
185
|
+
verbose: The new verbose state to apply.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, return_to: str = "", verbose: bool = False) -> None:
|
|
189
|
+
self.return_to = return_to
|
|
190
|
+
self.verbose = verbose
|
|
191
|
+
super().__init__()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ActionType(Enum):
|
|
195
|
+
"""Types of actions that can result from key handling.
|
|
196
|
+
|
|
197
|
+
Actions are semantic representations of user intent, abstracted
|
|
198
|
+
from the specific keys used to trigger them.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
NAVIGATE_UP = auto()
|
|
202
|
+
NAVIGATE_DOWN = auto()
|
|
203
|
+
SELECT = auto() # Enter in single-select
|
|
204
|
+
TOGGLE = auto() # Space in multi-select
|
|
205
|
+
TOGGLE_ALL = auto() # 'a' in multi-select
|
|
206
|
+
CONFIRM = auto() # Enter in multi-select
|
|
207
|
+
CANCEL = auto() # Esc
|
|
208
|
+
QUIT = auto() # 'q'
|
|
209
|
+
HELP = auto() # '?'
|
|
210
|
+
FILTER_CHAR = auto() # Printable character for filtering
|
|
211
|
+
FILTER_DELETE = auto() # Backspace
|
|
212
|
+
TAB_NEXT = auto() # Tab
|
|
213
|
+
TAB_PREV = auto() # Shift+Tab
|
|
214
|
+
TEAM_SWITCH = auto() # 't' - switch to team selection
|
|
215
|
+
REFRESH = auto() # 'r' - reload data
|
|
216
|
+
NEW_SESSION = auto() # 'n' - start new session (explicit action)
|
|
217
|
+
CUSTOM = auto() # Action key defined by caller
|
|
218
|
+
NOOP = auto() # Unrecognized key - no action
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
222
|
+
# BACK Sentinel for navigation
|
|
223
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _BackSentinel:
|
|
227
|
+
"""Sentinel class for back navigation.
|
|
228
|
+
|
|
229
|
+
Use identity comparison: `if result is BACK`
|
|
230
|
+
|
|
231
|
+
This sentinel signals that the user wants to go back to the previous screen
|
|
232
|
+
(pressed Esc), as opposed to quitting the application entirely (pressed q).
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
__slots__ = ()
|
|
236
|
+
|
|
237
|
+
def __repr__(self) -> str:
|
|
238
|
+
return "BACK"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
BACK: _BackSentinel = _BackSentinel()
|
|
242
|
+
"""Sentinel value indicating user wants to go back to previous screen."""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass
|
|
246
|
+
class Action(Generic[T]):
|
|
247
|
+
"""Result of handling a key press.
|
|
248
|
+
|
|
249
|
+
Attributes:
|
|
250
|
+
action_type: The semantic action type.
|
|
251
|
+
should_exit: Whether the event loop should terminate.
|
|
252
|
+
result: Optional result value (for SELECT, CONFIRM actions).
|
|
253
|
+
state_changed: Whether the UI needs to re-render.
|
|
254
|
+
custom_key: The key pressed, for CUSTOM action type.
|
|
255
|
+
filter_char: The character to add to filter, for FILTER_CHAR type.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
action_type: ActionType
|
|
259
|
+
should_exit: bool = False
|
|
260
|
+
result: T | None = None
|
|
261
|
+
state_changed: bool = True
|
|
262
|
+
custom_key: str | None = None
|
|
263
|
+
filter_char: str | None = None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
267
|
+
# Keybinding Documentation (Single Source of Truth)
|
|
268
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class KeyDoc:
|
|
272
|
+
"""Documentation entry for a keybinding.
|
|
273
|
+
|
|
274
|
+
This is the single source of truth for keybinding documentation.
|
|
275
|
+
Both the help overlay (ui/help.py) and footer hints (ui/chrome.py)
|
|
276
|
+
should derive their content from KEYBINDING_DOCS.
|
|
277
|
+
|
|
278
|
+
Attributes:
|
|
279
|
+
display_key: How to display the key (e.g., "↑ / k", "Enter").
|
|
280
|
+
description: Full description for help overlay.
|
|
281
|
+
section: Category for grouping in help (Navigation, Selection, etc.).
|
|
282
|
+
modes: Mode names where this binding is shown.
|
|
283
|
+
Empty tuple = all modes ("PICKER", "MULTI_SELECT", "DASHBOARD").
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
__slots__ = ("display_key", "description", "section", "modes")
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
display_key: str,
|
|
291
|
+
description: str,
|
|
292
|
+
section: str = "General",
|
|
293
|
+
modes: tuple[str, ...] = (),
|
|
294
|
+
) -> None:
|
|
295
|
+
self.display_key = display_key
|
|
296
|
+
self.description = description
|
|
297
|
+
self.section = section
|
|
298
|
+
self.modes = modes
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# Single source of truth for all keybinding documentation.
|
|
302
|
+
# Modes: empty tuple = all modes, or specific modes like ("PICKER",) or ("DASHBOARD",)
|
|
303
|
+
# Sections group related keybindings in help overlay: Navigation, Filtering, Selection, etc.
|
|
304
|
+
KEYBINDING_DOCS: tuple[KeyDoc, ...] = (
|
|
305
|
+
# Navigation
|
|
306
|
+
KeyDoc("↑ / k", "Move cursor up", section="Navigation"),
|
|
307
|
+
KeyDoc("↓ / j", "Move cursor down", section="Navigation"),
|
|
308
|
+
# Filtering
|
|
309
|
+
KeyDoc("type", "Filter items by text", section="Filtering"),
|
|
310
|
+
KeyDoc("Backspace", "Delete filter character", section="Filtering"),
|
|
311
|
+
# Selection (mode-specific)
|
|
312
|
+
KeyDoc("Enter", "Select item", section="Selection", modes=("PICKER",)),
|
|
313
|
+
KeyDoc("Space", "Toggle selection", section="Selection", modes=("MULTI_SELECT",)),
|
|
314
|
+
KeyDoc("a", "Toggle all items", section="Selection", modes=("MULTI_SELECT",)),
|
|
315
|
+
KeyDoc("Enter", "Confirm selection", section="Selection", modes=("MULTI_SELECT",)),
|
|
316
|
+
KeyDoc("Enter", "View details", section="Selection", modes=("DASHBOARD",)),
|
|
317
|
+
# Tab navigation (dashboard only)
|
|
318
|
+
KeyDoc("Tab", "Next tab", section="Tabs", modes=("DASHBOARD",)),
|
|
319
|
+
KeyDoc("Shift+Tab", "Previous tab", section="Tabs", modes=("DASHBOARD",)),
|
|
320
|
+
# Actions
|
|
321
|
+
KeyDoc("r", "Refresh data", section="Actions", modes=("DASHBOARD",)),
|
|
322
|
+
KeyDoc("n", "New session", section="Actions", modes=("DASHBOARD",)),
|
|
323
|
+
KeyDoc("t", "Switch team", section="Actions"),
|
|
324
|
+
# Worktrees tab actions
|
|
325
|
+
KeyDoc("w", "Recent workspaces", section="Worktrees", modes=("DASHBOARD",)),
|
|
326
|
+
KeyDoc("i", "Initialize git repo", section="Worktrees", modes=("DASHBOARD",)),
|
|
327
|
+
KeyDoc("c", "Create worktree / clone", section="Worktrees", modes=("DASHBOARD",)),
|
|
328
|
+
# Exit
|
|
329
|
+
KeyDoc("Esc", "Cancel / go back", section="Exit", modes=("PICKER", "MULTI_SELECT")),
|
|
330
|
+
KeyDoc("q", "Quit", section="Exit", modes=("DASHBOARD",)),
|
|
331
|
+
# Help
|
|
332
|
+
KeyDoc("?", "Show this help", section="Help"),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def get_keybindings_for_mode(mode: str) -> list[tuple[str, str]]:
|
|
337
|
+
"""Get keybinding entries filtered for a specific mode.
|
|
338
|
+
|
|
339
|
+
This function provides the primary interface for chrome.py footer hints
|
|
340
|
+
to retrieve keybinding documentation. It filters KEYBINDING_DOCS to
|
|
341
|
+
return only entries applicable to the given mode.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
mode: Mode name ("PICKER", "MULTI_SELECT", or "DASHBOARD").
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
List of (display_key, description) tuples for the given mode.
|
|
348
|
+
|
|
349
|
+
Example:
|
|
350
|
+
>>> entries = get_keybindings_for_mode("PICKER")
|
|
351
|
+
>>> ("Enter", "Select item") in entries
|
|
352
|
+
True
|
|
353
|
+
"""
|
|
354
|
+
entries: list[tuple[str, str]] = []
|
|
355
|
+
for doc in KEYBINDING_DOCS:
|
|
356
|
+
# Empty modes = all modes, or check if mode is in the list
|
|
357
|
+
if not doc.modes or mode in doc.modes:
|
|
358
|
+
entries.append((doc.display_key, doc.description))
|
|
359
|
+
return entries
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def get_keybindings_grouped_by_section(mode: str) -> dict[str, list[tuple[str, str]]]:
|
|
363
|
+
"""Get keybinding entries grouped by section for a specific mode.
|
|
364
|
+
|
|
365
|
+
This function provides the interface for help.py to render keybindings
|
|
366
|
+
with section headers. It filters KEYBINDING_DOCS and groups entries
|
|
367
|
+
by their section field while preserving order.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
mode: Mode name ("PICKER", "MULTI_SELECT", or "DASHBOARD").
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Dict mapping section names to lists of (display_key, description) tuples.
|
|
374
|
+
Sections are returned in the order they first appear in KEYBINDING_DOCS.
|
|
375
|
+
|
|
376
|
+
Example:
|
|
377
|
+
>>> grouped = get_keybindings_grouped_by_section("DASHBOARD")
|
|
378
|
+
>>> "Navigation" in grouped
|
|
379
|
+
True
|
|
380
|
+
>>> grouped["Navigation"]
|
|
381
|
+
[('↑ / k', 'Move cursor up'), ('↓ / j', 'Move cursor down')]
|
|
382
|
+
"""
|
|
383
|
+
# Use dict to preserve insertion order (Python 3.7+)
|
|
384
|
+
sections: dict[str, list[tuple[str, str]]] = {}
|
|
385
|
+
for doc in KEYBINDING_DOCS:
|
|
386
|
+
# Empty modes = all modes, or check if mode is in the list
|
|
387
|
+
if not doc.modes or mode in doc.modes:
|
|
388
|
+
if doc.section not in sections:
|
|
389
|
+
sections[doc.section] = []
|
|
390
|
+
sections[doc.section].append((doc.display_key, doc.description))
|
|
391
|
+
return sections
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
395
|
+
# Key Mappings (Runtime Behavior)
|
|
396
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
397
|
+
|
|
398
|
+
# Default key mappings for navigation and common actions.
|
|
399
|
+
# These are shared across all list modes.
|
|
400
|
+
# NOTE: Dashboard-specific keys like 'r' (refresh) should NOT be here.
|
|
401
|
+
# They are handled explicitly in the Dashboard component.
|
|
402
|
+
DEFAULT_KEY_MAP: dict[str, ActionType] = {
|
|
403
|
+
# Arrow key navigation
|
|
404
|
+
readchar.key.UP: ActionType.NAVIGATE_UP,
|
|
405
|
+
readchar.key.DOWN: ActionType.NAVIGATE_DOWN,
|
|
406
|
+
# Vim-style navigation
|
|
407
|
+
"k": ActionType.NAVIGATE_UP,
|
|
408
|
+
"j": ActionType.NAVIGATE_DOWN,
|
|
409
|
+
# Selection and confirmation
|
|
410
|
+
readchar.key.ENTER: ActionType.SELECT,
|
|
411
|
+
readchar.key.SPACE: ActionType.TOGGLE,
|
|
412
|
+
"a": ActionType.TOGGLE_ALL,
|
|
413
|
+
# Cancel and quit
|
|
414
|
+
readchar.key.ESC: ActionType.CANCEL,
|
|
415
|
+
"q": ActionType.QUIT,
|
|
416
|
+
# Help
|
|
417
|
+
"?": ActionType.HELP,
|
|
418
|
+
# Tab navigation
|
|
419
|
+
readchar.key.TAB: ActionType.TAB_NEXT,
|
|
420
|
+
readchar.key.SHIFT_TAB: ActionType.TAB_PREV,
|
|
421
|
+
# Filter control
|
|
422
|
+
readchar.key.BACKSPACE: ActionType.FILTER_DELETE,
|
|
423
|
+
# Team switching
|
|
424
|
+
"t": ActionType.TEAM_SWITCH,
|
|
425
|
+
# Note: "n" (new session) is NOT in DEFAULT_KEY_MAP because it's screen-specific.
|
|
426
|
+
# It's added via custom_keys only to Quick Resume and Dashboard where it makes sense.
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def read_key() -> str:
|
|
431
|
+
"""Read a single key press from stdin.
|
|
432
|
+
|
|
433
|
+
This function blocks until a key is pressed. It handles
|
|
434
|
+
multi-byte escape sequences for special keys (arrows, etc.)
|
|
435
|
+
via readchar.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
The key pressed as a string. Special keys are returned
|
|
439
|
+
as readchar.key constants (e.g., readchar.key.UP).
|
|
440
|
+
"""
|
|
441
|
+
return readchar.readkey()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def is_printable(key: str) -> bool:
|
|
445
|
+
"""Check if a key is a printable character for type-to-filter.
|
|
446
|
+
|
|
447
|
+
Supports full Unicode including non-ASCII characters (åäö, emoji)
|
|
448
|
+
for Swedish locale and international users.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
key: The key to check.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
True if the key is a single printable character that
|
|
455
|
+
should be added to the filter query.
|
|
456
|
+
"""
|
|
457
|
+
# Single character only
|
|
458
|
+
if len(key) != 1:
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
# Use Python's built-in isprintable() for proper Unicode support
|
|
462
|
+
# This handles åäö, emoji, and other non-ASCII printable chars
|
|
463
|
+
if not key.isprintable():
|
|
464
|
+
return False
|
|
465
|
+
|
|
466
|
+
# Exclude keys with special bindings
|
|
467
|
+
# (they'll be handled by the key map first)
|
|
468
|
+
# NOTE: 'r' and 'n' are NOT here - they're filterable chars.
|
|
469
|
+
# Dashboard handles 'r' and 'n' explicitly via custom_keys.
|
|
470
|
+
special_keys = {"q", "?", "a", "j", "k", " ", "t"}
|
|
471
|
+
return key not in special_keys
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def map_key_to_action(
|
|
475
|
+
key: str,
|
|
476
|
+
*,
|
|
477
|
+
custom_keys: dict[str, str] | None = None,
|
|
478
|
+
enable_filter: bool = True,
|
|
479
|
+
filter_active: bool = False,
|
|
480
|
+
) -> Action[None]:
|
|
481
|
+
"""Map a key press to a semantic action.
|
|
482
|
+
|
|
483
|
+
The mapping process follows this priority:
|
|
484
|
+
1. If filter_active and key is j/k, treat as FILTER_CHAR (user is typing)
|
|
485
|
+
2. Check DEFAULT_KEY_MAP for standard actions
|
|
486
|
+
3. Check custom_keys for caller-defined actions
|
|
487
|
+
4. If enable_filter and printable, return FILTER_CHAR
|
|
488
|
+
5. Otherwise, return no-op (state_changed=False)
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
key: The key that was pressed (from read_key()).
|
|
492
|
+
custom_keys: Optional mapping of keys to custom action names.
|
|
493
|
+
enable_filter: Whether to treat printable chars as filter input.
|
|
494
|
+
filter_active: Whether a filter query is currently active. When True,
|
|
495
|
+
j/k become filter characters instead of navigation shortcuts.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
An Action describing the semantic meaning of the key press.
|
|
499
|
+
|
|
500
|
+
Example:
|
|
501
|
+
>>> action = map_key_to_action(readchar.key.UP)
|
|
502
|
+
>>> action.action_type
|
|
503
|
+
ActionType.NAVIGATE_UP
|
|
504
|
+
|
|
505
|
+
>>> action = map_key_to_action("s", custom_keys={"s": "shell"})
|
|
506
|
+
>>> action.action_type
|
|
507
|
+
ActionType.CUSTOM
|
|
508
|
+
>>> action.custom_key
|
|
509
|
+
's'
|
|
510
|
+
"""
|
|
511
|
+
# Priority 1: When filter is active, certain mapped keys become filter characters
|
|
512
|
+
# (user is typing, arrow keys still work for navigation)
|
|
513
|
+
# j/k = vim navigation, t = team switch, a = toggle all, n = new session, r = refresh
|
|
514
|
+
# All become filterable when user is actively typing a filter query
|
|
515
|
+
if filter_active and enable_filter and key in ("j", "k", "t", "a", "n", "r"):
|
|
516
|
+
return Action(
|
|
517
|
+
action_type=ActionType.FILTER_CHAR,
|
|
518
|
+
filter_char=key,
|
|
519
|
+
should_exit=False,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# Priority 2: Check standard key map
|
|
523
|
+
if key in DEFAULT_KEY_MAP:
|
|
524
|
+
action_type = DEFAULT_KEY_MAP[key]
|
|
525
|
+
should_exit = action_type in (
|
|
526
|
+
ActionType.CANCEL,
|
|
527
|
+
ActionType.QUIT,
|
|
528
|
+
ActionType.SELECT,
|
|
529
|
+
)
|
|
530
|
+
return Action(action_type=action_type, should_exit=should_exit)
|
|
531
|
+
|
|
532
|
+
# Priority 2: Check custom keys
|
|
533
|
+
if custom_keys and key in custom_keys:
|
|
534
|
+
return Action(
|
|
535
|
+
action_type=ActionType.CUSTOM,
|
|
536
|
+
custom_key=key,
|
|
537
|
+
should_exit=False,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Priority 3: Printable character for filter
|
|
541
|
+
if enable_filter and is_printable(key):
|
|
542
|
+
return Action(
|
|
543
|
+
action_type=ActionType.FILTER_CHAR,
|
|
544
|
+
filter_char=key,
|
|
545
|
+
should_exit=False,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# No action - key not recognized
|
|
549
|
+
return Action(action_type=ActionType.NOOP, state_changed=False)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class KeyReader:
|
|
553
|
+
"""High-level key reader with mode-aware action mapping.
|
|
554
|
+
|
|
555
|
+
This class provides a convenient interface for reading and mapping
|
|
556
|
+
keys in the context of a specific list mode.
|
|
557
|
+
|
|
558
|
+
Attributes:
|
|
559
|
+
custom_keys: Custom key bindings for ACTIONABLE mode.
|
|
560
|
+
enable_filter: Whether type-to-filter is enabled.
|
|
561
|
+
|
|
562
|
+
Example:
|
|
563
|
+
>>> reader = KeyReader(custom_keys={"s": "shell", "l": "logs"})
|
|
564
|
+
>>> action = reader.read() # Blocks for input
|
|
565
|
+
>>> if action.action_type == ActionType.CUSTOM:
|
|
566
|
+
... handle_custom(action.custom_key)
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
def __init__(
|
|
570
|
+
self,
|
|
571
|
+
*,
|
|
572
|
+
custom_keys: dict[str, str] | None = None,
|
|
573
|
+
enable_filter: bool = True,
|
|
574
|
+
) -> None:
|
|
575
|
+
"""Initialize the key reader.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
custom_keys: Custom key bindings mapping key → action name.
|
|
579
|
+
enable_filter: Whether to enable type-to-filter behavior.
|
|
580
|
+
"""
|
|
581
|
+
self.custom_keys = custom_keys or {}
|
|
582
|
+
self.enable_filter = enable_filter
|
|
583
|
+
|
|
584
|
+
def read(self, *, filter_active: bool = False) -> Action[None]:
|
|
585
|
+
"""Read a key and return the corresponding action.
|
|
586
|
+
|
|
587
|
+
This method blocks until a key is pressed, then maps it
|
|
588
|
+
to an Action using the configured settings.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
filter_active: Whether a filter query is currently active.
|
|
592
|
+
When True, j/k become filter characters instead of
|
|
593
|
+
navigation shortcuts (arrow keys still work).
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
The Action corresponding to the pressed key.
|
|
597
|
+
"""
|
|
598
|
+
key = read_key()
|
|
599
|
+
return map_key_to_action(
|
|
600
|
+
key,
|
|
601
|
+
custom_keys=self.custom_keys,
|
|
602
|
+
enable_filter=self.enable_filter,
|
|
603
|
+
filter_active=filter_active,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# Re-export readchar.key for convenience
|
|
608
|
+
# This allows consumers to use keys.KEY_UP instead of importing readchar
|
|
609
|
+
KEY_UP = readchar.key.UP
|
|
610
|
+
KEY_DOWN = readchar.key.DOWN
|
|
611
|
+
KEY_ENTER = readchar.key.ENTER
|
|
612
|
+
KEY_SPACE = readchar.key.SPACE
|
|
613
|
+
KEY_ESC = readchar.key.ESC
|
|
614
|
+
KEY_TAB = readchar.key.TAB
|
|
615
|
+
KEY_BACKSPACE = readchar.key.BACKSPACE
|