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,308 @@
1
+ """Fuzzy search selection component."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable, cast
7
+
8
+ from rich.console import Group, RenderableType
9
+ from rich.text import Text
10
+
11
+ from iac_code.i18n import _
12
+ from iac_code.ui.components.search_box import SearchBox
13
+ from iac_code.ui.core.key_event import KeyEvent
14
+
15
+
16
+ @dataclass
17
+ class PickerItem:
18
+ """An item that can be displayed and selected in the FuzzyPicker."""
19
+
20
+ key: str
21
+ display: str
22
+ description: str = ""
23
+ metadata: Any = None
24
+ filter_text: str = ""
25
+
26
+ def __post_init__(self) -> None:
27
+ if not self.filter_text:
28
+ self.filter_text = self.display
29
+
30
+
31
+ def fuzzy_match(query: str, text: str) -> float | None:
32
+ """Subsequence matching with scoring.
33
+
34
+ Returns None if no match.
35
+ Scoring:
36
+ +1 per matched character
37
+ +0.5 * consecutive_run for consecutive matches
38
+ +2.0 for prefix match
39
+ +1.5 for word boundary match
40
+ """
41
+ if not query:
42
+ return 0.0
43
+
44
+ query_lower = query.lower()
45
+ text_lower = text.lower()
46
+
47
+ # Quick rejection: all query chars must exist in text
48
+ for ch in query_lower:
49
+ if ch not in text_lower:
50
+ return None
51
+
52
+ # Greedy subsequence matching with scoring
53
+ score = 0.0
54
+ ti = 0 # text index
55
+ qi = 0 # query index
56
+ consecutive = 0
57
+ last_ti = -1
58
+ prefix_bonus_given = False
59
+
60
+ while qi < len(query_lower) and ti < len(text_lower):
61
+ if text_lower[ti] == query_lower[qi]:
62
+ score += 1.0
63
+
64
+ # Prefix bonus: first query char matches at position 0
65
+ if ti == 0 and qi == 0 and not prefix_bonus_given:
66
+ score += 2.0
67
+ prefix_bonus_given = True
68
+
69
+ # Word boundary bonus: text char is at start or preceded by space/separator
70
+ if ti == 0 or text_lower[ti - 1] in (" ", "_", "-", "/", "."):
71
+ score += 1.5
72
+
73
+ # Consecutive bonus
74
+ if last_ti == ti - 1:
75
+ consecutive += 1
76
+ score += 0.5 * consecutive
77
+ else:
78
+ consecutive = 0
79
+
80
+ last_ti = ti
81
+ qi += 1
82
+ ti += 1
83
+
84
+ if qi < len(query_lower):
85
+ # Did not match all query characters
86
+ return None
87
+
88
+ return score
89
+
90
+
91
+ class FuzzyPicker:
92
+ """A fuzzy-search selection component.
93
+
94
+ Combines a SearchBox with a filtered, scrollable list of items.
95
+ Items can be a static list (filtered in-memory via fuzzy_match) or
96
+ a callable that returns items dynamically (for async/server search).
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ items: list[PickerItem] | Callable[[str], list[PickerItem]],
102
+ on_select: Callable[[PickerItem], None],
103
+ on_cancel: Callable[[], None] | None = None,
104
+ title: str = "",
105
+ placeholder: str = "",
106
+ render_preview: Callable[[PickerItem], RenderableType] | None = None,
107
+ visible_count: int = 10,
108
+ debounce_ms: int = 0,
109
+ empty_message: str = "",
110
+ tab_action: Callable[[], None] | None = None,
111
+ keybinding_manager: object | None = None,
112
+ ) -> None:
113
+ self._items = items
114
+ self._on_select = on_select
115
+ self._on_cancel = on_cancel
116
+ self._title = title
117
+ self._render_preview = render_preview
118
+ self._visible_count = visible_count
119
+ self._debounce_ms = debounce_ms
120
+ self._empty_message = empty_message or _("No matches found")
121
+ self._tab_action = tab_action
122
+ self._km = keybinding_manager
123
+
124
+ self._search_box = SearchBox(
125
+ placeholder=placeholder,
126
+ on_change=self._on_query_change,
127
+ )
128
+ self._filtered_items: list[PickerItem] = []
129
+ self._focused_index: int = 0
130
+ self._visible_from: int = 0
131
+ self._done: bool = False
132
+ self._result: PickerItem | None = None
133
+
134
+ # Initial population
135
+ self._update_filter("")
136
+
137
+ # ------------------------------------------------------------------
138
+ # Public API
139
+ # ------------------------------------------------------------------
140
+
141
+ def run(self) -> PickerItem | None:
142
+ """Blocking mode: run an event loop and return selected item or None."""
143
+ from rich.cells import cell_len
144
+ from rich.console import Console
145
+
146
+ from iac_code.ui.core.in_place_render import InPlaceRenderer
147
+ from iac_code.ui.core.raw_input import RawInputCapture
148
+
149
+ renderer = InPlaceRenderer(Console())
150
+ self._done = False
151
+ self._result = None
152
+
153
+ def cursor_pos() -> tuple[int, int]:
154
+ # Search box is the first rendered row; ``"> "`` is 2 cells.
155
+ sb = self._search_box
156
+ col = 2 if not sb.value else 2 + cell_len(sb.value[: sb.cursor])
157
+ return (0, col)
158
+
159
+ try:
160
+ with RawInputCapture() as cap:
161
+ while not self._done:
162
+ renderer.render(self.render(), cursor_to=cursor_pos())
163
+ key_event = cap.read_key(timeout=0.1)
164
+ if key_event is not None:
165
+ self.handle_key(key_event)
166
+ finally:
167
+ renderer.clear()
168
+
169
+ return self._result
170
+
171
+ def handle_key(self, key_event: KeyEvent) -> bool:
172
+ """Handle a key event. Returns True if consumed."""
173
+ key = key_event.key
174
+ ctrl = key_event.ctrl
175
+
176
+ if key == "up" or (ctrl and key == "p"):
177
+ self._move_focus(-1)
178
+ return True
179
+
180
+ if key == "down" or (ctrl and key == "n"):
181
+ self._move_focus(1)
182
+ return True
183
+
184
+ if key == "pageup":
185
+ self._move_focus(-self._visible_count)
186
+ return True
187
+
188
+ if key == "pagedown":
189
+ self._move_focus(self._visible_count)
190
+ return True
191
+
192
+ if key == "enter":
193
+ if self._filtered_items:
194
+ item = self._filtered_items[self._focused_index]
195
+ self._result = item
196
+ self._done = True
197
+ self._on_select(item)
198
+ return True
199
+
200
+ if key == "escape":
201
+ self._done = True
202
+ if self._on_cancel is not None:
203
+ self._on_cancel()
204
+ return True
205
+
206
+ if key == "tab" and self._tab_action is not None:
207
+ self._tab_action()
208
+ return True
209
+
210
+ # Delegate to search box
211
+ consumed = self._search_box.handle_key(key_event)
212
+ return consumed
213
+
214
+ def render(self) -> RenderableType:
215
+ """Render search box + item list + match count + optional preview."""
216
+ parts: list[RenderableType] = []
217
+
218
+ # Search box
219
+ parts.append(self._search_box.render())
220
+
221
+ # Item list
222
+ if not self._filtered_items:
223
+ parts.append(Text(self._empty_message, style="dim"))
224
+ else:
225
+ visible = self._filtered_items[self._visible_from : self._visible_from + self._visible_count]
226
+ for i, item in enumerate(visible):
227
+ abs_i = self._visible_from + i
228
+ is_focused = abs_i == self._focused_index
229
+ parts.append(self._render_item(item, is_focused))
230
+
231
+ # Match count
232
+ parts.append(Text(self._get_match_count_text(), style="dim"))
233
+
234
+ # Preview panel
235
+ if (
236
+ self._render_preview is not None
237
+ and self._filtered_items
238
+ and 0 <= self._focused_index < len(self._filtered_items)
239
+ ):
240
+ preview = self._render_preview(self._filtered_items[self._focused_index])
241
+ parts.append(preview)
242
+
243
+ return Group(*parts)
244
+
245
+ # ------------------------------------------------------------------
246
+ # Internal helpers
247
+ # ------------------------------------------------------------------
248
+
249
+ def _on_query_change(self, query: str) -> None:
250
+ self._update_filter(query)
251
+
252
+ def _update_filter(self, query: str) -> None:
253
+ """Update _filtered_items based on query. Reset focus to 0."""
254
+ if callable(self._items):
255
+ # Dynamic: call the function directly
256
+ item_factory = cast(Callable[[str], list[PickerItem]], self._items)
257
+ self._filtered_items = item_factory(query)
258
+ else:
259
+ # Static list: apply fuzzy matching
260
+ static_items = self._items
261
+ if not query:
262
+ self._filtered_items = list(static_items)
263
+ else:
264
+ scored: list[tuple[float, PickerItem]] = []
265
+ for item in static_items:
266
+ score = fuzzy_match(query, item.filter_text)
267
+ if score is not None:
268
+ scored.append((score, item))
269
+ # Sort by score descending
270
+ scored.sort(key=lambda x: x[0], reverse=True)
271
+ self._filtered_items = [item for _, item in scored]
272
+
273
+ self._focused_index = 0
274
+ self._visible_from = 0
275
+
276
+ def _move_focus(self, delta: int) -> None:
277
+ """Move focus by delta, clamping at edges."""
278
+ n = len(self._filtered_items)
279
+ if n == 0:
280
+ return
281
+ new_idx = max(0, min(self._focused_index + delta, n - 1))
282
+ self._focused_index = new_idx
283
+ self._update_scroll()
284
+
285
+ def _update_scroll(self) -> None:
286
+ """Adjust visible_from so focused item is in view."""
287
+ if self._focused_index < self._visible_from:
288
+ self._visible_from = self._focused_index
289
+ elif self._focused_index >= self._visible_from + self._visible_count:
290
+ self._visible_from = self._focused_index - self._visible_count + 1
291
+
292
+ def _get_match_count_text(self) -> str:
293
+ total = len(self._items) if isinstance(self._items, list) else len(self._filtered_items)
294
+ matched = len(self._filtered_items)
295
+ if isinstance(self._items, list):
296
+ return f"{matched}/{total} matches"
297
+ return f"{matched} results"
298
+
299
+ def _render_item(self, item: PickerItem, is_focused: bool) -> Text:
300
+ text = Text()
301
+ if is_focused:
302
+ text.append("❯ ", style="bold cyan")
303
+ else:
304
+ text.append(" ")
305
+ text.append(item.display, style="bold" if is_focused else "")
306
+ if item.description:
307
+ text.append(f" {item.description}", style="dim")
308
+ return text
@@ -0,0 +1,54 @@
1
+ """ProgressBar component using Rich Text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+
7
+
8
+ class ProgressBar:
9
+ """A simple terminal progress bar rendered as Rich Text.
10
+
11
+ Format: "████░░░░ 65%"
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ total: int = 100,
17
+ completed: int = 0,
18
+ width: int = 40,
19
+ filled_char: str = "█",
20
+ empty_char: str = "░",
21
+ style: str = "blue",
22
+ ) -> None:
23
+ self.total = total
24
+ self._completed = completed
25
+ self.width = width
26
+ self.filled_char = filled_char
27
+ self.empty_char = empty_char
28
+ self.style = style
29
+
30
+ # ------------------------------------------------------------------
31
+ # Update
32
+ # ------------------------------------------------------------------
33
+
34
+ def update(self, completed: int) -> None:
35
+ """Set the completed count."""
36
+ self._completed = completed
37
+
38
+ # ------------------------------------------------------------------
39
+ # Rendering
40
+ # ------------------------------------------------------------------
41
+
42
+ def render(self) -> Text:
43
+ """Render the progress bar as Rich Text."""
44
+ ratio = self._completed / self.total if self.total > 0 else 0.0
45
+ ratio = max(0.0, min(1.0, ratio))
46
+ filled = round(self.width * ratio)
47
+ empty = self.width - filled
48
+ pct = int(ratio * 100)
49
+
50
+ text = Text()
51
+ text.append(self.filled_char * filled, style=self.style)
52
+ text.append(self.empty_char * empty, style="dim")
53
+ text.append(f" {pct}%")
54
+ return text
@@ -0,0 +1,165 @@
1
+ """Single-line text input component with editing operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable
6
+
7
+ from rich.text import Text
8
+
9
+ from iac_code.ui.core.key_event import KeyEvent
10
+
11
+
12
+ class SearchBox:
13
+ """A single-line text input component with cursor and editing operations.
14
+
15
+ Editing operations supported:
16
+ - Printable character insertion
17
+ - Backspace (delete before cursor), Delete (delete after cursor)
18
+ - Left / Right cursor movement
19
+ - Home / Ctrl+A (move to start), End / Ctrl+E (move to end)
20
+ - Ctrl+K (delete to end of line)
21
+ - Ctrl+U (delete to start of line)
22
+ - Ctrl+W (delete previous word)
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ placeholder: str = "",
28
+ initial_value: str = "",
29
+ on_change: Callable[[str], None] | None = None,
30
+ ) -> None:
31
+ self._placeholder = placeholder
32
+ self._text: list[str] = list(initial_value)
33
+ self._cursor: int = len(self._text)
34
+ self._on_change = on_change
35
+
36
+ # ------------------------------------------------------------------
37
+ # Properties
38
+ # ------------------------------------------------------------------
39
+
40
+ @property
41
+ def value(self) -> str:
42
+ return "".join(self._text)
43
+
44
+ @property
45
+ def cursor(self) -> int:
46
+ return self._cursor
47
+
48
+ # ------------------------------------------------------------------
49
+ # Key handling
50
+ # ------------------------------------------------------------------
51
+
52
+ def handle_key(self, key_event: KeyEvent) -> bool:
53
+ """Handle a key event. Returns True if consumed, False otherwise."""
54
+ key = key_event.key
55
+ ctrl = key_event.ctrl
56
+
57
+ old_value = self.value
58
+
59
+ # --- Navigation (no text change) ---
60
+ if key == "left":
61
+ if self._cursor > 0:
62
+ self._cursor -= 1
63
+ return True
64
+
65
+ if key == "right":
66
+ if self._cursor < len(self._text):
67
+ self._cursor += 1
68
+ return True
69
+
70
+ if key == "home" or (ctrl and key == "a"):
71
+ self._cursor = 0
72
+ return True
73
+
74
+ if key == "end" or (ctrl and key == "e"):
75
+ self._cursor = len(self._text)
76
+ return True
77
+
78
+ # --- Deletion ---
79
+ if key == "backspace":
80
+ if self._cursor > 0:
81
+ self._cursor -= 1
82
+ self._text.pop(self._cursor)
83
+ self._notify(old_value)
84
+ return True
85
+
86
+ if key == "delete":
87
+ if self._cursor < len(self._text):
88
+ self._text.pop(self._cursor)
89
+ self._notify(old_value)
90
+ return True
91
+
92
+ if ctrl and key == "k":
93
+ del self._text[self._cursor :]
94
+ self._notify(old_value)
95
+ return True
96
+
97
+ if ctrl and key == "u":
98
+ del self._text[: self._cursor]
99
+ self._cursor = 0
100
+ self._notify(old_value)
101
+ return True
102
+
103
+ if ctrl and key == "w":
104
+ # Delete backwards one "token": either a run of spaces or a run of
105
+ # non-space characters immediately before the cursor.
106
+ pos = self._cursor
107
+ if pos > 0 and self._text[pos - 1] == " ":
108
+ # preceding char is a space: delete the run of spaces
109
+ while pos > 0 and self._text[pos - 1] == " ":
110
+ pos -= 1
111
+ else:
112
+ # preceding char is a non-space: delete the word
113
+ while pos > 0 and self._text[pos - 1] != " ":
114
+ pos -= 1
115
+ del self._text[pos : self._cursor]
116
+ self._cursor = pos
117
+ self._notify(old_value)
118
+ return True
119
+
120
+ # --- Character insertion ---
121
+ # Only handle printable characters (single char, no ctrl modifier)
122
+ char = key_event.char
123
+ if not ctrl and len(char) == 1 and char.isprintable():
124
+ self._text.insert(self._cursor, char)
125
+ self._cursor += 1
126
+ self._notify(old_value)
127
+ return True
128
+
129
+ return False
130
+
131
+ # ------------------------------------------------------------------
132
+ # Rendering
133
+ # ------------------------------------------------------------------
134
+
135
+ def render(self) -> Text:
136
+ """Render the search box as a Rich Text object.
137
+
138
+ Format: "> text_" where _ represents the cursor position.
139
+ """
140
+ text = Text()
141
+ text.append("> ", style="bold cyan")
142
+
143
+ value = self.value
144
+ if not value and self._placeholder:
145
+ text.append(self._placeholder, style="dim")
146
+ else:
147
+ before = value[: self._cursor]
148
+ at = value[self._cursor] if self._cursor < len(value) else " "
149
+ after = value[self._cursor + 1 :] if self._cursor < len(value) else ""
150
+ text.append(before)
151
+ text.append(at, style="reverse")
152
+ text.append(after)
153
+
154
+ return text
155
+
156
+ # ------------------------------------------------------------------
157
+ # Internal helpers
158
+ # ------------------------------------------------------------------
159
+
160
+ def _notify(self, old_value: str) -> None:
161
+ """Call on_change callback if value changed."""
162
+ if self._on_change is not None:
163
+ new_value = self.value
164
+ if new_value != old_value:
165
+ self._on_change(new_value)