iac-code 0.1.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.
Files changed (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. iac_code-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,280 @@
1
+ """ModelPicker dialog with effort level support.
2
+
3
+ Thinking-mode capability is defined per-model in
4
+ ``iac_code.providers.thinking``. This module re-exports the familiar
5
+ ``EffortLevel`` / ``EFFORT_SYMBOLS`` names so callers stay compatible while
6
+ the underlying configuration is shared with the provider layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Callable
12
+
13
+ from rich.console import Group, RenderableType
14
+ from rich.text import Text
15
+
16
+ from iac_code.providers.thinking import (
17
+ EFFORT_ORDER as _EFFORT_ORDER_SHARED,
18
+ )
19
+ from iac_code.providers.thinking import (
20
+ EFFORT_SYMBOLS,
21
+ EffortLevel,
22
+ get_thinking_spec,
23
+ )
24
+ from iac_code.ui.core.key_event import KeyEvent
25
+
26
+ _EFFORT_ORDER = list(_EFFORT_ORDER_SHARED)
27
+
28
+
29
+ class ModelPicker:
30
+ """A picker dialog for selecting a model and optionally adjusting effort level.
31
+
32
+ Navigation:
33
+ ↑/↓ move focus (skipping group headers).
34
+ ←/→ cycle effort level for the focused model (no-op if unsupported).
35
+ Enter confirm selection.
36
+ Escape cancel.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ initial_model: str,
42
+ configured_providers: list[str],
43
+ on_select: Callable[[str, EffortLevel | None], None],
44
+ on_cancel: Callable[[], None],
45
+ keybinding_manager: object | None = None,
46
+ ) -> None:
47
+ self._initial_model = initial_model
48
+ self._configured_providers = configured_providers
49
+ self._on_select = on_select
50
+ self._on_cancel = on_cancel
51
+ self._km = keybinding_manager
52
+
53
+ # Build flat item list; each model item carries {"model": ..., "provider_key": ...}
54
+ self._items: list[dict] = self._build_items()
55
+
56
+ # Initialize effort levels from defaults for each (provider, model) pair
57
+ # in the visible items. Models without effort default to None and are
58
+ # never used downstream.
59
+ self._efforts: dict[tuple[str, str], EffortLevel] = {}
60
+ for item in self._items:
61
+ if "model" not in item:
62
+ continue
63
+ spec = get_thinking_spec(item["provider_key"], item["model"])
64
+ if spec.default_effort is not None:
65
+ self._efforts[(item["provider_key"], item["model"])] = spec.default_effort
66
+
67
+ # Set initial focus on initial_model (or first selectable)
68
+ self._focused_index: int = 0
69
+ self._set_initial_focus()
70
+
71
+ self._done: bool = False
72
+
73
+ # ------------------------------------------------------------------
74
+ # Public API
75
+ # ------------------------------------------------------------------
76
+
77
+ def run(self) -> tuple[str, EffortLevel | None] | None:
78
+ """Blocking run via Dialog. Returns (model, effort) or None on cancel."""
79
+ from iac_code.ui.components.dialog import Dialog
80
+ from iac_code.ui.keybindings.manager import KeybindingManager
81
+
82
+ km: KeybindingManager = self._km if isinstance(self._km, KeybindingManager) else KeybindingManager()
83
+
84
+ result_holder: list[tuple[str, EffortLevel | None]] = []
85
+ cancelled = [False]
86
+
87
+ def _on_select(model: str, effort: EffortLevel | None) -> None:
88
+ result_holder.append((model, effort))
89
+
90
+ def _on_cancel() -> None:
91
+ cancelled[0] = True
92
+
93
+ orig_on_select = self._on_select
94
+ orig_on_cancel = self._on_cancel
95
+ self._on_select = _on_select
96
+ self._on_cancel = _on_cancel
97
+
98
+ dialog = Dialog(
99
+ title="Select Model",
100
+ keybinding_manager=km,
101
+ on_cancel=_on_cancel,
102
+ footer_hints=[
103
+ ("↑↓", "navigate"),
104
+ ("←→", "effort"),
105
+ ("Enter", "select"),
106
+ ("Esc", "cancel"),
107
+ ],
108
+ )
109
+
110
+ dialog.run(
111
+ body_builder=self.render,
112
+ key_handler=self.handle_key,
113
+ )
114
+
115
+ self._on_select = orig_on_select
116
+ self._on_cancel = orig_on_cancel
117
+
118
+ if cancelled[0]:
119
+ return None
120
+ return result_holder[0] if result_holder else None
121
+
122
+ def handle_key(self, key_event: KeyEvent) -> bool:
123
+ """Handle a key event. Returns True if consumed."""
124
+ key = key_event.key
125
+
126
+ if key == "up":
127
+ self._move_focus(-1)
128
+ return True
129
+
130
+ if key == "down":
131
+ self._move_focus(1)
132
+ return True
133
+
134
+ if key == "left":
135
+ pair = self._focused_pair()
136
+ if pair is not None:
137
+ self._cycle_effort(pair, -1)
138
+ return True
139
+
140
+ if key == "right":
141
+ pair = self._focused_pair()
142
+ if pair is not None:
143
+ self._cycle_effort(pair, 1)
144
+ return True
145
+
146
+ if key == "enter":
147
+ pair = self._focused_pair()
148
+ if pair is not None:
149
+ provider_key, model = pair
150
+ spec = get_thinking_spec(provider_key, model)
151
+ effort = self._efforts.get(pair) if spec.supports_effort else None
152
+ self._on_select(model, effort)
153
+ self._done = True
154
+ return True
155
+
156
+ if key == "escape":
157
+ self._on_cancel()
158
+ self._done = True
159
+ return True
160
+
161
+ return False
162
+
163
+ def render(self) -> RenderableType:
164
+ """Render the model picker list."""
165
+ lines: list[RenderableType] = []
166
+ for i, item in enumerate(self._items):
167
+ if "header" in item:
168
+ text = Text()
169
+ text.append(item["header"], style="bold")
170
+ lines.append(text)
171
+ else:
172
+ is_focused = i == self._focused_index
173
+ lines.append(self._render_model_line(item, is_focused))
174
+ return Group(*lines)
175
+
176
+ # ------------------------------------------------------------------
177
+ # Internal helpers
178
+ # ------------------------------------------------------------------
179
+
180
+ def _build_items(self) -> list[dict]:
181
+ """Build flat list of header and model items for configured providers."""
182
+ from iac_code.commands.auth import PROVIDERS
183
+
184
+ items: list[dict] = []
185
+ for provider in PROVIDERS:
186
+ key = str(provider["key_name"])
187
+ if key not in self._configured_providers:
188
+ continue
189
+ items.append({"header": str(provider["display_name"])})
190
+ for model in list(provider["models"]):
191
+ items.append({"model": model, "provider_key": key})
192
+ return items
193
+
194
+ def _set_initial_focus(self) -> None:
195
+ """Set focus to initial_model, or first selectable item."""
196
+ for i, item in enumerate(self._items):
197
+ if item.get("model") == self._initial_model:
198
+ self._focused_index = i
199
+ return
200
+ # Fall back to first selectable
201
+ for i, item in enumerate(self._items):
202
+ if "model" in item:
203
+ self._focused_index = i
204
+ return
205
+
206
+ def _focused_pair(self) -> tuple[str, str] | None:
207
+ """Return (provider_key, model) for the focused item, or None."""
208
+ item = self._items[self._focused_index] if self._items else None
209
+ if item is None or "model" not in item:
210
+ return None
211
+ return item["provider_key"], item["model"]
212
+
213
+ def _focused_model(self) -> str | None:
214
+ """Return the model name at the current focused index, or None."""
215
+ pair = self._focused_pair()
216
+ return pair[1] if pair is not None else None
217
+
218
+ def _move_focus(self, direction: int) -> None:
219
+ """Move focus by direction (+1 or -1), skipping headers."""
220
+ n = len(self._items)
221
+ if n == 0:
222
+ return
223
+ current = self._focused_index
224
+ step = 1 if direction > 0 else -1
225
+ idx = current + step
226
+ while 0 <= idx < n:
227
+ if "model" in self._items[idx]:
228
+ self._focused_index = idx
229
+ return
230
+ idx += step
231
+ # No selectable found — stay at current
232
+
233
+ def _cycle_effort(self, pair: tuple[str, str], direction: int) -> None:
234
+ """Cycle effort level for a (provider_key, model) pair by direction (+1 or -1).
235
+
236
+ Cycles through the model's explicit allowed list, so families with
237
+ gaps (e.g. DeepSeek → ``[HIGH, MAX]``) skip unsupported levels.
238
+ """
239
+ provider_key, model = pair
240
+ spec = get_thinking_spec(provider_key, model)
241
+ if not spec.supports_effort:
242
+ return
243
+
244
+ allowed = spec.allowed_efforts
245
+ current = self._efforts.get(pair, spec.default_effort)
246
+ try:
247
+ current_idx = allowed.index(current)
248
+ except ValueError:
249
+ current_idx = allowed.index(spec.default_effort)
250
+ new_idx = max(0, min(len(allowed) - 1, current_idx + direction))
251
+ self._efforts[pair] = allowed[new_idx]
252
+
253
+ def _render_model_line(self, item: dict, is_focused: bool) -> Text:
254
+ """Render a single model line."""
255
+ model = item["model"]
256
+ provider_key = item["provider_key"]
257
+ text = Text()
258
+
259
+ # Focus indicator
260
+ if is_focused:
261
+ text.append("> ", style="bold cyan")
262
+ else:
263
+ text.append(" ")
264
+
265
+ # Model name
266
+ style = "bold" if is_focused else ""
267
+ text.append(model, style=style)
268
+
269
+ # Current marker
270
+ if model == self._initial_model:
271
+ text.append(" (current)", style="dim green")
272
+
273
+ # Effort symbol for effort-capable models
274
+ spec = get_thinking_spec(provider_key, model)
275
+ if spec.supports_effort and spec.default_effort is not None:
276
+ effort = self._efforts.get((provider_key, model), spec.default_effort)
277
+ symbol = EFFORT_SYMBOLS[effort]
278
+ text.append(f" {symbol}", style="yellow")
279
+
280
+ return text
@@ -0,0 +1,108 @@
1
+ """QuickOpen dialog — open files by fuzzy name search (Ctrl+P)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Callable
7
+
8
+ from rich.panel import Panel
9
+ from rich.syntax import Syntax
10
+
11
+ from iac_code.i18n import _
12
+ from iac_code.ui.components.fuzzy_picker import FuzzyPicker, PickerItem
13
+ from iac_code.ui.suggestions.file_provider import _should_exclude_dir
14
+
15
+
16
+ class QuickOpen:
17
+ """Dialog for opening files by fuzzy-searching the file tree.
18
+
19
+ Selecting a file returns the absolute path and inserts ``@relative_path``
20
+ into the input.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ root_dir: str,
26
+ on_select: Callable[[str], None],
27
+ on_cancel: Callable[[], None],
28
+ keybinding_manager: object | None = None,
29
+ ) -> None:
30
+ self._root_dir = os.path.abspath(root_dir)
31
+ self._on_select = on_select
32
+ self._on_cancel = on_cancel
33
+ self._km = keybinding_manager
34
+
35
+ # ------------------------------------------------------------------
36
+ # Public API
37
+ # ------------------------------------------------------------------
38
+
39
+ def run(self) -> str | None:
40
+ """Open the quick-open picker and return selected file path or None."""
41
+ items = self._build_items()
42
+ result_holder: list[str] = []
43
+ cancelled = [False]
44
+
45
+ def _on_select(item: PickerItem) -> None:
46
+ abs_path: str = item.metadata
47
+ result_holder.append(abs_path)
48
+ self._on_select(f"@{item.display}")
49
+
50
+ def _on_cancel() -> None:
51
+ cancelled[0] = True
52
+ self._on_cancel()
53
+
54
+ picker = FuzzyPicker(
55
+ items=items,
56
+ on_select=_on_select,
57
+ on_cancel=_on_cancel,
58
+ title=_("Open File"),
59
+ placeholder=_("Type to search files..."),
60
+ empty_message=_("No matching files"),
61
+ render_preview=self._render_preview,
62
+ keybinding_manager=self._km,
63
+ )
64
+ picker.run()
65
+
66
+ return result_holder[0] if result_holder else None
67
+
68
+ # ------------------------------------------------------------------
69
+ # Internal helpers
70
+ # ------------------------------------------------------------------
71
+
72
+ def _build_items(self) -> list[PickerItem]:
73
+ """Walk the directory tree and build PickerItems for all files."""
74
+ items: list[PickerItem] = []
75
+ for dirpath, dirnames, filenames in os.walk(self._root_dir):
76
+ # Prune excluded dirs in-place
77
+ dirnames[:] = [d for d in dirnames if not _should_exclude_dir(d)]
78
+ for fname in filenames:
79
+ abs_path = os.path.join(dirpath, fname)
80
+ rel_path = os.path.relpath(abs_path, self._root_dir)
81
+ items.append(
82
+ PickerItem(
83
+ key=f"file:{rel_path}",
84
+ display=rel_path,
85
+ metadata=abs_path,
86
+ filter_text=rel_path,
87
+ )
88
+ )
89
+ return items
90
+
91
+ def _render_preview(self, item: PickerItem) -> Panel:
92
+ """Render first 20 lines of the file with syntax highlighting."""
93
+ abs_path: str = item.metadata
94
+ ext = os.path.splitext(abs_path)[1].lstrip(".")
95
+
96
+ try:
97
+ with open(abs_path, encoding="utf-8", errors="replace") as fh:
98
+ lines = []
99
+ for i, line in enumerate(fh):
100
+ if i >= 20:
101
+ break
102
+ lines.append(line)
103
+ content = "".join(lines)
104
+ except OSError:
105
+ content = ""
106
+
107
+ syntax = Syntax(content, ext or "text", line_numbers=True)
108
+ return Panel(syntax, title=item.display, border_style="dim")