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.
- tunacode/__init__.py +0 -0
- tunacode/cli/textual_repl.tcss +283 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +45 -0
- tunacode/configuration/models.py +147 -0
- tunacode/configuration/models_registry.json +1 -0
- tunacode/configuration/pricing.py +74 -0
- tunacode/configuration/settings.py +35 -0
- tunacode/constants.py +227 -0
- tunacode/core/__init__.py +6 -0
- tunacode/core/agents/__init__.py +39 -0
- tunacode/core/agents/agent_components/__init__.py +48 -0
- tunacode/core/agents/agent_components/agent_config.py +441 -0
- tunacode/core/agents/agent_components/agent_helpers.py +290 -0
- tunacode/core/agents/agent_components/message_handler.py +99 -0
- tunacode/core/agents/agent_components/node_processor.py +477 -0
- tunacode/core/agents/agent_components/response_state.py +129 -0
- tunacode/core/agents/agent_components/result_wrapper.py +51 -0
- tunacode/core/agents/agent_components/state_transition.py +112 -0
- tunacode/core/agents/agent_components/streaming.py +271 -0
- tunacode/core/agents/agent_components/task_completion.py +40 -0
- tunacode/core/agents/agent_components/tool_buffer.py +44 -0
- tunacode/core/agents/agent_components/tool_executor.py +101 -0
- tunacode/core/agents/agent_components/truncation_checker.py +37 -0
- tunacode/core/agents/delegation_tools.py +109 -0
- tunacode/core/agents/main.py +545 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/research_agent.py +231 -0
- tunacode/core/compaction.py +218 -0
- tunacode/core/prompting/__init__.py +27 -0
- tunacode/core/prompting/loader.py +66 -0
- tunacode/core/prompting/prompting_engine.py +98 -0
- tunacode/core/prompting/sections.py +50 -0
- tunacode/core/prompting/templates.py +69 -0
- tunacode/core/state.py +409 -0
- tunacode/exceptions.py +313 -0
- tunacode/indexing/__init__.py +5 -0
- tunacode/indexing/code_index.py +432 -0
- tunacode/indexing/constants.py +86 -0
- tunacode/lsp/__init__.py +112 -0
- tunacode/lsp/client.py +351 -0
- tunacode/lsp/diagnostics.py +19 -0
- tunacode/lsp/servers.py +101 -0
- tunacode/prompts/default_prompt.md +952 -0
- tunacode/prompts/research/sections/agent_role.xml +5 -0
- tunacode/prompts/research/sections/constraints.xml +14 -0
- tunacode/prompts/research/sections/output_format.xml +57 -0
- tunacode/prompts/research/sections/tool_use.xml +23 -0
- tunacode/prompts/sections/advanced_patterns.xml +255 -0
- tunacode/prompts/sections/agent_role.xml +8 -0
- tunacode/prompts/sections/completion.xml +10 -0
- tunacode/prompts/sections/critical_rules.xml +37 -0
- tunacode/prompts/sections/examples.xml +220 -0
- tunacode/prompts/sections/output_style.xml +94 -0
- tunacode/prompts/sections/parallel_exec.xml +105 -0
- tunacode/prompts/sections/search_pattern.xml +100 -0
- tunacode/prompts/sections/system_info.xml +6 -0
- tunacode/prompts/sections/tool_use.xml +84 -0
- tunacode/prompts/sections/user_instructions.xml +3 -0
- tunacode/py.typed +0 -0
- tunacode/templates/__init__.py +5 -0
- tunacode/templates/loader.py +15 -0
- tunacode/tools/__init__.py +10 -0
- tunacode/tools/authorization/__init__.py +29 -0
- tunacode/tools/authorization/context.py +32 -0
- tunacode/tools/authorization/factory.py +20 -0
- tunacode/tools/authorization/handler.py +58 -0
- tunacode/tools/authorization/notifier.py +35 -0
- tunacode/tools/authorization/policy.py +19 -0
- tunacode/tools/authorization/requests.py +119 -0
- tunacode/tools/authorization/rules.py +72 -0
- tunacode/tools/bash.py +222 -0
- tunacode/tools/decorators.py +213 -0
- tunacode/tools/glob.py +353 -0
- tunacode/tools/grep.py +468 -0
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +158 -0
- tunacode/tools/grep_components/result_formatter.py +87 -0
- tunacode/tools/grep_components/search_result.py +34 -0
- tunacode/tools/list_dir.py +205 -0
- tunacode/tools/prompts/bash_prompt.xml +10 -0
- tunacode/tools/prompts/glob_prompt.xml +7 -0
- tunacode/tools/prompts/grep_prompt.xml +10 -0
- tunacode/tools/prompts/list_dir_prompt.xml +7 -0
- tunacode/tools/prompts/read_file_prompt.xml +9 -0
- tunacode/tools/prompts/todoclear_prompt.xml +12 -0
- tunacode/tools/prompts/todoread_prompt.xml +16 -0
- tunacode/tools/prompts/todowrite_prompt.xml +28 -0
- tunacode/tools/prompts/update_file_prompt.xml +9 -0
- tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
- tunacode/tools/prompts/write_file_prompt.xml +7 -0
- tunacode/tools/react.py +111 -0
- tunacode/tools/read_file.py +68 -0
- tunacode/tools/todo.py +222 -0
- tunacode/tools/update_file.py +62 -0
- tunacode/tools/utils/__init__.py +1 -0
- tunacode/tools/utils/ripgrep.py +311 -0
- tunacode/tools/utils/text_match.py +352 -0
- tunacode/tools/web_fetch.py +245 -0
- tunacode/tools/write_file.py +34 -0
- tunacode/tools/xml_helper.py +34 -0
- tunacode/types/__init__.py +166 -0
- tunacode/types/base.py +94 -0
- tunacode/types/callbacks.py +53 -0
- tunacode/types/dataclasses.py +121 -0
- tunacode/types/pydantic_ai.py +31 -0
- tunacode/types/state.py +122 -0
- tunacode/ui/__init__.py +6 -0
- tunacode/ui/app.py +542 -0
- tunacode/ui/commands/__init__.py +430 -0
- tunacode/ui/components/__init__.py +1 -0
- tunacode/ui/headless/__init__.py +5 -0
- tunacode/ui/headless/output.py +72 -0
- tunacode/ui/main.py +252 -0
- tunacode/ui/renderers/__init__.py +41 -0
- tunacode/ui/renderers/errors.py +197 -0
- tunacode/ui/renderers/panels.py +550 -0
- tunacode/ui/renderers/search.py +314 -0
- tunacode/ui/renderers/tools/__init__.py +21 -0
- tunacode/ui/renderers/tools/bash.py +247 -0
- tunacode/ui/renderers/tools/diagnostics.py +186 -0
- tunacode/ui/renderers/tools/glob.py +226 -0
- tunacode/ui/renderers/tools/grep.py +228 -0
- tunacode/ui/renderers/tools/list_dir.py +198 -0
- tunacode/ui/renderers/tools/read_file.py +226 -0
- tunacode/ui/renderers/tools/research.py +294 -0
- tunacode/ui/renderers/tools/update_file.py +237 -0
- tunacode/ui/renderers/tools/web_fetch.py +182 -0
- tunacode/ui/repl_support.py +226 -0
- tunacode/ui/screens/__init__.py +16 -0
- tunacode/ui/screens/model_picker.py +303 -0
- tunacode/ui/screens/session_picker.py +181 -0
- tunacode/ui/screens/setup.py +218 -0
- tunacode/ui/screens/theme_picker.py +90 -0
- tunacode/ui/screens/update_confirm.py +69 -0
- tunacode/ui/shell_runner.py +129 -0
- tunacode/ui/styles/layout.tcss +98 -0
- tunacode/ui/styles/modals.tcss +38 -0
- tunacode/ui/styles/panels.tcss +81 -0
- tunacode/ui/styles/theme-nextstep.tcss +303 -0
- tunacode/ui/styles/widgets.tcss +33 -0
- tunacode/ui/styles.py +18 -0
- tunacode/ui/widgets/__init__.py +23 -0
- tunacode/ui/widgets/command_autocomplete.py +62 -0
- tunacode/ui/widgets/editor.py +402 -0
- tunacode/ui/widgets/file_autocomplete.py +47 -0
- tunacode/ui/widgets/messages.py +46 -0
- tunacode/ui/widgets/resource_bar.py +182 -0
- tunacode/ui/widgets/status_bar.py +98 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/config/__init__.py +13 -0
- tunacode/utils/config/user_configuration.py +91 -0
- tunacode/utils/messaging/__init__.py +10 -0
- tunacode/utils/messaging/message_utils.py +34 -0
- tunacode/utils/messaging/token_counter.py +77 -0
- tunacode/utils/parsing/__init__.py +13 -0
- tunacode/utils/parsing/command_parser.py +55 -0
- tunacode/utils/parsing/json_utils.py +188 -0
- tunacode/utils/parsing/retry.py +146 -0
- tunacode/utils/parsing/tool_parser.py +267 -0
- tunacode/utils/security/__init__.py +15 -0
- tunacode/utils/security/command.py +106 -0
- tunacode/utils/system/__init__.py +25 -0
- tunacode/utils/system/gitignore.py +155 -0
- tunacode/utils/system/paths.py +190 -0
- tunacode/utils/ui/__init__.py +9 -0
- tunacode/utils/ui/file_filter.py +135 -0
- tunacode/utils/ui/helpers.py +24 -0
- tunacode_cli-0.1.21.dist-info/METADATA +170 -0
- tunacode_cli-0.1.21.dist-info/RECORD +174 -0
- tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
- tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
- 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)
|