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.

Files changed (113) 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 +259 -0
  8. scc_cli/cli_admin.py +706 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1454 -0
  16. scc_cli/cli_org.py +1428 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +892 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +604 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1074 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1521 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/adapter.py +74 -0
  46. scc_cli/marketplace/compute.py +377 -0
  47. scc_cli/marketplace/constants.py +87 -0
  48. scc_cli/marketplace/managed.py +135 -0
  49. scc_cli/marketplace/materialize.py +723 -0
  50. scc_cli/marketplace/normalize.py +548 -0
  51. scc_cli/marketplace/render.py +257 -0
  52. scc_cli/marketplace/resolve.py +459 -0
  53. scc_cli/marketplace/schema.py +506 -0
  54. scc_cli/marketplace/sync.py +260 -0
  55. scc_cli/marketplace/team_cache.py +195 -0
  56. scc_cli/marketplace/team_fetch.py +688 -0
  57. scc_cli/marketplace/trust.py +244 -0
  58. scc_cli/models/__init__.py +41 -0
  59. scc_cli/models/exceptions.py +273 -0
  60. scc_cli/models/plugin_audit.py +434 -0
  61. scc_cli/org_templates.py +269 -0
  62. scc_cli/output_mode.py +167 -0
  63. scc_cli/panels.py +113 -0
  64. scc_cli/platform.py +350 -0
  65. scc_cli/profiles.py +960 -0
  66. scc_cli/remote.py +443 -0
  67. scc_cli/schemas/__init__.py +1 -0
  68. scc_cli/schemas/org-v1.schema.json +456 -0
  69. scc_cli/schemas/team-config.v1.schema.json +163 -0
  70. scc_cli/sessions.py +425 -0
  71. scc_cli/setup.py +588 -0
  72. scc_cli/source_resolver.py +470 -0
  73. scc_cli/stats.py +378 -0
  74. scc_cli/stores/__init__.py +13 -0
  75. scc_cli/stores/exception_store.py +251 -0
  76. scc_cli/subprocess_utils.py +88 -0
  77. scc_cli/teams.py +382 -0
  78. scc_cli/templates/__init__.py +2 -0
  79. scc_cli/templates/org/__init__.py +0 -0
  80. scc_cli/templates/org/minimal.json +19 -0
  81. scc_cli/templates/org/reference.json +74 -0
  82. scc_cli/templates/org/strict.json +38 -0
  83. scc_cli/templates/org/teams.json +42 -0
  84. scc_cli/templates/statusline.sh +75 -0
  85. scc_cli/theme.py +348 -0
  86. scc_cli/ui/__init__.py +124 -0
  87. scc_cli/ui/branding.py +68 -0
  88. scc_cli/ui/chrome.py +395 -0
  89. scc_cli/ui/dashboard/__init__.py +62 -0
  90. scc_cli/ui/dashboard/_dashboard.py +677 -0
  91. scc_cli/ui/dashboard/loaders.py +395 -0
  92. scc_cli/ui/dashboard/models.py +184 -0
  93. scc_cli/ui/dashboard/orchestrator.py +390 -0
  94. scc_cli/ui/formatters.py +443 -0
  95. scc_cli/ui/gate.py +350 -0
  96. scc_cli/ui/help.py +157 -0
  97. scc_cli/ui/keys.py +538 -0
  98. scc_cli/ui/list_screen.py +431 -0
  99. scc_cli/ui/picker.py +700 -0
  100. scc_cli/ui/prompts.py +200 -0
  101. scc_cli/ui/wizard.py +675 -0
  102. scc_cli/update.py +680 -0
  103. scc_cli/utils/__init__.py +39 -0
  104. scc_cli/utils/fixit.py +264 -0
  105. scc_cli/utils/fuzzy.py +124 -0
  106. scc_cli/utils/locks.py +101 -0
  107. scc_cli/utils/ttl.py +376 -0
  108. scc_cli/validate.py +455 -0
  109. scc_cli-1.4.1.dist-info/METADATA +369 -0
  110. scc_cli-1.4.1.dist-info/RECORD +113 -0
  111. scc_cli-1.4.1.dist-info/WHEEL +4 -0
  112. scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
  113. scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
scc_cli/ui/keys.py ADDED
@@ -0,0 +1,538 @@
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 ActionType(Enum):
122
+ """Types of actions that can result from key handling.
123
+
124
+ Actions are semantic representations of user intent, abstracted
125
+ from the specific keys used to trigger them.
126
+ """
127
+
128
+ NAVIGATE_UP = auto()
129
+ NAVIGATE_DOWN = auto()
130
+ SELECT = auto() # Enter in single-select
131
+ TOGGLE = auto() # Space in multi-select
132
+ TOGGLE_ALL = auto() # 'a' in multi-select
133
+ CONFIRM = auto() # Enter in multi-select
134
+ CANCEL = auto() # Esc
135
+ QUIT = auto() # 'q'
136
+ HELP = auto() # '?'
137
+ FILTER_CHAR = auto() # Printable character for filtering
138
+ FILTER_DELETE = auto() # Backspace
139
+ TAB_NEXT = auto() # Tab
140
+ TAB_PREV = auto() # Shift+Tab
141
+ TEAM_SWITCH = auto() # 't' - switch to team selection
142
+ REFRESH = auto() # 'r' - reload data
143
+ NEW_SESSION = auto() # 'n' - start new session (explicit action)
144
+ CUSTOM = auto() # Action key defined by caller
145
+ NOOP = auto() # Unrecognized key - no action
146
+
147
+
148
+ # ═══════════════════════════════════════════════════════════════════════════════
149
+ # BACK Sentinel for navigation
150
+ # ═══════════════════════════════════════════════════════════════════════════════
151
+
152
+
153
+ class _BackSentinel:
154
+ """Sentinel class for back navigation.
155
+
156
+ Use identity comparison: `if result is BACK`
157
+
158
+ This sentinel signals that the user wants to go back to the previous screen
159
+ (pressed Esc), as opposed to quitting the application entirely (pressed q).
160
+ """
161
+
162
+ __slots__ = ()
163
+
164
+ def __repr__(self) -> str:
165
+ return "BACK"
166
+
167
+
168
+ BACK: _BackSentinel = _BackSentinel()
169
+ """Sentinel value indicating user wants to go back to previous screen."""
170
+
171
+
172
+ @dataclass
173
+ class Action(Generic[T]):
174
+ """Result of handling a key press.
175
+
176
+ Attributes:
177
+ action_type: The semantic action type.
178
+ should_exit: Whether the event loop should terminate.
179
+ result: Optional result value (for SELECT, CONFIRM actions).
180
+ state_changed: Whether the UI needs to re-render.
181
+ custom_key: The key pressed, for CUSTOM action type.
182
+ filter_char: The character to add to filter, for FILTER_CHAR type.
183
+ """
184
+
185
+ action_type: ActionType
186
+ should_exit: bool = False
187
+ result: T | None = None
188
+ state_changed: bool = True
189
+ custom_key: str | None = None
190
+ filter_char: str | None = None
191
+
192
+
193
+ # ═══════════════════════════════════════════════════════════════════════════════
194
+ # Keybinding Documentation (Single Source of Truth)
195
+ # ═══════════════════════════════════════════════════════════════════════════════
196
+
197
+
198
+ class KeyDoc:
199
+ """Documentation entry for a keybinding.
200
+
201
+ This is the single source of truth for keybinding documentation.
202
+ Both the help overlay (ui/help.py) and footer hints (ui/chrome.py)
203
+ should derive their content from KEYBINDING_DOCS.
204
+
205
+ Attributes:
206
+ display_key: How to display the key (e.g., "↑ / k", "Enter").
207
+ description: Full description for help overlay.
208
+ section: Category for grouping in help (Navigation, Selection, etc.).
209
+ modes: Mode names where this binding is shown.
210
+ Empty tuple = all modes ("PICKER", "MULTI_SELECT", "DASHBOARD").
211
+ """
212
+
213
+ __slots__ = ("display_key", "description", "section", "modes")
214
+
215
+ def __init__(
216
+ self,
217
+ display_key: str,
218
+ description: str,
219
+ section: str = "General",
220
+ modes: tuple[str, ...] = (),
221
+ ) -> None:
222
+ self.display_key = display_key
223
+ self.description = description
224
+ self.section = section
225
+ self.modes = modes
226
+
227
+
228
+ # Single source of truth for all keybinding documentation.
229
+ # Modes: empty tuple = all modes, or specific modes like ("PICKER",) or ("DASHBOARD",)
230
+ # Sections group related keybindings in help overlay: Navigation, Filtering, Selection, etc.
231
+ KEYBINDING_DOCS: tuple[KeyDoc, ...] = (
232
+ # Navigation
233
+ KeyDoc("↑ / k", "Move cursor up", section="Navigation"),
234
+ KeyDoc("↓ / j", "Move cursor down", section="Navigation"),
235
+ # Filtering
236
+ KeyDoc("type", "Filter items by text", section="Filtering"),
237
+ KeyDoc("Backspace", "Delete filter character", section="Filtering"),
238
+ # Selection (mode-specific)
239
+ KeyDoc("Enter", "Select item", section="Selection", modes=("PICKER",)),
240
+ KeyDoc("Space", "Toggle selection", section="Selection", modes=("MULTI_SELECT",)),
241
+ KeyDoc("a", "Toggle all items", section="Selection", modes=("MULTI_SELECT",)),
242
+ KeyDoc("Enter", "Confirm selection", section="Selection", modes=("MULTI_SELECT",)),
243
+ KeyDoc("Enter", "View details", section="Selection", modes=("DASHBOARD",)),
244
+ # Tab navigation (dashboard only)
245
+ KeyDoc("Tab", "Next tab", section="Tabs", modes=("DASHBOARD",)),
246
+ KeyDoc("Shift+Tab", "Previous tab", section="Tabs", modes=("DASHBOARD",)),
247
+ # Actions
248
+ KeyDoc("r", "Refresh data", section="Actions", modes=("DASHBOARD",)),
249
+ KeyDoc("n", "New session", section="Actions", modes=("DASHBOARD",)),
250
+ KeyDoc("t", "Switch team", section="Actions"),
251
+ # Exit
252
+ KeyDoc("Esc", "Cancel / go back", section="Exit", modes=("PICKER", "MULTI_SELECT")),
253
+ KeyDoc("q", "Quit", section="Exit", modes=("DASHBOARD",)),
254
+ # Help
255
+ KeyDoc("?", "Show this help", section="Help"),
256
+ )
257
+
258
+
259
+ def get_keybindings_for_mode(mode: str) -> list[tuple[str, str]]:
260
+ """Get keybinding entries filtered for a specific mode.
261
+
262
+ This function provides the primary interface for chrome.py footer hints
263
+ to retrieve keybinding documentation. It filters KEYBINDING_DOCS to
264
+ return only entries applicable to the given mode.
265
+
266
+ Args:
267
+ mode: Mode name ("PICKER", "MULTI_SELECT", or "DASHBOARD").
268
+
269
+ Returns:
270
+ List of (display_key, description) tuples for the given mode.
271
+
272
+ Example:
273
+ >>> entries = get_keybindings_for_mode("PICKER")
274
+ >>> ("Enter", "Select item") in entries
275
+ True
276
+ """
277
+ entries: list[tuple[str, str]] = []
278
+ for doc in KEYBINDING_DOCS:
279
+ # Empty modes = all modes, or check if mode is in the list
280
+ if not doc.modes or mode in doc.modes:
281
+ entries.append((doc.display_key, doc.description))
282
+ return entries
283
+
284
+
285
+ def get_keybindings_grouped_by_section(mode: str) -> dict[str, list[tuple[str, str]]]:
286
+ """Get keybinding entries grouped by section for a specific mode.
287
+
288
+ This function provides the interface for help.py to render keybindings
289
+ with section headers. It filters KEYBINDING_DOCS and groups entries
290
+ by their section field while preserving order.
291
+
292
+ Args:
293
+ mode: Mode name ("PICKER", "MULTI_SELECT", or "DASHBOARD").
294
+
295
+ Returns:
296
+ Dict mapping section names to lists of (display_key, description) tuples.
297
+ Sections are returned in the order they first appear in KEYBINDING_DOCS.
298
+
299
+ Example:
300
+ >>> grouped = get_keybindings_grouped_by_section("DASHBOARD")
301
+ >>> "Navigation" in grouped
302
+ True
303
+ >>> grouped["Navigation"]
304
+ [('↑ / k', 'Move cursor up'), ('↓ / j', 'Move cursor down')]
305
+ """
306
+ # Use dict to preserve insertion order (Python 3.7+)
307
+ sections: dict[str, list[tuple[str, str]]] = {}
308
+ for doc in KEYBINDING_DOCS:
309
+ # Empty modes = all modes, or check if mode is in the list
310
+ if not doc.modes or mode in doc.modes:
311
+ if doc.section not in sections:
312
+ sections[doc.section] = []
313
+ sections[doc.section].append((doc.display_key, doc.description))
314
+ return sections
315
+
316
+
317
+ # ═══════════════════════════════════════════════════════════════════════════════
318
+ # Key Mappings (Runtime Behavior)
319
+ # ═══════════════════════════════════════════════════════════════════════════════
320
+
321
+ # Default key mappings for navigation and common actions.
322
+ # These are shared across all list modes.
323
+ # NOTE: Dashboard-specific keys like 'r' (refresh) should NOT be here.
324
+ # They are handled explicitly in the Dashboard component.
325
+ DEFAULT_KEY_MAP: dict[str, ActionType] = {
326
+ # Arrow key navigation
327
+ readchar.key.UP: ActionType.NAVIGATE_UP,
328
+ readchar.key.DOWN: ActionType.NAVIGATE_DOWN,
329
+ # Vim-style navigation
330
+ "k": ActionType.NAVIGATE_UP,
331
+ "j": ActionType.NAVIGATE_DOWN,
332
+ # Selection and confirmation
333
+ readchar.key.ENTER: ActionType.SELECT,
334
+ readchar.key.SPACE: ActionType.TOGGLE,
335
+ "a": ActionType.TOGGLE_ALL,
336
+ # Cancel and quit
337
+ readchar.key.ESC: ActionType.CANCEL,
338
+ "q": ActionType.QUIT,
339
+ # Help
340
+ "?": ActionType.HELP,
341
+ # Tab navigation
342
+ readchar.key.TAB: ActionType.TAB_NEXT,
343
+ readchar.key.SHIFT_TAB: ActionType.TAB_PREV,
344
+ # Filter control
345
+ readchar.key.BACKSPACE: ActionType.FILTER_DELETE,
346
+ # Team switching
347
+ "t": ActionType.TEAM_SWITCH,
348
+ # Note: "n" (new session) is NOT in DEFAULT_KEY_MAP because it's screen-specific.
349
+ # It's added via custom_keys only to Quick Resume and Dashboard where it makes sense.
350
+ }
351
+
352
+
353
+ def read_key() -> str:
354
+ """Read a single key press from stdin.
355
+
356
+ This function blocks until a key is pressed. It handles
357
+ multi-byte escape sequences for special keys (arrows, etc.)
358
+ via readchar.
359
+
360
+ Returns:
361
+ The key pressed as a string. Special keys are returned
362
+ as readchar.key constants (e.g., readchar.key.UP).
363
+ """
364
+ return readchar.readkey()
365
+
366
+
367
+ def is_printable(key: str) -> bool:
368
+ """Check if a key is a printable character for type-to-filter.
369
+
370
+ Supports full Unicode including non-ASCII characters (åäö, emoji)
371
+ for Swedish locale and international users.
372
+
373
+ Args:
374
+ key: The key to check.
375
+
376
+ Returns:
377
+ True if the key is a single printable character that
378
+ should be added to the filter query.
379
+ """
380
+ # Single character only
381
+ if len(key) != 1:
382
+ return False
383
+
384
+ # Use Python's built-in isprintable() for proper Unicode support
385
+ # This handles åäö, emoji, and other non-ASCII printable chars
386
+ if not key.isprintable():
387
+ return False
388
+
389
+ # Exclude keys with special bindings
390
+ # (they'll be handled by the key map first)
391
+ # NOTE: 'r' and 'n' are NOT here - they're filterable chars.
392
+ # Dashboard handles 'r' and 'n' explicitly via custom_keys.
393
+ special_keys = {"q", "?", "a", "j", "k", " ", "t"}
394
+ return key not in special_keys
395
+
396
+
397
+ def map_key_to_action(
398
+ key: str,
399
+ *,
400
+ custom_keys: dict[str, str] | None = None,
401
+ enable_filter: bool = True,
402
+ filter_active: bool = False,
403
+ ) -> Action[None]:
404
+ """Map a key press to a semantic action.
405
+
406
+ The mapping process follows this priority:
407
+ 1. If filter_active and key is j/k, treat as FILTER_CHAR (user is typing)
408
+ 2. Check DEFAULT_KEY_MAP for standard actions
409
+ 3. Check custom_keys for caller-defined actions
410
+ 4. If enable_filter and printable, return FILTER_CHAR
411
+ 5. Otherwise, return no-op (state_changed=False)
412
+
413
+ Args:
414
+ key: The key that was pressed (from read_key()).
415
+ custom_keys: Optional mapping of keys to custom action names.
416
+ enable_filter: Whether to treat printable chars as filter input.
417
+ filter_active: Whether a filter query is currently active. When True,
418
+ j/k become filter characters instead of navigation shortcuts.
419
+
420
+ Returns:
421
+ An Action describing the semantic meaning of the key press.
422
+
423
+ Example:
424
+ >>> action = map_key_to_action(readchar.key.UP)
425
+ >>> action.action_type
426
+ ActionType.NAVIGATE_UP
427
+
428
+ >>> action = map_key_to_action("s", custom_keys={"s": "shell"})
429
+ >>> action.action_type
430
+ ActionType.CUSTOM
431
+ >>> action.custom_key
432
+ 's'
433
+ """
434
+ # Priority 1: When filter is active, certain mapped keys become filter characters
435
+ # (user is typing, arrow keys still work for navigation)
436
+ # j/k = vim navigation, t = team switch, a = toggle all, n = new session, r = refresh
437
+ # All become filterable when user is actively typing a filter query
438
+ if filter_active and enable_filter and key in ("j", "k", "t", "a", "n", "r"):
439
+ return Action(
440
+ action_type=ActionType.FILTER_CHAR,
441
+ filter_char=key,
442
+ should_exit=False,
443
+ )
444
+
445
+ # Priority 2: Check standard key map
446
+ if key in DEFAULT_KEY_MAP:
447
+ action_type = DEFAULT_KEY_MAP[key]
448
+ should_exit = action_type in (
449
+ ActionType.CANCEL,
450
+ ActionType.QUIT,
451
+ ActionType.SELECT,
452
+ )
453
+ return Action(action_type=action_type, should_exit=should_exit)
454
+
455
+ # Priority 2: Check custom keys
456
+ if custom_keys and key in custom_keys:
457
+ return Action(
458
+ action_type=ActionType.CUSTOM,
459
+ custom_key=key,
460
+ should_exit=False,
461
+ )
462
+
463
+ # Priority 3: Printable character for filter
464
+ if enable_filter and is_printable(key):
465
+ return Action(
466
+ action_type=ActionType.FILTER_CHAR,
467
+ filter_char=key,
468
+ should_exit=False,
469
+ )
470
+
471
+ # No action - key not recognized
472
+ return Action(action_type=ActionType.NOOP, state_changed=False)
473
+
474
+
475
+ class KeyReader:
476
+ """High-level key reader with mode-aware action mapping.
477
+
478
+ This class provides a convenient interface for reading and mapping
479
+ keys in the context of a specific list mode.
480
+
481
+ Attributes:
482
+ custom_keys: Custom key bindings for ACTIONABLE mode.
483
+ enable_filter: Whether type-to-filter is enabled.
484
+
485
+ Example:
486
+ >>> reader = KeyReader(custom_keys={"s": "shell", "l": "logs"})
487
+ >>> action = reader.read() # Blocks for input
488
+ >>> if action.action_type == ActionType.CUSTOM:
489
+ ... handle_custom(action.custom_key)
490
+ """
491
+
492
+ def __init__(
493
+ self,
494
+ *,
495
+ custom_keys: dict[str, str] | None = None,
496
+ enable_filter: bool = True,
497
+ ) -> None:
498
+ """Initialize the key reader.
499
+
500
+ Args:
501
+ custom_keys: Custom key bindings mapping key → action name.
502
+ enable_filter: Whether to enable type-to-filter behavior.
503
+ """
504
+ self.custom_keys = custom_keys or {}
505
+ self.enable_filter = enable_filter
506
+
507
+ def read(self, *, filter_active: bool = False) -> Action[None]:
508
+ """Read a key and return the corresponding action.
509
+
510
+ This method blocks until a key is pressed, then maps it
511
+ to an Action using the configured settings.
512
+
513
+ Args:
514
+ filter_active: Whether a filter query is currently active.
515
+ When True, j/k become filter characters instead of
516
+ navigation shortcuts (arrow keys still work).
517
+
518
+ Returns:
519
+ The Action corresponding to the pressed key.
520
+ """
521
+ key = read_key()
522
+ return map_key_to_action(
523
+ key,
524
+ custom_keys=self.custom_keys,
525
+ enable_filter=self.enable_filter,
526
+ filter_active=filter_active,
527
+ )
528
+
529
+
530
+ # Re-export readchar.key for convenience
531
+ # This allows consumers to use keys.KEY_UP instead of importing readchar
532
+ KEY_UP = readchar.key.UP
533
+ KEY_DOWN = readchar.key.DOWN
534
+ KEY_ENTER = readchar.key.ENTER
535
+ KEY_SPACE = readchar.key.SPACE
536
+ KEY_ESC = readchar.key.ESC
537
+ KEY_TAB = readchar.key.TAB
538
+ KEY_BACKSPACE = readchar.key.BACKSPACE