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.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. 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