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,319 @@
1
+ """Enhanced selector component with TextOption and InputOption support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any, Callable
8
+
9
+ from rich.console import Group, RenderableType
10
+ from rich.text import Text
11
+
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 TextOption:
18
+ """A selectable text option."""
19
+
20
+ label: str
21
+ value: Any
22
+ description: str = ""
23
+ disabled: bool = False
24
+
25
+
26
+ @dataclass
27
+ class InputOption:
28
+ """An option that opens an inline text input when selected."""
29
+
30
+ label: str
31
+ value: Any
32
+ placeholder: str = ""
33
+ initial_value: str = ""
34
+ on_change: Callable[[str], None] | None = None
35
+
36
+
37
+ OptionType = TextOption | InputOption
38
+
39
+
40
+ class SelectLayout(Enum):
41
+ COMPACT = "compact"
42
+ EXPANDED = "expanded"
43
+ COMPACT_VERTICAL = "compact_vertical"
44
+
45
+
46
+ @dataclass
47
+ class SelectState:
48
+ focused_index: int = 0
49
+ visible_from: int = 0
50
+ visible_to: int = 0
51
+ is_in_input: bool = False
52
+ input_values: dict[Any, str] = field(default_factory=dict)
53
+
54
+
55
+ class Select:
56
+ """An enhanced selector component that supports TextOption and InputOption.
57
+
58
+ Navigation:
59
+ ↑/↓/Ctrl+P/Ctrl+N move focus (skipping disabled options).
60
+ PageUp/PageDown moves by visible_count.
61
+ No wrapping at edges.
62
+ Enter selects TextOption or enters edit mode for InputOption.
63
+ Escape cancels (or exits edit mode if in one).
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ options: list[OptionType],
69
+ default_value: Any = None,
70
+ layout: SelectLayout = SelectLayout.EXPANDED,
71
+ visible_count: int = 10,
72
+ keybinding_manager: object | None = None,
73
+ ) -> None:
74
+ self._options = options
75
+ self._layout = layout
76
+ self._visible_count = visible_count
77
+ self._keybinding_manager = keybinding_manager
78
+
79
+ self.state = SelectState()
80
+
81
+ # Callbacks set externally or by run()
82
+ self._on_select: Callable[[Any], None] | None = None
83
+ self._on_cancel: Callable[[], None] | None = None
84
+ self._done: bool = False
85
+ self._result: Any = None
86
+
87
+ # Active search box for InputOption editing
88
+ self._active_search_box: SearchBox | None = None
89
+
90
+ # Set initial focus based on default_value
91
+ if default_value is not None:
92
+ for i, opt in enumerate(options):
93
+ if opt.value == default_value:
94
+ self.state.focused_index = i
95
+ break
96
+
97
+ # Initialize viewport
98
+ self._update_viewport()
99
+
100
+ # ------------------------------------------------------------------
101
+ # Public API
102
+ # ------------------------------------------------------------------
103
+
104
+ def run(self) -> Any | None:
105
+ """Blocking mode: enter raw input and loop until selection or cancel."""
106
+ from rich.console import Console
107
+
108
+ from iac_code.ui.core.in_place_render import InPlaceRenderer
109
+ from iac_code.ui.core.raw_input import RawInputCapture
110
+
111
+ renderer = InPlaceRenderer(Console())
112
+ result_holder: list[Any] = []
113
+ cancelled = [False]
114
+
115
+ def on_select(value: Any) -> None:
116
+ result_holder.append(value)
117
+ self._done = True
118
+
119
+ def on_cancel() -> None:
120
+ cancelled[0] = True
121
+ self._done = True
122
+
123
+ self._on_select = on_select
124
+ self._on_cancel = on_cancel
125
+
126
+ try:
127
+ with RawInputCapture() as cap:
128
+ while not self._done:
129
+ renderer.render(self.render())
130
+ key_event = cap.read_key(timeout=0.1)
131
+ if key_event is not None:
132
+ self.handle_key(key_event)
133
+ finally:
134
+ renderer.clear()
135
+
136
+ if cancelled[0]:
137
+ return None
138
+ return result_holder[0] if result_holder else None
139
+
140
+ def render(self) -> RenderableType:
141
+ """Render the select component."""
142
+ lines: list[RenderableType] = []
143
+ visible_opts = self._options[self.state.visible_from : self.state.visible_to]
144
+ for i, opt in enumerate(visible_opts):
145
+ abs_i = self.state.visible_from + i
146
+ is_focused = abs_i == self.state.focused_index
147
+ lines.append(self._render_option(opt, is_focused, abs_i))
148
+ return Group(*lines)
149
+
150
+ def handle_key(self, key_event: KeyEvent) -> bool:
151
+ """Handle a key event. Returns True if consumed."""
152
+ key = key_event.key
153
+ ctrl = key_event.ctrl
154
+
155
+ # If we're in input edit mode, delegate to search box
156
+ if self.state.is_in_input and self._active_search_box is not None:
157
+ if key == "escape":
158
+ # Exit edit mode without cancelling
159
+ self.state.is_in_input = False
160
+ self._active_search_box = None
161
+ return True
162
+ if key == "enter":
163
+ # Commit the input value
164
+ opt = self._options[self.state.focused_index]
165
+ self.state.input_values[opt.value] = self._active_search_box.value
166
+ self.state.is_in_input = False
167
+ if self._on_select is not None:
168
+ self._on_select(opt.value)
169
+ self._active_search_box = None
170
+ return True
171
+ return self._active_search_box.handle_key(key_event)
172
+
173
+ # Navigation
174
+ if key == "up" or (ctrl and key == "p"):
175
+ self._move_focus(-1)
176
+ return True
177
+
178
+ if key == "down" or (ctrl and key == "n"):
179
+ self._move_focus(1)
180
+ return True
181
+
182
+ if key == "pageup":
183
+ self._move_focus(-self._visible_count)
184
+ return True
185
+
186
+ if key == "pagedown":
187
+ self._move_focus(self._visible_count)
188
+ return True
189
+
190
+ # Selection / cancel
191
+ if key == "enter":
192
+ return self._handle_enter()
193
+
194
+ if key == "escape":
195
+ if self._on_cancel is not None:
196
+ self._on_cancel()
197
+ return True
198
+
199
+ return False
200
+
201
+ # ------------------------------------------------------------------
202
+ # Internal helpers
203
+ # ------------------------------------------------------------------
204
+
205
+ def _move_focus(self, delta: int) -> None:
206
+ """Move focus by delta steps, skipping disabled options, clamping at edges."""
207
+ n = len(self._options)
208
+ if n == 0:
209
+ return
210
+
211
+ current = self.state.focused_index
212
+ step = 1 if delta > 0 else -1
213
+ remaining = abs(delta)
214
+
215
+ while remaining > 0:
216
+ next_idx = current + step
217
+ if next_idx < 0 or next_idx >= n:
218
+ break # No wrapping
219
+ current = next_idx
220
+ opt = self._options[current]
221
+ is_disabled = isinstance(opt, TextOption) and opt.disabled
222
+ if not is_disabled:
223
+ remaining -= 1
224
+
225
+ self.state.focused_index = current
226
+ self._update_viewport()
227
+
228
+ def _handle_enter(self) -> bool:
229
+ """Handle enter key press."""
230
+ if not self._options:
231
+ return False
232
+
233
+ opt = self._options[self.state.focused_index]
234
+
235
+ # Don't allow selection of disabled options
236
+ if isinstance(opt, TextOption) and opt.disabled:
237
+ return False
238
+
239
+ if isinstance(opt, InputOption):
240
+ # Enter edit mode
241
+ initial = self.state.input_values.get(opt.value, opt.initial_value)
242
+ on_change = opt.on_change
243
+ self._active_search_box = SearchBox(
244
+ placeholder=opt.placeholder,
245
+ initial_value=initial,
246
+ on_change=on_change,
247
+ )
248
+ self.state.is_in_input = True
249
+ return True
250
+
251
+ # TextOption: select it
252
+ if self._on_select is not None:
253
+ self._on_select(opt.value)
254
+ return True
255
+
256
+ def _update_viewport(self) -> None:
257
+ """Update visible_from and visible_to so focused_index is visible."""
258
+ n = len(self._options)
259
+ count = min(self._visible_count, n)
260
+
261
+ if count == 0:
262
+ self.state.visible_from = 0
263
+ self.state.visible_to = 0
264
+ return
265
+
266
+ # Clamp focused_index
267
+ fi = max(0, min(self.state.focused_index, n - 1))
268
+
269
+ vf = self.state.visible_from
270
+ vt = self.state.visible_to
271
+
272
+ # Initialise if not set
273
+ if vt == 0:
274
+ vt = count
275
+
276
+ # Scroll down
277
+ if fi >= vt:
278
+ vt = fi + 1
279
+ vf = vt - count
280
+
281
+ # Scroll up
282
+ if fi < vf:
283
+ vf = fi
284
+ vt = vf + count
285
+
286
+ # Clamp
287
+ vf = max(0, vf)
288
+ vt = min(n, vt)
289
+
290
+ self.state.visible_from = vf
291
+ self.state.visible_to = vt
292
+
293
+ def _render_option(self, opt: OptionType, is_focused: bool, index: int) -> Text:
294
+ """Render a single option line."""
295
+ text = Text()
296
+
297
+ if is_focused:
298
+ text.append("❯ ", style="bold cyan")
299
+ else:
300
+ text.append(" ")
301
+
302
+ if isinstance(opt, TextOption):
303
+ style = "dim" if opt.disabled else ("bold" if is_focused else "")
304
+ text.append(opt.label, style=style)
305
+ if opt.description:
306
+ text.append(f" {opt.description}", style="dim")
307
+ elif isinstance(opt, InputOption):
308
+ text.append(opt.label, style="bold" if is_focused else "")
309
+ # Show current value if we have one
310
+ current_val = self.state.input_values.get(opt.value, opt.initial_value)
311
+ if self.state.is_in_input and is_focused and self._active_search_box is not None:
312
+ text.append(": ")
313
+ text.append_text(self._active_search_box.render())
314
+ elif current_val:
315
+ text.append(f": {current_val}", style="cyan")
316
+ elif opt.placeholder:
317
+ text.append(f": {opt.placeholder}", style="dim")
318
+
319
+ return text
@@ -0,0 +1,42 @@
1
+ """StatusIcon component with coloured status symbols."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ from rich.text import Text
8
+
9
+
10
+ class Status(Enum):
11
+ """Enumeration of supported status values."""
12
+
13
+ SUCCESS = "success"
14
+ ERROR = "error"
15
+ WARNING = "warning"
16
+ INFO = "info"
17
+ PENDING = "pending"
18
+ RUNNING = "running"
19
+
20
+
21
+ _ICONS: dict[Status, tuple[str, str]] = {
22
+ Status.SUCCESS: ("✓", "green"),
23
+ Status.ERROR: ("✗", "red"),
24
+ Status.WARNING: ("⚠", "yellow"),
25
+ Status.INFO: ("●", "blue"),
26
+ Status.PENDING: ("○", "dim"),
27
+ Status.RUNNING: ("◐", "blue"),
28
+ }
29
+
30
+
31
+ class StatusIcon:
32
+ """Renders a coloured status icon as Rich Text."""
33
+
34
+ def __init__(self, status: Status) -> None:
35
+ self.status = status
36
+
37
+ def render(self) -> Text:
38
+ """Return the status icon as Rich Text."""
39
+ icon, style = _ICONS[self.status]
40
+ text = Text()
41
+ text.append(icon, style=style)
42
+ return text
@@ -0,0 +1,128 @@
1
+ """Tabs component for switching between named content panes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, cast
7
+
8
+ from rich.console import Group, RenderableType
9
+ from rich.rule import Rule
10
+ from rich.text import Text
11
+
12
+ from iac_code.ui.core.key_event import KeyEvent
13
+
14
+
15
+ @dataclass
16
+ class Tab:
17
+ """Definition of a single tab."""
18
+
19
+ id: str
20
+ title: str
21
+ content: RenderableType | Callable[[], RenderableType]
22
+
23
+
24
+ class Tabs:
25
+ """A tab-bar component.
26
+
27
+ Renders as:
28
+ [Selected] | Other | Other
29
+ ────────────────────────────
30
+ <content of selected tab>
31
+
32
+ Navigation:
33
+ ← / → move between tabs without wrapping at edges.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ tabs: list[Tab],
39
+ default_tab: str | None = None,
40
+ on_tab_change: Callable[[str], None] | None = None,
41
+ keybinding_manager: object | None = None,
42
+ ) -> None:
43
+ self._tabs = tabs
44
+ self._on_tab_change = on_tab_change
45
+ self._keybinding_manager = keybinding_manager
46
+
47
+ if default_tab is not None:
48
+ self._selected = default_tab
49
+ elif tabs:
50
+ self._selected = tabs[0].id
51
+ else:
52
+ self._selected = ""
53
+
54
+ # ------------------------------------------------------------------
55
+ # Properties
56
+ # ------------------------------------------------------------------
57
+
58
+ @property
59
+ def selected_tab(self) -> str:
60
+ return self._selected
61
+
62
+ # ------------------------------------------------------------------
63
+ # Key handling
64
+ # ------------------------------------------------------------------
65
+
66
+ def handle_key(self, key_event: KeyEvent) -> bool:
67
+ """Handle left/right arrow keys to switch tabs.
68
+
69
+ Returns True if the key was consumed, False otherwise.
70
+ """
71
+ key = key_event.key
72
+
73
+ if key not in ("left", "right"):
74
+ return False
75
+
76
+ ids = [t.id for t in self._tabs]
77
+ if not ids:
78
+ return True # consumed even if nothing to do
79
+
80
+ try:
81
+ idx = ids.index(self._selected)
82
+ except ValueError:
83
+ return True
84
+
85
+ if key == "right":
86
+ new_idx = min(idx + 1, len(ids) - 1)
87
+ else:
88
+ new_idx = max(idx - 1, 0)
89
+
90
+ if new_idx != idx:
91
+ self._selected = ids[new_idx]
92
+ if self._on_tab_change is not None:
93
+ self._on_tab_change(self._selected)
94
+
95
+ return True
96
+
97
+ # ------------------------------------------------------------------
98
+ # Rendering
99
+ # ------------------------------------------------------------------
100
+
101
+ def render(self) -> RenderableType:
102
+ """Render the tab bar, rule, and active tab content."""
103
+ tab_bar = self._render_tab_bar()
104
+ rule = Rule(style="dim")
105
+ content = self._get_content()
106
+ return Group(tab_bar, rule, content)
107
+
108
+ def _render_tab_bar(self) -> Text:
109
+ """Build the tab header line."""
110
+ text = Text()
111
+ for i, tab in enumerate(self._tabs):
112
+ if i > 0:
113
+ text.append(" | ", style="dim")
114
+ if tab.id == self._selected:
115
+ text.append(f"[{tab.title}]", style="bold cyan")
116
+ else:
117
+ text.append(tab.title, style="dim")
118
+ return text
119
+
120
+ def _get_content(self) -> RenderableType:
121
+ """Return the content for the currently selected tab."""
122
+ for tab in self._tabs:
123
+ if tab.id == self._selected:
124
+ if callable(tab.content):
125
+ content_factory = cast(Callable[[], RenderableType], tab.content)
126
+ return content_factory()
127
+ return tab.content
128
+ return Text("")
File without changes
@@ -0,0 +1,129 @@
1
+ """In-place renderer for full-screen pickers and dialogs.
2
+
3
+ Renders a Rich renderable at the cursor's current position in the main
4
+ buffer (not the alternate screen), erasing the previous frame before
5
+ drawing each new one. Same teardown pattern as
6
+ ``Renderer._quiet_stop_live`` — emits zero newlines past the bottom of
7
+ the render so nothing leaks into scrollback.
8
+
9
+ Why not the alternate screen / Rich ``Live(transient=True)``: both leak
10
+ the rendered frames into the main buffer's scrollback on some terminals,
11
+ which makes ``↑`` after a picker show every frame instead of pre-picker
12
+ history.
13
+
14
+ Why not bare ``console.print`` in a loop: that appends each frame below
15
+ the previous one (no erase), and under ``RawInputCapture`` (raw mode,
16
+ OPOST off) the kernel TTY no longer maps ``\\n`` → ``\\r\\n``, so each
17
+ line stair-steps right of where the previous one ended.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from rich.console import Console, RenderableType
23
+
24
+
25
+ class InPlaceRenderer:
26
+ """Erase-and-redraw renderer for picker / dialog event loops.
27
+
28
+ Each :meth:`render` call rewinds over the previous frame using
29
+ ``CR + erase-line + (UP + erase-line) × (h-1)``, then writes the new
30
+ content. :meth:`clear` runs the same erase to wipe the last frame on
31
+ exit. Safe under raw mode: bare ``\\n`` in Rich's output is
32
+ translated to ``\\r\\n`` so each line returns to column 0.
33
+
34
+ Optional ``cursor_to`` lets callers park the hardware cursor inside
35
+ the frame (e.g. inside a search box) after drawing — the renderer
36
+ walks the cursor back to the last row before the next erase, so the
37
+ erase math stays correct.
38
+ """
39
+
40
+ def __init__(self, console: Console) -> None:
41
+ self._console = console
42
+ self._last_height = 0
43
+ # Where the cursor currently sits within the last rendered frame
44
+ # (0-indexed from the top). After a plain :meth:`render` the
45
+ # cursor is on the bottom row; if ``cursor_to`` was passed, it
46
+ # may be parked higher up.
47
+ self._cursor_row = 0
48
+
49
+ @property
50
+ def last_height(self) -> int:
51
+ return self._last_height
52
+
53
+ def render(
54
+ self,
55
+ renderable: RenderableType,
56
+ cursor_to: tuple[int, int] | None = None,
57
+ ) -> None:
58
+ """Erase the previous frame (if any) and draw the new one.
59
+
60
+ Args:
61
+ renderable: Rich renderable to draw.
62
+ cursor_to: Optional ``(row, col)`` offset (both 0-indexed,
63
+ relative to the top-left of the rendered frame) where the
64
+ terminal cursor should land after drawing. Useful for
65
+ pickers that want the hardware cursor to sit inside their
66
+ search box rather than at the bottom of the frame.
67
+ """
68
+ with self._console.capture() as capture:
69
+ self._console.print(renderable)
70
+ text = capture.get().replace("\r\n", "\n").replace("\n", "\r\n")
71
+ lines = text.split("\r\n")
72
+ if lines and lines[-1] == "":
73
+ lines.pop()
74
+
75
+ out = self._console.file
76
+ if self._last_height > 0:
77
+ self._erase_previous(out)
78
+ if lines:
79
+ out.write("\r\n".join(lines))
80
+ new_height = len(lines)
81
+
82
+ # Cursor is now on the last drawn row; place it elsewhere only if
83
+ # the caller asked for it.
84
+ self._cursor_row = max(0, new_height - 1)
85
+ if cursor_to is not None and new_height > 0:
86
+ target_row, target_col = cursor_to
87
+ target_row = max(0, min(target_row, new_height - 1))
88
+ target_col = max(0, target_col)
89
+ out.write("\r")
90
+ rows_up = (new_height - 1) - target_row
91
+ if rows_up > 0:
92
+ out.write(f"\x1b[{rows_up}A")
93
+ if target_col > 0:
94
+ out.write(f"\x1b[{target_col}C")
95
+ self._cursor_row = target_row
96
+
97
+ out.flush()
98
+ self._last_height = new_height
99
+
100
+ def clear(self) -> None:
101
+ """Erase the last rendered frame.
102
+
103
+ Idempotent — calling :meth:`clear` twice is a no-op.
104
+ """
105
+ if self._last_height <= 0:
106
+ return
107
+ out = self._console.file
108
+ try:
109
+ self._erase_previous(out)
110
+ out.flush()
111
+ except OSError:
112
+ pass
113
+ self._last_height = 0
114
+ self._cursor_row = 0
115
+
116
+ # ------------------------------------------------------------------
117
+ # Internal helpers
118
+ # ------------------------------------------------------------------
119
+
120
+ def _erase_previous(self, out) -> None:
121
+ """Walk the cursor back to the last row of the previous frame
122
+ (if it was parked higher by ``cursor_to``) and erase every row.
123
+ """
124
+ rows_down = (self._last_height - 1) - self._cursor_row
125
+ if rows_down > 0:
126
+ out.write(f"\x1b[{rows_down}B")
127
+ out.write("\r\x1b[2K")
128
+ for _ in range(self._last_height - 1):
129
+ out.write("\x1b[A\x1b[2K")