tunacode-cli 0.1.21__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 tunacode-cli might be problematic. Click here for more details.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,402 @@
1
+ """Editor widget for TunaCode REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from rich.cells import cell_len
8
+ from rich.text import Text
9
+ from textual import events
10
+ from textual.binding import Binding
11
+ from textual.expand_tabs import expand_tabs_inline
12
+ from textual.geometry import Offset, Region, Size
13
+ from textual.strip import Strip
14
+ from textual.widgets import Input
15
+
16
+ from .messages import EditorSubmitRequested
17
+ from .status_bar import StatusBar
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class _WrappedEditorState:
22
+ lines: list[Text]
23
+ cursor_offset: tuple[int, int]
24
+ wrap_width: int
25
+
26
+
27
+ class Editor(Input):
28
+ """Single-line editor with Enter to submit."""
29
+
30
+ value: str # type re-declaration for mypy (inherited reactive from Input)
31
+
32
+ BASH_MODE_PREFIX = "!"
33
+ BASH_MODE_PREFIX_WITH_SPACE = "! "
34
+ PASTE_BUFFER_LONG_LINE_THRESHOLD: int = 400
35
+ PASTE_BUFFER_SEPARATOR: str = "\n\n"
36
+ PASTE_INDICATOR_LINES_TEMPLATE: str = "[[ {line_count} lines ]]"
37
+ PASTE_INDICATOR_CHARS_TEMPLATE: str = "[[ {char_count} chars ]]"
38
+ PASTE_INDICATOR_SEPARATOR: str = " "
39
+
40
+ BINDINGS = [
41
+ Binding("enter", "submit", "Submit", show=False),
42
+ ]
43
+
44
+ def __init__(self) -> None:
45
+ super().__init__(placeholder="we await...")
46
+ self._placeholder_cleared: bool = False
47
+ self._was_pasted: bool = False
48
+ self._pasted_content: str = ""
49
+ self._paste_after_typed_text: bool = False
50
+ self._wrap_cache: _WrappedEditorState | None = None
51
+ self._wrap_cache_key: tuple[object, ...] | None = None
52
+
53
+ @property
54
+ def has_paste_buffer(self) -> bool:
55
+ return bool(self._was_pasted and self._pasted_content)
56
+
57
+ @property
58
+ def paste_summary(self) -> str | None:
59
+ if not self.has_paste_buffer:
60
+ return None
61
+ line_count = max(1, len(self._pasted_content.splitlines()))
62
+ if line_count > 1:
63
+ return self.PASTE_INDICATOR_LINES_TEMPLATE.format(line_count=line_count)
64
+
65
+ char_count = len(self._pasted_content)
66
+ return self.PASTE_INDICATOR_CHARS_TEMPLATE.format(char_count=char_count)
67
+
68
+ @property
69
+ def _status_bar(self) -> StatusBar | None:
70
+ """Get status bar or None if not available."""
71
+ from textual.css.query import NoMatches
72
+
73
+ try:
74
+ return self.app.query_one(StatusBar)
75
+ except NoMatches:
76
+ return None
77
+
78
+ def on_key(self, event: events.Key) -> None:
79
+ """Handle key events for confirmation and bash-mode auto-spacing."""
80
+ if event.key in ("1", "2", "3"):
81
+ app = self.app
82
+ if (
83
+ hasattr(app, "pending_confirmation")
84
+ and app.pending_confirmation is not None
85
+ and not app.pending_confirmation.future.done()
86
+ ):
87
+ event.prevent_default()
88
+ return
89
+
90
+ has_paste_buffer = bool(getattr(self, "has_paste_buffer", False))
91
+ if has_paste_buffer and not self.value and event.key == "backspace":
92
+ event.prevent_default()
93
+ self._clear_paste_buffer()
94
+ return
95
+
96
+ if event.character == self.BASH_MODE_PREFIX:
97
+ if self.value.startswith(self.BASH_MODE_PREFIX):
98
+ event.prevent_default()
99
+ value = self.value[len(self.BASH_MODE_PREFIX) :]
100
+ if value.startswith(" "):
101
+ value = value[1:]
102
+ self.value = value
103
+ self.cursor_position = len(self.value)
104
+ return
105
+
106
+ if not self.value:
107
+ event.prevent_default()
108
+ self.value = self.BASH_MODE_PREFIX_WITH_SPACE
109
+ self.cursor_position = len(self.value)
110
+ return
111
+
112
+ # Auto-insert space after ! prefix
113
+ # When value is "!" and user types a non-space character,
114
+ # insert space between ! and the character
115
+ if self.value == "!" and event.character and event.character != " ":
116
+ event.prevent_default()
117
+ self.value = f"! {event.character}"
118
+ self.cursor_position = len(self.value)
119
+
120
+ def clear_input(self) -> None:
121
+ self.value = ""
122
+ self._clear_paste_buffer()
123
+ self.scroll_to(x=0, y=0, animate=False, immediate=True)
124
+
125
+ async def action_submit(self) -> None:
126
+ submission = self._build_submission()
127
+ if submission is None:
128
+ return
129
+ text, raw_text, was_pasted = submission
130
+
131
+ self.post_message(
132
+ EditorSubmitRequested(text=text, raw_text=raw_text, was_pasted=was_pasted)
133
+ )
134
+ self.value = ""
135
+ self.placeholder = "" # Reset placeholder after paste submit
136
+ self._clear_paste_buffer()
137
+ self.scroll_to(x=0, y=0, animate=False, immediate=True)
138
+
139
+ # Reset StatusBar mode
140
+ if status_bar := self._status_bar:
141
+ status_bar.set_mode(None)
142
+
143
+ def _on_paste(self, event: events.Paste) -> None:
144
+ """Capture full paste content before Input truncates to first line."""
145
+ line_count = max(1, len(event.text.splitlines()))
146
+ is_multiline = line_count > 1
147
+ is_long_single_line = len(event.text) >= self.PASTE_BUFFER_LONG_LINE_THRESHOLD
148
+
149
+ if not is_multiline and not is_long_single_line:
150
+ super()._on_paste(event)
151
+ return
152
+
153
+ self._was_pasted = True
154
+ self._pasted_content = event.text
155
+ self._paste_after_typed_text = bool(self.value.strip())
156
+
157
+ if paste_summary := self.paste_summary:
158
+ self.placeholder = paste_summary
159
+
160
+ event.stop()
161
+
162
+ def watch_value(self, value: str) -> None:
163
+ """React to value changes."""
164
+ self._maybe_clear_placeholder(value)
165
+ self._update_bash_mode(value)
166
+
167
+ def _maybe_clear_placeholder(self, value: str) -> None:
168
+ """Clear placeholder on first non-paste input."""
169
+ if value and not self._placeholder_cleared and not self.has_paste_buffer:
170
+ self.placeholder = ""
171
+ self._placeholder_cleared = True
172
+
173
+ def _update_bash_mode(self, value: str) -> None:
174
+ """Toggle bash-mode class and status bar indicator."""
175
+ self.remove_class("bash-mode")
176
+
177
+ if self.has_paste_buffer:
178
+ return
179
+
180
+ if value.startswith(self.BASH_MODE_PREFIX):
181
+ self.add_class("bash-mode")
182
+
183
+ if status_bar := self._status_bar:
184
+ mode = "bash mode" if value.startswith(self.BASH_MODE_PREFIX) else None
185
+ status_bar.set_mode(mode)
186
+
187
+ def _clear_paste_buffer(self) -> None:
188
+ previous_summary = self.paste_summary
189
+ self._was_pasted = False
190
+ self._pasted_content = ""
191
+ self._paste_after_typed_text = False
192
+
193
+ if previous_summary and self.placeholder == previous_summary:
194
+ self.placeholder = ""
195
+
196
+ self._update_bash_mode(self.value)
197
+
198
+ def _invalidate_wrap_cache(self) -> None:
199
+ self._wrap_cache = None
200
+ self._wrap_cache_key = None
201
+
202
+ def _watch_value(self, value: str) -> None:
203
+ super()._watch_value(value)
204
+ self._invalidate_wrap_cache()
205
+
206
+ def _watch__suggestion(self, value: str) -> None: # noqa: ARG002
207
+ del value
208
+ self._invalidate_wrap_cache()
209
+
210
+ def _watch_selection(self, selection: object) -> None: # noqa: ARG002
211
+ del selection
212
+
213
+ self.app.clear_selection()
214
+ self.app.cursor_position = self.cursor_screen_offset
215
+ if self._initial_value:
216
+ return
217
+
218
+ cursor_x, cursor_y = self._wrapped_state().cursor_offset
219
+ self.scroll_to_region(
220
+ Region(cursor_x, cursor_y, width=1, height=1),
221
+ force=True,
222
+ animate=False,
223
+ x_axis=False,
224
+ )
225
+
226
+ @property
227
+ def cursor_screen_offset(self) -> Offset:
228
+ """Cursor offset in screen-space (column, row)."""
229
+ cursor_x, cursor_y = self._wrapped_state().cursor_offset
230
+ content_x, content_y, _width, _height = self.content_region
231
+ scroll_x, scroll_y = self.scroll_offset
232
+ return Offset(content_x + cursor_x - scroll_x, content_y + cursor_y - scroll_y)
233
+
234
+ def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002
235
+ del container
236
+ return max(1, viewport.width)
237
+
238
+ def get_content_height(self, container: Size, viewport: Size, width: int) -> int: # noqa: ARG002
239
+ del container, viewport
240
+ return len(self._wrapped_state(wrap_width=max(1, width)).lines)
241
+
242
+ def render_line(self, y: int) -> Strip:
243
+ state = self._wrapped_state()
244
+ if y < 0 or y >= len(state.lines):
245
+ return Strip.blank(self.size.width)
246
+
247
+ console = self.app.console
248
+ segments = list(
249
+ console.render(state.lines[y], console.options.update_width(state.wrap_width))
250
+ )
251
+ strip = Strip(segments).extend_cell_length(state.wrap_width)
252
+ return strip.apply_style(self.rich_style)
253
+
254
+ def _wrapped_state(self, *, wrap_width: int | None = None) -> _WrappedEditorState:
255
+ width = self._wrap_width(wrap_width)
256
+ key: tuple[object, ...] = (
257
+ width,
258
+ self.value,
259
+ self.placeholder,
260
+ self._suggestion,
261
+ self.selection.start,
262
+ self.selection.end,
263
+ self.has_focus,
264
+ self._cursor_visible,
265
+ self.cursor_position,
266
+ self.cursor_at_end,
267
+ )
268
+ if self._wrap_cache_key == key and self._wrap_cache is not None:
269
+ return self._wrap_cache
270
+
271
+ state = self._compute_wrapped_state(width)
272
+ self._wrap_cache_key = key
273
+ self._wrap_cache = state
274
+ self.virtual_size = Size(width, len(state.lines))
275
+ return state
276
+
277
+ def _wrap_width(self, override: int | None) -> int:
278
+ if override is not None:
279
+ return max(1, override)
280
+ return max(1, self.scrollable_content_region.width)
281
+
282
+ def _compute_wrapped_state(self, wrap_width: int) -> _WrappedEditorState:
283
+ display_text, cursor_index = self._build_wrapped_display_text()
284
+ wrapped_lines = list(display_text.wrap(self.app.console, width=wrap_width, overflow="fold"))
285
+ cursor_x, cursor_y = self._cursor_offset_in_wrapped_lines(wrapped_lines, cursor_index)
286
+ return _WrappedEditorState(
287
+ lines=wrapped_lines,
288
+ cursor_offset=(cursor_x, cursor_y),
289
+ wrap_width=wrap_width,
290
+ )
291
+
292
+ def _build_wrapped_display_text(self) -> tuple[Text, int]:
293
+ cursor_style = self.get_component_rich_style("input--cursor")
294
+
295
+ if not self.value:
296
+ placeholder = Text(self.placeholder, justify="left", end="", overflow="fold")
297
+ placeholder.stylize(self.get_component_rich_style("input--placeholder"))
298
+ if self.has_focus and self._cursor_visible:
299
+ if len(placeholder) == 0:
300
+ placeholder = Text(" ", end="", overflow="fold")
301
+ placeholder.stylize(cursor_style, 0, 1)
302
+ return placeholder, 0
303
+
304
+ if self.has_paste_buffer:
305
+ placeholder.pad_right(1)
306
+ cursor_index = len(placeholder) - 1
307
+ placeholder.stylize(cursor_style, cursor_index, cursor_index + 1)
308
+ return placeholder, cursor_index
309
+
310
+ placeholder.stylize(cursor_style, 0, 1)
311
+ return placeholder, 0
312
+
313
+ value = self.value
314
+ value_length = len(value)
315
+ suggestion = self._suggestion
316
+ show_suggestion = len(suggestion) > value_length and self.has_focus
317
+
318
+ result = Text(value, end="", overflow="fold")
319
+ if self.highlighter is not None:
320
+ result = self.highlighter(result)
321
+
322
+ if show_suggestion:
323
+ result += Text(
324
+ suggestion[value_length:],
325
+ self.get_component_rich_style("input--suggestion"),
326
+ end="",
327
+ )
328
+
329
+ if self.cursor_at_end and not show_suggestion:
330
+ result.pad_right(1)
331
+
332
+ if self.has_focus:
333
+ if not self.selection.is_empty:
334
+ start, end = sorted(self.selection)
335
+ selection_style = self.get_component_rich_style("input--selection")
336
+ result.stylize_before(selection_style, start, end)
337
+
338
+ if self._cursor_visible:
339
+ cursor = self.cursor_position
340
+ result.stylize(cursor_style, cursor, cursor + 1)
341
+
342
+ cursor_index = self.cursor_position
343
+
344
+ if self.has_paste_buffer and (paste_summary := self.paste_summary):
345
+ indicator_style = self.get_component_rich_style("input--placeholder")
346
+ if self._paste_after_typed_text:
347
+ uses_cursor_padding_for_spacing = self.cursor_at_end and not show_suggestion
348
+ separator = (
349
+ "" if uses_cursor_padding_for_spacing else self.PASTE_INDICATOR_SEPARATOR
350
+ )
351
+ result.append(f"...{separator}{paste_summary}", style=indicator_style)
352
+ else:
353
+ prefix = Text(
354
+ f"{paste_summary}...{self.PASTE_INDICATOR_SEPARATOR}",
355
+ style=indicator_style,
356
+ end="",
357
+ overflow="fold",
358
+ )
359
+ cursor_index += len(prefix.plain)
360
+ result = prefix + result
361
+
362
+ return result, cursor_index
363
+
364
+ def _cursor_offset_in_wrapped_lines(
365
+ self,
366
+ lines: list[Text],
367
+ cursor_index: int,
368
+ ) -> tuple[int, int]:
369
+ remaining = max(0, cursor_index)
370
+ for y, line in enumerate(lines):
371
+ line_length = len(line.plain)
372
+ if remaining <= line_length:
373
+ prefix = line.plain[:remaining]
374
+ return cell_len(expand_tabs_inline(prefix, 4)), y
375
+ remaining -= line_length
376
+
377
+ last_line = lines[-1]
378
+ return cell_len(expand_tabs_inline(last_line.plain, 4)), len(lines) - 1
379
+
380
+ def _build_submission(self) -> tuple[str, str, bool] | None:
381
+ typed_text = self.value
382
+ typed_has_content = bool(typed_text.strip())
383
+ paste_text = self._pasted_content.rstrip("\n")
384
+ paste_has_content = bool(paste_text.strip())
385
+
386
+ if not typed_has_content and not paste_has_content:
387
+ return None
388
+
389
+ if not paste_has_content:
390
+ text = typed_text.strip()
391
+ return text, typed_text, False
392
+
393
+ if not typed_has_content:
394
+ return paste_text, paste_text, True
395
+
396
+ typed_stripped = typed_text.strip()
397
+ if self._paste_after_typed_text:
398
+ combined = typed_stripped + self.PASTE_BUFFER_SEPARATOR + paste_text
399
+ else:
400
+ combined = paste_text + self.PASTE_BUFFER_SEPARATOR + typed_stripped
401
+
402
+ return combined, combined, True
@@ -0,0 +1,47 @@
1
+ """File autocomplete dropdown widget for @ mentions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.widgets import Input
6
+ from textual_autocomplete import AutoComplete, DropdownItem, TargetState
7
+
8
+ from tunacode.utils.ui.file_filter import FileFilter
9
+
10
+
11
+ class FileAutoComplete(AutoComplete):
12
+ """Real-time @ file autocomplete dropdown."""
13
+
14
+ def __init__(self, target: Input) -> None:
15
+ self._filter = FileFilter()
16
+ super().__init__(target)
17
+
18
+ def get_search_string(self, target_state: TargetState) -> str:
19
+ """Extract ONLY the part after @ symbol."""
20
+ text = target_state.text
21
+ cursor = target_state.cursor_position
22
+ at_pos = text.rfind("@", 0, cursor)
23
+ if at_pos == -1:
24
+ return ""
25
+ prefix_region = text[at_pos + 1 : cursor]
26
+ if " " in prefix_region:
27
+ return ""
28
+ return prefix_region
29
+
30
+ def get_candidates(self, target_state: TargetState) -> list[DropdownItem]:
31
+ """Return file candidates for current search."""
32
+ search = self.get_search_string(target_state)
33
+ at_pos = target_state.text.rfind("@", 0, target_state.cursor_position)
34
+ if at_pos == -1:
35
+ return []
36
+ candidates = self._filter.complete(search)
37
+ return [DropdownItem(main=f"@{path}") for path in candidates]
38
+
39
+ def apply_completion(self, value: str, state: TargetState) -> None:
40
+ """Replace @path region with completed value."""
41
+ text = state.text
42
+ cursor = state.cursor_position
43
+ at_pos = text.rfind("@", 0, cursor)
44
+ if at_pos != -1:
45
+ new_text = text[:at_pos] + value + text[cursor:]
46
+ self.target.value = new_text
47
+ self.target.cursor_position = at_pos + len(value)
@@ -0,0 +1,46 @@
1
+ """Textual message classes for widget communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Any
7
+
8
+ from textual.message import Message
9
+
10
+
11
+ class EditorCompletionsAvailable(Message):
12
+ """Notify the app when multiple completions are available."""
13
+
14
+ def __init__(self, *, candidates: Iterable[str]) -> None:
15
+ super().__init__()
16
+ self.candidates = list(candidates)
17
+
18
+
19
+ class EditorSubmitRequested(Message):
20
+ """Submit event for the current editor content."""
21
+
22
+ def __init__(self, *, text: str, raw_text: str, was_pasted: bool = False) -> None:
23
+ super().__init__()
24
+ self.text = text
25
+ self.raw_text = raw_text
26
+ self.was_pasted = was_pasted
27
+
28
+
29
+ class ToolResultDisplay(Message):
30
+ """Request to display a tool result panel in the RichLog."""
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ tool_name: str,
36
+ status: str,
37
+ args: dict[str, Any],
38
+ result: str | None = None,
39
+ duration_ms: float | None = None,
40
+ ) -> None:
41
+ super().__init__()
42
+ self.tool_name = tool_name
43
+ self.status = status
44
+ self.args = args
45
+ self.result = result
46
+ self.duration_ms = duration_ms
@@ -0,0 +1,182 @@
1
+ """Resource bar widget for TunaCode REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual.widgets import Static
7
+
8
+ from tunacode.constants import RESOURCE_BAR_COST_FORMAT, RESOURCE_BAR_SEPARATOR
9
+ from tunacode.types import UserConfig
10
+ from tunacode.ui.styles import (
11
+ STYLE_ERROR,
12
+ STYLE_MUTED,
13
+ STYLE_PRIMARY,
14
+ STYLE_SUCCESS,
15
+ STYLE_WARNING,
16
+ )
17
+
18
+
19
+ def _check_lsp_status(user_config: UserConfig) -> tuple[bool, str | None]:
20
+ """Check LSP configuration and server availability.
21
+
22
+ Returns:
23
+ Tuple of (is_enabled, server_name_or_none)
24
+ """
25
+ from pathlib import Path
26
+
27
+ from tunacode.lsp.servers import get_server_command
28
+
29
+ settings = user_config.get("settings", {})
30
+ lsp_config = settings.get("lsp", {})
31
+ is_enabled = lsp_config.get("enabled", False)
32
+
33
+ if not is_enabled:
34
+ return False, None
35
+
36
+ # Check what server would actually be used for a .py file
37
+ # This reflects the real LSP config, not just what's installed
38
+ command = get_server_command(Path("test.py"))
39
+ if command:
40
+ # Extract server name from command (e.g., "ruff" from ["ruff", "server", "--stdio"])
41
+ binary = command[0]
42
+ # Friendly name mapping
43
+ name_map = {
44
+ "ruff": "ruff",
45
+ "pyright-langserver": "pyright",
46
+ "pylsp": "pylsp",
47
+ "typescript-language-server": "tsserver",
48
+ "gopls": "gopls",
49
+ "rust-analyzer": "rust-analyzer",
50
+ }
51
+ return True, name_map.get(binary, binary)
52
+
53
+ return True, None
54
+
55
+
56
+ class ResourceBar(Static):
57
+ """Top bar showing resources: tokens, model, cost, LSP status."""
58
+
59
+ def __init__(self) -> None:
60
+ super().__init__("Loading...")
61
+ self._tokens: int = 0
62
+ self._max_tokens: int = 200000
63
+ self._model: str = "---"
64
+ self._cost: float = 0.0
65
+ self._session_cost: float = 0.0
66
+ self._lsp_enabled: bool = False
67
+ self._lsp_server: str | None = None
68
+
69
+ def on_mount(self) -> None:
70
+ self._refresh_lsp_status()
71
+ self._refresh_display()
72
+
73
+ def _refresh_lsp_status(self) -> None:
74
+ user_config = self._get_user_config()
75
+ if user_config is None:
76
+ return
77
+
78
+ self._lsp_enabled, self._lsp_server = _check_lsp_status(user_config)
79
+
80
+ def _get_user_config(self) -> UserConfig | None:
81
+ app = getattr(self, "app", None)
82
+ if app is None:
83
+ return None
84
+
85
+ state_manager = getattr(app, "state_manager", None)
86
+ if state_manager is None:
87
+ return None
88
+
89
+ session = getattr(state_manager, "session", None)
90
+ if session is None:
91
+ return None
92
+
93
+ user_config = getattr(session, "user_config", None)
94
+ if user_config is None:
95
+ return None
96
+
97
+ return user_config
98
+
99
+ def update_stats(
100
+ self,
101
+ *,
102
+ tokens: int | None = None,
103
+ max_tokens: int | None = None,
104
+ model: str | None = None,
105
+ cost: float | None = None,
106
+ session_cost: float | None = None,
107
+ ) -> None:
108
+ if tokens is not None:
109
+ self._tokens = tokens
110
+ if max_tokens is not None:
111
+ self._max_tokens = max_tokens
112
+ if model is not None:
113
+ self._model = model
114
+ if cost is not None:
115
+ self._cost = cost
116
+ if session_cost is not None:
117
+ self._session_cost = session_cost
118
+ self._refresh_lsp_status()
119
+ self._refresh_display()
120
+
121
+ def _calculate_remaining_pct(self) -> float:
122
+ if self._max_tokens == 0:
123
+ return 0.0
124
+ raw_pct = (self._max_tokens - self._tokens) / self._max_tokens * 100
125
+ return max(0.0, min(100.0, raw_pct))
126
+
127
+ def _get_circle_color(self, remaining_pct: float) -> str:
128
+ if remaining_pct > 60:
129
+ return STYLE_SUCCESS
130
+ if remaining_pct > 30:
131
+ return STYLE_WARNING
132
+ return STYLE_ERROR
133
+
134
+ def _get_circle_char(self, remaining_pct: float) -> str:
135
+ if remaining_pct > 87.5:
136
+ return "●"
137
+ if remaining_pct > 62.5:
138
+ return "◕"
139
+ if remaining_pct > 37.5:
140
+ return "◑"
141
+ if remaining_pct > 12.5:
142
+ return "◔"
143
+ return "○"
144
+
145
+ def _get_lsp_indicator(self) -> tuple[str, str]:
146
+ """Get LSP status indicator character and color.
147
+
148
+ Returns:
149
+ Tuple of (indicator_text, style)
150
+ """
151
+ if not self._lsp_enabled:
152
+ return "", STYLE_MUTED # Don't show anything if LSP is off
153
+ if self._lsp_server:
154
+ return f"LSP: {self._lsp_server}", STYLE_SUCCESS
155
+ return "LSP: no server", STYLE_WARNING
156
+
157
+ def _refresh_display(self) -> None:
158
+ sep = RESOURCE_BAR_SEPARATOR
159
+ session_cost_str = RESOURCE_BAR_COST_FORMAT.format(cost=self._session_cost)
160
+
161
+ remaining_pct = self._calculate_remaining_pct()
162
+ circle_char = self._get_circle_char(remaining_pct)
163
+ circle_color = self._get_circle_color(remaining_pct)
164
+
165
+ lsp_text, lsp_style = self._get_lsp_indicator()
166
+
167
+ parts: list[tuple[str, str]] = [
168
+ (self._model, STYLE_PRIMARY),
169
+ (sep, STYLE_MUTED),
170
+ (circle_char, circle_color),
171
+ (f" {remaining_pct:.0f}%", circle_color),
172
+ (sep, STYLE_MUTED),
173
+ (session_cost_str, STYLE_SUCCESS),
174
+ ]
175
+
176
+ # Only show LSP indicator if enabled
177
+ if lsp_text:
178
+ parts.append((sep, STYLE_MUTED))
179
+ parts.append((lsp_text, lsp_style))
180
+
181
+ content = Text.assemble(*parts)
182
+ self.update(content)