vibecore 0.2.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.
Potentially problematic release.
This version of vibecore might be problematic. Click here for more details.
- vibecore/__init__.py +0 -0
- vibecore/agents/default.py +79 -0
- vibecore/agents/prompts.py +12 -0
- vibecore/agents/task_agent.py +66 -0
- vibecore/cli.py +150 -0
- vibecore/context.py +24 -0
- vibecore/handlers/__init__.py +5 -0
- vibecore/handlers/stream_handler.py +231 -0
- vibecore/main.py +506 -0
- vibecore/main.tcss +0 -0
- vibecore/mcp/__init__.py +6 -0
- vibecore/mcp/manager.py +167 -0
- vibecore/mcp/server_wrapper.py +109 -0
- vibecore/models/__init__.py +5 -0
- vibecore/models/anthropic.py +239 -0
- vibecore/prompts/common_system_prompt.txt +64 -0
- vibecore/py.typed +0 -0
- vibecore/session/__init__.py +5 -0
- vibecore/session/file_lock.py +127 -0
- vibecore/session/jsonl_session.py +236 -0
- vibecore/session/loader.py +193 -0
- vibecore/session/path_utils.py +81 -0
- vibecore/settings.py +161 -0
- vibecore/tools/__init__.py +1 -0
- vibecore/tools/base.py +27 -0
- vibecore/tools/file/__init__.py +5 -0
- vibecore/tools/file/executor.py +282 -0
- vibecore/tools/file/tools.py +184 -0
- vibecore/tools/file/utils.py +78 -0
- vibecore/tools/python/__init__.py +1 -0
- vibecore/tools/python/backends/__init__.py +1 -0
- vibecore/tools/python/backends/terminal_backend.py +58 -0
- vibecore/tools/python/helpers.py +80 -0
- vibecore/tools/python/manager.py +208 -0
- vibecore/tools/python/tools.py +27 -0
- vibecore/tools/shell/__init__.py +5 -0
- vibecore/tools/shell/executor.py +223 -0
- vibecore/tools/shell/tools.py +156 -0
- vibecore/tools/task/__init__.py +5 -0
- vibecore/tools/task/executor.py +51 -0
- vibecore/tools/task/tools.py +51 -0
- vibecore/tools/todo/__init__.py +1 -0
- vibecore/tools/todo/manager.py +31 -0
- vibecore/tools/todo/models.py +36 -0
- vibecore/tools/todo/tools.py +111 -0
- vibecore/utils/__init__.py +5 -0
- vibecore/utils/text.py +28 -0
- vibecore/widgets/core.py +332 -0
- vibecore/widgets/core.tcss +63 -0
- vibecore/widgets/expandable.py +121 -0
- vibecore/widgets/expandable.tcss +69 -0
- vibecore/widgets/info.py +25 -0
- vibecore/widgets/info.tcss +17 -0
- vibecore/widgets/messages.py +232 -0
- vibecore/widgets/messages.tcss +85 -0
- vibecore/widgets/tool_message_factory.py +121 -0
- vibecore/widgets/tool_messages.py +483 -0
- vibecore/widgets/tool_messages.tcss +289 -0
- vibecore-0.2.0.dist-info/METADATA +407 -0
- vibecore-0.2.0.dist-info/RECORD +63 -0
- vibecore-0.2.0.dist-info/WHEEL +4 -0
- vibecore-0.2.0.dist-info/entry_points.txt +2 -0
- vibecore-0.2.0.dist-info/licenses/LICENSE +21 -0
vibecore/widgets/core.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from textual import events
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
|
8
|
+
from textual.geometry import Size
|
|
9
|
+
from textual.message import Message
|
|
10
|
+
from textual.widget import Widget
|
|
11
|
+
from textual.widgets import Footer, ProgressBar, Static, TextArea
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InputBox(Widget):
|
|
15
|
+
"""A simple input box widget."""
|
|
16
|
+
|
|
17
|
+
def compose(self) -> ComposeResult:
|
|
18
|
+
"""Create child widgets for the input box."""
|
|
19
|
+
text_area = MyTextArea(compact=True, id="input-textarea")
|
|
20
|
+
yield Static(">", id="input-label")
|
|
21
|
+
yield text_area
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AppFooter(Widget):
|
|
25
|
+
def get_current_working_directory(self) -> str:
|
|
26
|
+
"""Get the current working directory for display.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The current working directory path, with home directory replaced by ~
|
|
30
|
+
"""
|
|
31
|
+
cwd = os.getcwd()
|
|
32
|
+
if cwd.startswith(os.path.expanduser("~")):
|
|
33
|
+
cwd = cwd.replace(os.path.expanduser("~"), "~", 1)
|
|
34
|
+
return cwd
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
yield LoadingWidget(status="Generating…", id="loading-widget")
|
|
38
|
+
yield InputBox()
|
|
39
|
+
# Wrap ProgressBar in vertical container to dock it right
|
|
40
|
+
with Vertical(id="context-info"):
|
|
41
|
+
cwd = self.get_current_working_directory()
|
|
42
|
+
yield Static(f"{cwd}", id="context-cwd")
|
|
43
|
+
with Horizontal(id="context-progress-container"):
|
|
44
|
+
yield Static("Context: ", id="context-progress-label")
|
|
45
|
+
yield ProgressBar(total=100, id="context-progress", show_eta=False)
|
|
46
|
+
yield Footer()
|
|
47
|
+
|
|
48
|
+
def set_context_progress(self, percent: float) -> None:
|
|
49
|
+
bar = self.query_one("#context-progress", ProgressBar)
|
|
50
|
+
value = max(0, min(100, int(percent * 100)))
|
|
51
|
+
bar.update(total=100, progress=value)
|
|
52
|
+
|
|
53
|
+
def on_mount(self) -> None:
|
|
54
|
+
"""Hide loading widget on mount."""
|
|
55
|
+
self.query_one("#loading-widget").display = False
|
|
56
|
+
|
|
57
|
+
def show_loading(self, status: str = "Generating…", metadata: str = "") -> None:
|
|
58
|
+
"""Show the loading widget with given status and metadata."""
|
|
59
|
+
loading = self.query_one("#loading-widget", LoadingWidget)
|
|
60
|
+
loading._start_time = time.monotonic() # Reset the timer
|
|
61
|
+
loading.update_status(status)
|
|
62
|
+
if metadata:
|
|
63
|
+
loading.update_metadata(metadata)
|
|
64
|
+
loading.display = True
|
|
65
|
+
|
|
66
|
+
def hide_loading(self) -> None:
|
|
67
|
+
"""Hide the loading widget."""
|
|
68
|
+
self.query_one("#loading-widget").display = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MyTextArea(TextArea):
|
|
72
|
+
class UserMessage(Message):
|
|
73
|
+
"""A user message input."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, text: str) -> None:
|
|
76
|
+
self.text = text
|
|
77
|
+
super().__init__()
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"UserMessage(text={self.text!r})"
|
|
81
|
+
|
|
82
|
+
def __init__(self, **kwargs) -> None:
|
|
83
|
+
"""Initialize MyTextArea with history tracking."""
|
|
84
|
+
super().__init__(**kwargs)
|
|
85
|
+
self.history_index = -1 # -1 means we're typing a new message
|
|
86
|
+
self.draft_text = "" # Store the draft when navigating history
|
|
87
|
+
|
|
88
|
+
async def get_user_history(self) -> list[str]:
|
|
89
|
+
"""Get the list of user messages from the session and current input_items."""
|
|
90
|
+
from vibecore.main import VibecoreApp
|
|
91
|
+
|
|
92
|
+
app = self.app
|
|
93
|
+
if isinstance(app, VibecoreApp):
|
|
94
|
+
history = []
|
|
95
|
+
|
|
96
|
+
# First, load history from the session
|
|
97
|
+
if app.session:
|
|
98
|
+
try:
|
|
99
|
+
# Get all items from the session
|
|
100
|
+
session_items = await app.session.get_items()
|
|
101
|
+
for item in session_items:
|
|
102
|
+
# Filter for user messages
|
|
103
|
+
if isinstance(item, dict):
|
|
104
|
+
# Check both "role" and "type" fields for compatibility
|
|
105
|
+
role = item.get("role") or item.get("type")
|
|
106
|
+
if role == "user":
|
|
107
|
+
content = item.get("content")
|
|
108
|
+
if isinstance(content, str):
|
|
109
|
+
history.append(content)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
# Log error but don't crash
|
|
112
|
+
from textual import log
|
|
113
|
+
|
|
114
|
+
log(f"Error loading session history: {e}")
|
|
115
|
+
|
|
116
|
+
# Then add current session's messages (in memory, not yet persisted)
|
|
117
|
+
for item in app.input_items:
|
|
118
|
+
if isinstance(item, dict) and item.get("role") == "user":
|
|
119
|
+
content = item.get("content")
|
|
120
|
+
if isinstance(content, str) and content not in history:
|
|
121
|
+
history.append(content)
|
|
122
|
+
|
|
123
|
+
return history
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
127
|
+
"""Check if an action may run."""
|
|
128
|
+
# If text is empty, allow app to handle ctrl+d, not as delete_right
|
|
129
|
+
return not (action == "delete_right" and not self.text)
|
|
130
|
+
|
|
131
|
+
async def _on_key(self, event: events.Key) -> None:
|
|
132
|
+
if event.key == "enter":
|
|
133
|
+
self.post_message(self.UserMessage(self.text))
|
|
134
|
+
self.text = ""
|
|
135
|
+
self.history_index = -1 # Reset history navigation
|
|
136
|
+
self.draft_text = ""
|
|
137
|
+
event.prevent_default()
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Handle up arrow for history navigation
|
|
141
|
+
if event.key == "up":
|
|
142
|
+
if not self.cursor_at_start_of_text:
|
|
143
|
+
# Move cursor to start of text first
|
|
144
|
+
await super()._on_key(event)
|
|
145
|
+
return
|
|
146
|
+
else:
|
|
147
|
+
# Navigate to previous history item
|
|
148
|
+
history = await self.get_user_history()
|
|
149
|
+
if history:
|
|
150
|
+
# Save current draft if starting history navigation
|
|
151
|
+
if self.history_index == -1:
|
|
152
|
+
self.draft_text = self.text
|
|
153
|
+
|
|
154
|
+
# Move to previous item
|
|
155
|
+
if self.history_index < len(history) - 1:
|
|
156
|
+
self.history_index += 1
|
|
157
|
+
self.text = history[-(self.history_index + 1)]
|
|
158
|
+
self.move_cursor((0, 0)) # Move cursor to start
|
|
159
|
+
event.prevent_default()
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# Handle down arrow for history navigation
|
|
163
|
+
elif event.key == "down":
|
|
164
|
+
if not self.cursor_at_end_of_text:
|
|
165
|
+
# Move cursor to end of text first
|
|
166
|
+
await super()._on_key(event)
|
|
167
|
+
return
|
|
168
|
+
else:
|
|
169
|
+
# Navigate to next history item
|
|
170
|
+
if self.history_index >= 0:
|
|
171
|
+
self.history_index -= 1
|
|
172
|
+
if self.history_index == -1:
|
|
173
|
+
# Return to draft
|
|
174
|
+
self.text = self.draft_text
|
|
175
|
+
else:
|
|
176
|
+
history = await self.get_user_history()
|
|
177
|
+
self.text = history[-(self.history_index + 1)]
|
|
178
|
+
# Move cursor to end of text
|
|
179
|
+
last_line = self.document.line_count - 1
|
|
180
|
+
last_column = len(self.document[last_line]) if last_line >= 0 else 0
|
|
181
|
+
self.move_cursor((last_line, last_column))
|
|
182
|
+
event.prevent_default()
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
self._restart_blink()
|
|
186
|
+
|
|
187
|
+
if self.read_only:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
key = event.key
|
|
191
|
+
insert_values = {
|
|
192
|
+
"shift+enter": "\n",
|
|
193
|
+
# Ghostty with config: keybind = shift+enter=text:\n
|
|
194
|
+
"ctrl+j": "\n",
|
|
195
|
+
}
|
|
196
|
+
if self.tab_behavior == "indent":
|
|
197
|
+
if key == "escape":
|
|
198
|
+
event.stop()
|
|
199
|
+
event.prevent_default()
|
|
200
|
+
self.screen.focus_next()
|
|
201
|
+
return
|
|
202
|
+
if self.indent_type == "tabs":
|
|
203
|
+
insert_values["tab"] = "\t"
|
|
204
|
+
else:
|
|
205
|
+
insert_values["tab"] = " " * self._find_columns_to_next_tab_stop()
|
|
206
|
+
|
|
207
|
+
if event.is_printable or key in insert_values:
|
|
208
|
+
event.stop()
|
|
209
|
+
event.prevent_default()
|
|
210
|
+
insert = insert_values.get(key, event.character)
|
|
211
|
+
# `insert` is not None because event.character cannot be
|
|
212
|
+
# None because we've checked that it's printable.
|
|
213
|
+
assert insert is not None
|
|
214
|
+
start, end = self.selection
|
|
215
|
+
self._replace_via_keyboard(insert, start, end)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class MainScroll(ScrollableContainer):
|
|
219
|
+
"""A container with vertical layout and an automatic scrollbar on the Y axis."""
|
|
220
|
+
|
|
221
|
+
def watch_virtual_size(self, size: Size) -> None:
|
|
222
|
+
"""Scroll to the bottom when resized = when new content is added."""
|
|
223
|
+
# If the scroll is near the end, keep the scroll sticky to the end
|
|
224
|
+
epsilon = 30
|
|
225
|
+
in_the_end = (size.height - (self.scroll_target_y + self.scrollable_size.height)) < epsilon
|
|
226
|
+
if size.height > self.scrollable_size.height and in_the_end:
|
|
227
|
+
self.scroll_end(animate=False)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class LoadingWidget(Widget):
|
|
231
|
+
"""A loading indicator with spinner, status text, and metadata."""
|
|
232
|
+
|
|
233
|
+
DEFAULT_CSS = """
|
|
234
|
+
LoadingWidget {
|
|
235
|
+
width: 1fr;
|
|
236
|
+
height: 1;
|
|
237
|
+
padding: 0 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
LoadingWidget .loading-spinner {
|
|
241
|
+
color: $primary;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
LoadingWidget .loading-status {
|
|
245
|
+
color: $text;
|
|
246
|
+
margin: 0 1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
LoadingWidget .loading-metadata {
|
|
250
|
+
color: $text-muted;
|
|
251
|
+
}
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
SPINNERS: ClassVar[list[str]] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self,
|
|
258
|
+
status: str = "Loading…",
|
|
259
|
+
show_time: bool = True,
|
|
260
|
+
show_metadata: bool = True,
|
|
261
|
+
metadata: str = "",
|
|
262
|
+
escape_message: str = "esc to interrupt",
|
|
263
|
+
**kwargs,
|
|
264
|
+
) -> None:
|
|
265
|
+
super().__init__(**kwargs)
|
|
266
|
+
self.status = status
|
|
267
|
+
self.show_time = show_time
|
|
268
|
+
self.show_metadata = show_metadata
|
|
269
|
+
self.metadata = metadata
|
|
270
|
+
self.escape_message = escape_message
|
|
271
|
+
self._spinner_index = 0
|
|
272
|
+
self._start_time = time.monotonic()
|
|
273
|
+
self._spinner_timer = None
|
|
274
|
+
|
|
275
|
+
def compose(self) -> ComposeResult:
|
|
276
|
+
yield Static("", id="loading-content")
|
|
277
|
+
|
|
278
|
+
def on_mount(self) -> None:
|
|
279
|
+
"""Start the spinner animation when mounted."""
|
|
280
|
+
self._spinner_timer = self.set_interval(0.1, self._update_spinner)
|
|
281
|
+
self._update_display()
|
|
282
|
+
|
|
283
|
+
def on_unmount(self) -> None:
|
|
284
|
+
"""Stop the spinner animation when unmounted."""
|
|
285
|
+
if self._spinner_timer:
|
|
286
|
+
self._spinner_timer.stop()
|
|
287
|
+
|
|
288
|
+
def _update_spinner(self) -> None:
|
|
289
|
+
"""Update the spinner character and elapsed time."""
|
|
290
|
+
self._spinner_index = (self._spinner_index + 1) % len(self.SPINNERS)
|
|
291
|
+
self._update_display()
|
|
292
|
+
|
|
293
|
+
def _update_display(self) -> None:
|
|
294
|
+
"""Update the entire loading display."""
|
|
295
|
+
parts = []
|
|
296
|
+
|
|
297
|
+
# Spinner
|
|
298
|
+
spinner = self.SPINNERS[self._spinner_index]
|
|
299
|
+
parts.append(f"[bold]{spinner}[/bold]")
|
|
300
|
+
|
|
301
|
+
# Status text
|
|
302
|
+
parts.append(self.status)
|
|
303
|
+
|
|
304
|
+
# Metadata section
|
|
305
|
+
metadata_parts = []
|
|
306
|
+
|
|
307
|
+
if self.show_time:
|
|
308
|
+
elapsed = int(time.monotonic() - self._start_time)
|
|
309
|
+
metadata_parts.append(f"{elapsed}s")
|
|
310
|
+
|
|
311
|
+
if self.show_metadata and self.metadata:
|
|
312
|
+
metadata_parts.append(self.metadata)
|
|
313
|
+
|
|
314
|
+
if self.escape_message:
|
|
315
|
+
metadata_parts.append(self.escape_message)
|
|
316
|
+
|
|
317
|
+
if metadata_parts:
|
|
318
|
+
metadata_str = " · ".join(metadata_parts)
|
|
319
|
+
parts.append(f"[dim]({metadata_str})[/dim]")
|
|
320
|
+
|
|
321
|
+
content = " ".join(parts)
|
|
322
|
+
self.query_one("#loading-content", Static).update(content)
|
|
323
|
+
|
|
324
|
+
def update_status(self, status: str) -> None:
|
|
325
|
+
"""Update the status text."""
|
|
326
|
+
self.status = status
|
|
327
|
+
self._update_display()
|
|
328
|
+
|
|
329
|
+
def update_metadata(self, metadata: str) -> None:
|
|
330
|
+
"""Update the metadata text."""
|
|
331
|
+
self.metadata = metadata
|
|
332
|
+
self._update_display()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
AppFooter {
|
|
2
|
+
dock: bottom;
|
|
3
|
+
height: auto;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
Footer {
|
|
7
|
+
dock: none;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#context-info {
|
|
11
|
+
height: 2;
|
|
12
|
+
color: $text-primary;
|
|
13
|
+
|
|
14
|
+
#context-cwd {
|
|
15
|
+
margin-left: 1;
|
|
16
|
+
height: 1;
|
|
17
|
+
width: auto;
|
|
18
|
+
dock: left;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#context-progress-container {
|
|
22
|
+
margin-right: 1;
|
|
23
|
+
height: auto;
|
|
24
|
+
width: auto;
|
|
25
|
+
dock: right;
|
|
26
|
+
layout: horizontal;
|
|
27
|
+
|
|
28
|
+
#context-progress-label {
|
|
29
|
+
width: auto;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
InputBox {
|
|
35
|
+
height: auto;
|
|
36
|
+
layout: horizontal;
|
|
37
|
+
|
|
38
|
+
#input-label {
|
|
39
|
+
width: 3;
|
|
40
|
+
height: 1;
|
|
41
|
+
text-align: center;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
TextArea {
|
|
45
|
+
width: 1fr;
|
|
46
|
+
height: auto;
|
|
47
|
+
max-height: 10;
|
|
48
|
+
min-height: 1;
|
|
49
|
+
border: none;
|
|
50
|
+
padding: 0 0;
|
|
51
|
+
background: $background;
|
|
52
|
+
& .text-area--cursor-line {
|
|
53
|
+
background: $background;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
border: round $border-blurred;
|
|
58
|
+
&:focus-within {
|
|
59
|
+
border: round $border;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# background: $surface;
|
|
63
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Expandable content widgets for Textual applications."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.content import Content
|
|
5
|
+
from textual.events import Click
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
from textual.widgets import Markdown, Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExpandableContent(Widget):
|
|
12
|
+
"""A widget that shows truncated content with an expandable button."""
|
|
13
|
+
|
|
14
|
+
expanded: reactive[bool] = reactive(False, recompose=True)
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self, content: str | Content, truncated_lines: int = 3, collapsed_text: str | Content | None = None, **kwargs
|
|
18
|
+
) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Initialize the ExpandableContent widget.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
content: The full content to display (str or Content for safe rendering)
|
|
24
|
+
truncated_lines: Number of lines to show when collapsed (ignored if collapsed_text is provided)
|
|
25
|
+
collapsed_text: Custom text to show when collapsed (overrides truncated content)
|
|
26
|
+
**kwargs: Additional keyword arguments for Widget
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(**kwargs)
|
|
29
|
+
self.content = content
|
|
30
|
+
self.truncated_lines = truncated_lines
|
|
31
|
+
self.collapsed_text = collapsed_text
|
|
32
|
+
# Extract plain text for line counting
|
|
33
|
+
self.content_str = str(content) if isinstance(content, Content) else content
|
|
34
|
+
self.lines = self.content_str.splitlines()
|
|
35
|
+
self.total_lines = len(self.lines)
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
"""Create child widgets based on expanded state."""
|
|
39
|
+
if self.expanded:
|
|
40
|
+
# Show all content
|
|
41
|
+
yield Static(self.content, classes="expandable-content-full")
|
|
42
|
+
yield Static("▲ collapse", classes="expandable-toggle expanded")
|
|
43
|
+
else:
|
|
44
|
+
# Show custom collapsed text if provided
|
|
45
|
+
if self.collapsed_text is not None:
|
|
46
|
+
yield Static(self.collapsed_text, classes="expandable-toggle collapsed")
|
|
47
|
+
# Show truncated content
|
|
48
|
+
elif self.total_lines > self.truncated_lines:
|
|
49
|
+
truncated_text = "\n".join(self.lines[: self.truncated_lines])
|
|
50
|
+
# Preserve Content safety if original was Content
|
|
51
|
+
truncated_content = Content(truncated_text) if isinstance(self.content, Content) else truncated_text
|
|
52
|
+
yield Static(truncated_content, classes="expandable-content-truncated")
|
|
53
|
+
remaining_lines = self.total_lines - self.truncated_lines
|
|
54
|
+
yield Static(f"… +{remaining_lines} more lines (view)", classes="expandable-toggle collapsed")
|
|
55
|
+
else:
|
|
56
|
+
# If content fits, just show it all
|
|
57
|
+
yield Static(self.content, classes="expandable-content-full")
|
|
58
|
+
|
|
59
|
+
def on_click(self, event: Click) -> None:
|
|
60
|
+
"""Handle click events to toggle expansion."""
|
|
61
|
+
# Only toggle if we clicked on the toggle element
|
|
62
|
+
if event.widget and event.widget.has_class("expandable-toggle"):
|
|
63
|
+
self.expanded = not self.expanded
|
|
64
|
+
event.stop()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ExpandableMarkdown(Widget):
|
|
68
|
+
"""A widget that shows truncated Markdown content with an expandable button."""
|
|
69
|
+
|
|
70
|
+
expanded: reactive[bool] = reactive(False, recompose=True)
|
|
71
|
+
|
|
72
|
+
def __init__(self, code: str, language: str = "python", truncated_lines: int = 8, **kwargs) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Initialize the ExpandableMarkdown widget.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
code: The full code to display
|
|
78
|
+
language: Programming language for syntax highlighting (empty string for plain markdown)
|
|
79
|
+
truncated_lines: Number of lines to show when collapsed
|
|
80
|
+
**kwargs: Additional keyword arguments for Widget
|
|
81
|
+
"""
|
|
82
|
+
super().__init__(**kwargs)
|
|
83
|
+
self.code = code
|
|
84
|
+
self.language = language
|
|
85
|
+
self.truncated_lines = truncated_lines
|
|
86
|
+
self.lines = code.splitlines()
|
|
87
|
+
self.total_lines = len(self.lines)
|
|
88
|
+
|
|
89
|
+
def compose(self) -> ComposeResult:
|
|
90
|
+
"""Create child widgets based on expanded state."""
|
|
91
|
+
# Determine content format based on language
|
|
92
|
+
if self.language:
|
|
93
|
+
# Render as code block with syntax highlighting
|
|
94
|
+
full_content = f"```{self.language}\n{self.code}\n```"
|
|
95
|
+
truncated_lines = "\n".join(self.lines[: self.truncated_lines])
|
|
96
|
+
truncated_content = f"```{self.language}\n{truncated_lines}\n```"
|
|
97
|
+
else:
|
|
98
|
+
# Render as plain markdown (no code block)
|
|
99
|
+
full_content = self.code
|
|
100
|
+
truncated_content = "\n".join(self.lines[: self.truncated_lines])
|
|
101
|
+
|
|
102
|
+
if self.expanded:
|
|
103
|
+
# Show all content
|
|
104
|
+
yield Markdown(full_content, classes="expandable-markdown-full")
|
|
105
|
+
yield Static("▲ collapse", classes="expandable-toggle expanded")
|
|
106
|
+
else:
|
|
107
|
+
# Show truncated content
|
|
108
|
+
if self.total_lines > self.truncated_lines:
|
|
109
|
+
yield Markdown(truncated_content, classes="expandable-markdown-truncated")
|
|
110
|
+
remaining_lines = self.total_lines - self.truncated_lines
|
|
111
|
+
yield Static(f"… +{remaining_lines} more lines (view)", classes="expandable-toggle collapsed")
|
|
112
|
+
else:
|
|
113
|
+
# If content fits, just show it all
|
|
114
|
+
yield Markdown(full_content, classes="expandable-markdown-full")
|
|
115
|
+
|
|
116
|
+
def on_click(self, event: Click) -> None:
|
|
117
|
+
"""Handle click events to toggle expansion."""
|
|
118
|
+
# Only toggle if we clicked on the toggle element
|
|
119
|
+
if event.widget and event.widget.has_class("expandable-toggle"):
|
|
120
|
+
self.expanded = not self.expanded
|
|
121
|
+
event.stop()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* Expandable Content Widget Styles */
|
|
2
|
+
ExpandableContent {
|
|
3
|
+
height: auto;
|
|
4
|
+
|
|
5
|
+
.expandable-content-truncated, .expandable-content-full {
|
|
6
|
+
padding: 0;
|
|
7
|
+
margin: 0;
|
|
8
|
+
height: auto;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.expandable-toggle {
|
|
12
|
+
padding: 0 0 0 0;
|
|
13
|
+
margin: 0 0 0 0;
|
|
14
|
+
width: auto;
|
|
15
|
+
|
|
16
|
+
&:hover {
|
|
17
|
+
color: $primary;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
&.collapsed {
|
|
21
|
+
margin-top: 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&.expanded {
|
|
25
|
+
margin-top: 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Expandable Markdown Widget Styles */
|
|
31
|
+
ExpandableMarkdown {
|
|
32
|
+
height: auto;
|
|
33
|
+
width: 1fr;
|
|
34
|
+
|
|
35
|
+
Markdown {
|
|
36
|
+
background: $background;
|
|
37
|
+
padding: 0;
|
|
38
|
+
|
|
39
|
+
MarkdownFence {
|
|
40
|
+
margin: 0;
|
|
41
|
+
padding: 0;
|
|
42
|
+
max-height: 100%;
|
|
43
|
+
|
|
44
|
+
&> Static {
|
|
45
|
+
# Override MarkdownFence > Static (Syntax) padding/margin
|
|
46
|
+
width: 1fr; # Trigger word-wrap instead of overflow
|
|
47
|
+
padding: 0 2 0 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.expandable-toggle {
|
|
53
|
+
padding: 0 0 0 2;
|
|
54
|
+
margin: 0 0 0 0;
|
|
55
|
+
width: auto;
|
|
56
|
+
|
|
57
|
+
&:hover {
|
|
58
|
+
color: $primary;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
&.collapsed {
|
|
62
|
+
margin-top: 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
&.expanded {
|
|
66
|
+
margin-top: 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
vibecore/widgets/info.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.widget import Widget
|
|
3
|
+
from textual.widgets import Static
|
|
4
|
+
|
|
5
|
+
VIBECORE_LOGO = """
|
|
6
|
+
██╗ ██╗██╗██████╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
7
|
+
██║ ██║██║██╔══██╗██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
|
8
|
+
██║ ██║██║██████╔╝█████╗ ██║ ██║ ██║██████╔╝█████╗
|
|
9
|
+
╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██║ ██║ ██║██╔══██╗██╔══╝
|
|
10
|
+
╚████╔╝ ██║██████╔╝███████╗╚██████╗╚██████╔╝██║ ██║███████╗
|
|
11
|
+
╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝
|
|
12
|
+
""".strip()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Welcome(Widget):
|
|
16
|
+
"""A simple input box widget."""
|
|
17
|
+
|
|
18
|
+
def compose(self) -> ComposeResult:
|
|
19
|
+
"""Create child widgets for the input box."""
|
|
20
|
+
yield Static(f"[$primary]{VIBECORE_LOGO}[/]", classes="logo")
|
|
21
|
+
yield Static("Welcome to [$text-primary][b]Vibecore[/b][/]!", classes="title")
|
|
22
|
+
yield Static(
|
|
23
|
+
"Type '/help' to see available commands.",
|
|
24
|
+
classes="subtitle",
|
|
25
|
+
)
|