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