kiwi-code 0.0.4__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.
- kiwi_code-0.0.4.dist-info/METADATA +234 -0
- kiwi_code-0.0.4.dist-info/RECORD +24 -0
- kiwi_code-0.0.4.dist-info/WHEEL +4 -0
- kiwi_code-0.0.4.dist-info/entry_points.txt +4 -0
- kiwi_runtime/__init__.py +3 -0
- kiwi_runtime/__main__.py +5 -0
- kiwi_runtime/main.py +989 -0
- kiwi_tui/__init__.py +3 -0
- kiwi_tui/auth.py +125 -0
- kiwi_tui/cli.py +243 -0
- kiwi_tui/client.py +539 -0
- kiwi_tui/commands.py +434 -0
- kiwi_tui/config.py +79 -0
- kiwi_tui/logger.py +32 -0
- kiwi_tui/main.py +337 -0
- kiwi_tui/models.py +85 -0
- kiwi_tui/runtime_manager.py +130 -0
- kiwi_tui/screens/__init__.py +9 -0
- kiwi_tui/screens/actions.py +271 -0
- kiwi_tui/screens/autobots.py +216 -0
- kiwi_tui/screens/dashboard.py +608 -0
- kiwi_tui/screens/login.py +320 -0
- kiwi_tui/screens/runtime_logs.py +96 -0
- kiwi_tui/widgets.py +197 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Login screen for Autobots TUI."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.screen import Screen
|
|
5
|
+
from textual.containers import Container, Vertical, Center, Horizontal
|
|
6
|
+
from textual.widgets import Header, Footer, Static, Input, Button
|
|
7
|
+
from textual.validation import Length
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from ..models import LoginCredentials
|
|
12
|
+
from ..widgets import ActionButton
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoginScreen(Screen):
|
|
16
|
+
"""Screen for user authentication."""
|
|
17
|
+
|
|
18
|
+
AUTO_FOCUS = None # Prevent automatic focus changes
|
|
19
|
+
|
|
20
|
+
CSS = """
|
|
21
|
+
LoginScreen {
|
|
22
|
+
background: $surface;
|
|
23
|
+
layout: vertical;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#login-container {
|
|
27
|
+
width: 100%;
|
|
28
|
+
height: 1fr;
|
|
29
|
+
min-height: 20;
|
|
30
|
+
border: none;
|
|
31
|
+
background: transparent;
|
|
32
|
+
padding: 4 8;
|
|
33
|
+
overflow-y: auto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#logo-container {
|
|
37
|
+
width: 100%;
|
|
38
|
+
height: auto;
|
|
39
|
+
margin-bottom: 2;
|
|
40
|
+
content-align: left top;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#logo {
|
|
44
|
+
color: #5fffff; /* Cyan-ish */
|
|
45
|
+
width: auto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#logo-text-container {
|
|
49
|
+
margin-left: 2;
|
|
50
|
+
height: auto;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#logo-text {
|
|
54
|
+
color: #5fffff;
|
|
55
|
+
text-style: bold;
|
|
56
|
+
height: auto;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#logo-subtitle {
|
|
60
|
+
color: #5fffff;
|
|
61
|
+
height: auto;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.divider {
|
|
65
|
+
width: 100%;
|
|
66
|
+
height: 1;
|
|
67
|
+
border-bottom: solid #444444;
|
|
68
|
+
margin-bottom: 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.info-row {
|
|
72
|
+
height: auto;
|
|
73
|
+
margin-bottom: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.info-label {
|
|
77
|
+
color: #5fffff;
|
|
78
|
+
width: auto;
|
|
79
|
+
margin-right: 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.info-value {
|
|
83
|
+
color: $text;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.section-header {
|
|
87
|
+
color: #5fffff;
|
|
88
|
+
text-style: bold;
|
|
89
|
+
margin-top: 2;
|
|
90
|
+
margin-bottom: 1;
|
|
91
|
+
border-bottom: solid #444444;
|
|
92
|
+
width: 40;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.input-prompt {
|
|
96
|
+
color: #5fffff;
|
|
97
|
+
width: auto;
|
|
98
|
+
margin-right: 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.form-group {
|
|
102
|
+
layout: horizontal;
|
|
103
|
+
height: 1;
|
|
104
|
+
margin: 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
Input {
|
|
108
|
+
width: 40;
|
|
109
|
+
background: transparent;
|
|
110
|
+
border: none;
|
|
111
|
+
padding: 0;
|
|
112
|
+
height: 1;
|
|
113
|
+
color: $text;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
Input:focus {
|
|
117
|
+
border: none;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#status-message {
|
|
121
|
+
margin-top: 1;
|
|
122
|
+
height: auto;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.status-line {
|
|
126
|
+
height: auto;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.status-symbol-success {
|
|
130
|
+
color: $success;
|
|
131
|
+
margin-right: 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.status-symbol-pending {
|
|
135
|
+
color: #5fffff;
|
|
136
|
+
margin-right: 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.status-symbol-error {
|
|
140
|
+
color: $error;
|
|
141
|
+
margin-right: 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#button-container {
|
|
145
|
+
display: none; /* Hide button to match sleek look */
|
|
146
|
+
}
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
BINDINGS = [
|
|
150
|
+
("escape", "quit", "Quit"),
|
|
151
|
+
("ctrl+c", "quit", "Quit"),
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
def compose(self) -> ComposeResult:
|
|
155
|
+
"""Compose login screen widgets."""
|
|
156
|
+
yield Header()
|
|
157
|
+
|
|
158
|
+
with Container(id="login-container"):
|
|
159
|
+
# Logo Section
|
|
160
|
+
with Horizontal(id="logo-container"):
|
|
161
|
+
logo_ascii = (
|
|
162
|
+
" #\n"
|
|
163
|
+
" #####\n"
|
|
164
|
+
" #########\n"
|
|
165
|
+
" ###########\n"
|
|
166
|
+
"#############\n"
|
|
167
|
+
" ###########\n"
|
|
168
|
+
" #########\n"
|
|
169
|
+
" #####\n"
|
|
170
|
+
" #"
|
|
171
|
+
)
|
|
172
|
+
yield Static(logo_ascii, id="logo")
|
|
173
|
+
with Vertical(id="logo-text-container"):
|
|
174
|
+
yield Static("KIWI AI", id="logo-text")
|
|
175
|
+
yield Static("Terminal Agent for AI-Powered Automation", id="logo-subtitle")
|
|
176
|
+
|
|
177
|
+
yield Static("", classes="divider")
|
|
178
|
+
|
|
179
|
+
# Environment Info
|
|
180
|
+
with Horizontal(classes="info-row"):
|
|
181
|
+
yield Static("> Server:", classes="info-label")
|
|
182
|
+
yield Static(f"app ({self.app.config.backend_url})", classes="info-value")
|
|
183
|
+
|
|
184
|
+
with Horizontal(classes="info-row"):
|
|
185
|
+
yield Static("> Mode:", classes="info-label")
|
|
186
|
+
yield Static("restricted", classes="info-value")
|
|
187
|
+
|
|
188
|
+
yield Static("Authentication", classes="section-header")
|
|
189
|
+
|
|
190
|
+
with Horizontal(classes="form-group"):
|
|
191
|
+
yield Static("> Email:", classes="input-prompt")
|
|
192
|
+
yield Input(
|
|
193
|
+
placeholder="email@example.com",
|
|
194
|
+
id="username-input",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
with Horizontal(classes="form-group"):
|
|
198
|
+
yield Static("> Password:", classes="input-prompt")
|
|
199
|
+
yield Input(
|
|
200
|
+
placeholder="••••••••",
|
|
201
|
+
password=True,
|
|
202
|
+
id="password-input",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
yield Static("", id="status-message")
|
|
206
|
+
|
|
207
|
+
with Center(id="button-container"):
|
|
208
|
+
yield ActionButton("Sign In", variant="success", id="btn-login")
|
|
209
|
+
|
|
210
|
+
yield Footer()
|
|
211
|
+
|
|
212
|
+
def on_mount(self) -> None:
|
|
213
|
+
"""Called when screen is mounted."""
|
|
214
|
+
logger.info("Login screen mounted")
|
|
215
|
+
# Focus on username input after a brief delay to ensure DOM is ready
|
|
216
|
+
try:
|
|
217
|
+
username_input = self.query_one("#username-input", Input)
|
|
218
|
+
self.set_timer(0.1, lambda: username_input.focus())
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.error(f"Error focusing username input: {e}")
|
|
221
|
+
|
|
222
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
223
|
+
"""Handle button press events."""
|
|
224
|
+
if event.button.id == "btn-login":
|
|
225
|
+
self.action_login()
|
|
226
|
+
|
|
227
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
228
|
+
"""Handle input submission (Enter key)."""
|
|
229
|
+
if event.input.id == "username-input":
|
|
230
|
+
# Move to password field
|
|
231
|
+
self.query_one("#password-input", Input).focus()
|
|
232
|
+
elif event.input.id == "password-input":
|
|
233
|
+
# Submit login
|
|
234
|
+
self.action_login()
|
|
235
|
+
|
|
236
|
+
def action_login(self) -> None:
|
|
237
|
+
"""Attempt to login with provided credentials."""
|
|
238
|
+
username_input = self.query_one("#username-input", Input)
|
|
239
|
+
password_input = self.query_one("#password-input", Input)
|
|
240
|
+
status_widget = self.query_one("#status-message", Static)
|
|
241
|
+
|
|
242
|
+
username = username_input.value.strip()
|
|
243
|
+
password = password_input.value
|
|
244
|
+
|
|
245
|
+
# Validate inputs
|
|
246
|
+
if not username:
|
|
247
|
+
status_widget.update("~ Please enter your email")
|
|
248
|
+
status_widget.styles.color = "red"
|
|
249
|
+
username_input.focus()
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
if not password:
|
|
253
|
+
status_widget.update("~ Please enter your password")
|
|
254
|
+
status_widget.styles.color = "red"
|
|
255
|
+
password_input.focus()
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
# Show loading state
|
|
259
|
+
status_widget.styles.color = "#5fffff"
|
|
260
|
+
status_widget.update(f"~ Authenticating with {self.app.config.backend_url}...")
|
|
261
|
+
|
|
262
|
+
logger.info(f"Login attempt for user: {username}")
|
|
263
|
+
|
|
264
|
+
# Create credentials
|
|
265
|
+
credentials = LoginCredentials(username=username, password=password)
|
|
266
|
+
|
|
267
|
+
# Call login through app
|
|
268
|
+
if hasattr(self.app, 'autobots_client'):
|
|
269
|
+
success, tokens, message = self.app.autobots_client.login(credentials)
|
|
270
|
+
|
|
271
|
+
if success and tokens:
|
|
272
|
+
status_widget.update("> Login successful!")
|
|
273
|
+
status_widget.styles.color = "green"
|
|
274
|
+
logger.info(f"Login successful for user: {username}")
|
|
275
|
+
|
|
276
|
+
# Save tokens
|
|
277
|
+
if hasattr(self.app, 'token_manager'):
|
|
278
|
+
self.app.token_manager.save_tokens(tokens)
|
|
279
|
+
|
|
280
|
+
# Share token with kiwi CLI session and start it
|
|
281
|
+
self.app._kiwi_token = tokens.access_token
|
|
282
|
+
self.app._start_kiwi_session()
|
|
283
|
+
|
|
284
|
+
# Notify app of successful login
|
|
285
|
+
self.app.post_message(LoginSuccess(tokens))
|
|
286
|
+
|
|
287
|
+
# Switch to dashboard
|
|
288
|
+
self.app.switch_screen("dashboard")
|
|
289
|
+
else:
|
|
290
|
+
error_msg = f"~ {message}"
|
|
291
|
+
status_widget.update(error_msg)
|
|
292
|
+
status_widget.styles.color = "red"
|
|
293
|
+
logger.error(f"Login failed for user {username}: {message}")
|
|
294
|
+
password_input.value = ""
|
|
295
|
+
password_input.focus()
|
|
296
|
+
else:
|
|
297
|
+
error_msg = "~ Client not initialized"
|
|
298
|
+
status_widget.update(error_msg)
|
|
299
|
+
status_widget.styles.color = "red"
|
|
300
|
+
logger.error("Autobots client not found in app")
|
|
301
|
+
|
|
302
|
+
def action_quit(self) -> None:
|
|
303
|
+
"""Quit the application."""
|
|
304
|
+
logger.info("Quitting application from login screen")
|
|
305
|
+
self.app.exit()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class LoginSuccess(Message):
|
|
309
|
+
"""Message sent when login is successful."""
|
|
310
|
+
|
|
311
|
+
bubbles = True
|
|
312
|
+
|
|
313
|
+
def __init__(self, tokens):
|
|
314
|
+
"""Initialize login success message.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
tokens: Authentication tokens
|
|
318
|
+
"""
|
|
319
|
+
super().__init__()
|
|
320
|
+
self.tokens = tokens
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual.screen import Screen
|
|
8
|
+
from textual.widgets import Footer, Header, RichLog, Static
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RuntimeLogsScreen(Screen):
|
|
13
|
+
"""Viewer for the persistent kiwi runtime process.
|
|
14
|
+
|
|
15
|
+
The kiwi runtime runs independently of the TUI (survives restarts).
|
|
16
|
+
This screen displays buffered log output from the runtime log file.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
("escape", "go_back", "Back"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
CSS = """
|
|
24
|
+
RuntimeLogsScreen {
|
|
25
|
+
background: $surface;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#runtime-log {
|
|
29
|
+
height: 1fr;
|
|
30
|
+
width: 100%;
|
|
31
|
+
scrollbar-size: 1 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#runtime-log-missing {
|
|
35
|
+
padding: 2 4;
|
|
36
|
+
color: #ff5555;
|
|
37
|
+
}
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def compose(self):
|
|
41
|
+
yield Header()
|
|
42
|
+
has_runtime = getattr(self.app, "_kiwi_pid", None) is not None
|
|
43
|
+
if not has_runtime:
|
|
44
|
+
yield Static(
|
|
45
|
+
"No runtime session active.\n\n"
|
|
46
|
+
"Kiwi runtime not started.\n"
|
|
47
|
+
"Login first to auto-start the terminal agent,\n"
|
|
48
|
+
"or run: kiwi-runtime connect --server app",
|
|
49
|
+
id="runtime-log-missing",
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
yield RichLog(id="runtime-log", wrap=True, markup=False)
|
|
53
|
+
yield Footer()
|
|
54
|
+
|
|
55
|
+
def on_mount(self) -> None:
|
|
56
|
+
has_runtime = getattr(self.app, "_kiwi_pid", None) is not None
|
|
57
|
+
if has_runtime:
|
|
58
|
+
self._replay_buffer()
|
|
59
|
+
self.set_interval(0.1, self._poll_kiwi_lines)
|
|
60
|
+
|
|
61
|
+
def action_go_back(self) -> None:
|
|
62
|
+
"""Go back without affecting the runtime process."""
|
|
63
|
+
self.app.pop_screen()
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Kiwi log output display
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _replay_buffer(self) -> None:
|
|
70
|
+
"""Write all buffered lines to the RichLog widget."""
|
|
71
|
+
try:
|
|
72
|
+
log = self.query_one("#runtime-log", RichLog)
|
|
73
|
+
except Exception:
|
|
74
|
+
return
|
|
75
|
+
lines = list(self.app._kiwi_log_lines)
|
|
76
|
+
for line in lines:
|
|
77
|
+
log.write(Text.from_ansi(line))
|
|
78
|
+
self._lines_displayed = len(self.app._kiwi_log_lines)
|
|
79
|
+
|
|
80
|
+
def _poll_kiwi_lines(self) -> None:
|
|
81
|
+
"""Display any new lines that arrived since last poll."""
|
|
82
|
+
total = len(self.app._kiwi_log_lines)
|
|
83
|
+
if total <= self._lines_displayed:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
log = self.query_one("#runtime-log", RichLog)
|
|
87
|
+
except Exception:
|
|
88
|
+
return
|
|
89
|
+
all_lines = list(self.app._kiwi_log_lines)
|
|
90
|
+
if self._lines_displayed >= total:
|
|
91
|
+
self._lines_displayed = total
|
|
92
|
+
return
|
|
93
|
+
new_lines = all_lines[-(total - self._lines_displayed):]
|
|
94
|
+
for line in new_lines:
|
|
95
|
+
log.write(Text.from_ansi(line))
|
|
96
|
+
self._lines_displayed = total
|
kiwi_tui/widgets.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Reusable widgets for Autobots TUI."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Container, Vertical, Horizontal
|
|
5
|
+
from textual.widgets import Static, Button, Label, DataTable
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StatusBadge(Static):
|
|
11
|
+
"""A colored status badge widget."""
|
|
12
|
+
|
|
13
|
+
DEFAULT_CSS = """
|
|
14
|
+
StatusBadge {
|
|
15
|
+
width: auto;
|
|
16
|
+
height: 1;
|
|
17
|
+
padding: 0 1;
|
|
18
|
+
margin: 0 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
StatusBadge.success {
|
|
22
|
+
background: $success;
|
|
23
|
+
color: $text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
StatusBadge.error {
|
|
27
|
+
background: $error;
|
|
28
|
+
color: $text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
StatusBadge.warning {
|
|
32
|
+
background: $warning;
|
|
33
|
+
color: $text;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
StatusBadge.info {
|
|
37
|
+
background: $accent;
|
|
38
|
+
color: $text;
|
|
39
|
+
}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, label: str, variant: str = "info", **kwargs):
|
|
43
|
+
"""Initialize status badge.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
label: Text to display
|
|
47
|
+
variant: Color variant (success, error, warning, info)
|
|
48
|
+
"""
|
|
49
|
+
super().__init__(label, **kwargs)
|
|
50
|
+
self.add_class(variant)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class StatCard(Container):
|
|
54
|
+
"""A card displaying a statistic."""
|
|
55
|
+
|
|
56
|
+
DEFAULT_CSS = """
|
|
57
|
+
StatCard {
|
|
58
|
+
width: 1fr;
|
|
59
|
+
height: auto;
|
|
60
|
+
border: none;
|
|
61
|
+
padding: 0;
|
|
62
|
+
margin: 0 2;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
StatCard > .stat-value {
|
|
66
|
+
text-align: left;
|
|
67
|
+
text-style: bold;
|
|
68
|
+
color: #5fffff;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
StatCard > .stat-label {
|
|
72
|
+
text-align: left;
|
|
73
|
+
color: $text-muted;
|
|
74
|
+
}
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
value: reactive[str] = reactive("", init=False)
|
|
78
|
+
label: reactive[str] = reactive("", init=False)
|
|
79
|
+
|
|
80
|
+
def __init__(self, label: str, value: str = "0", **kwargs):
|
|
81
|
+
"""Initialize stat card.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
label: Description of the stat
|
|
85
|
+
value: Current value
|
|
86
|
+
"""
|
|
87
|
+
super().__init__(**kwargs)
|
|
88
|
+
self._initial_label = label
|
|
89
|
+
self._initial_value = value
|
|
90
|
+
|
|
91
|
+
def compose(self) -> ComposeResult:
|
|
92
|
+
"""Compose stat card widgets."""
|
|
93
|
+
yield Static(self._initial_value, classes="stat-value")
|
|
94
|
+
yield Static(self._initial_label, classes="stat-label")
|
|
95
|
+
|
|
96
|
+
def on_mount(self) -> None:
|
|
97
|
+
"""Called when widget is mounted."""
|
|
98
|
+
self.label = self._initial_label
|
|
99
|
+
self.value = self._initial_value
|
|
100
|
+
|
|
101
|
+
def watch_value(self, value: str) -> None:
|
|
102
|
+
"""Update displayed value."""
|
|
103
|
+
if self.is_mounted:
|
|
104
|
+
self.query_one(".stat-value", Static).update(value)
|
|
105
|
+
|
|
106
|
+
def watch_label(self, label: str) -> None:
|
|
107
|
+
"""Update displayed label."""
|
|
108
|
+
if self.is_mounted:
|
|
109
|
+
self.query_one(".stat-label", Static).update(label)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ActionButton(Button):
|
|
113
|
+
"""A styled action button."""
|
|
114
|
+
|
|
115
|
+
DEFAULT_CSS = """
|
|
116
|
+
ActionButton {
|
|
117
|
+
min-width: 16;
|
|
118
|
+
margin: 0 1;
|
|
119
|
+
border: none;
|
|
120
|
+
background: $surface;
|
|
121
|
+
color: $text;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
ActionButton:focus {
|
|
125
|
+
background: #5fffff;
|
|
126
|
+
color: black;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
ActionButton.primary {
|
|
130
|
+
color: #5fffff;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ActionButton.success {
|
|
134
|
+
color: $success;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
ActionButton.danger {
|
|
138
|
+
color: $error;
|
|
139
|
+
}
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, label: str, variant: str = "primary", **kwargs):
|
|
143
|
+
"""Initialize action button.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
label: Button text
|
|
147
|
+
variant: Style variant (primary, success, danger)
|
|
148
|
+
"""
|
|
149
|
+
super().__init__(label, **kwargs)
|
|
150
|
+
self.add_class(variant)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class InfoPanel(Container):
|
|
154
|
+
"""An information panel with title and content."""
|
|
155
|
+
|
|
156
|
+
DEFAULT_CSS = """
|
|
157
|
+
InfoPanel {
|
|
158
|
+
height: auto;
|
|
159
|
+
border: none;
|
|
160
|
+
padding: 0;
|
|
161
|
+
margin: 1 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
InfoPanel > .panel-title {
|
|
165
|
+
text-style: bold;
|
|
166
|
+
color: #5fffff;
|
|
167
|
+
margin-bottom: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
InfoPanel > .panel-content {
|
|
171
|
+
color: $text;
|
|
172
|
+
}
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, title: str, content: str = "", **kwargs):
|
|
176
|
+
"""Initialize info panel.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
title: Panel title
|
|
180
|
+
content: Panel content
|
|
181
|
+
"""
|
|
182
|
+
super().__init__(**kwargs)
|
|
183
|
+
self._title = title
|
|
184
|
+
self._content = content
|
|
185
|
+
|
|
186
|
+
def compose(self) -> ComposeResult:
|
|
187
|
+
"""Compose info panel widgets."""
|
|
188
|
+
yield Static(self._title, classes="panel-title")
|
|
189
|
+
yield Static(self._content, classes="panel-content")
|
|
190
|
+
|
|
191
|
+
def update_content(self, content: str) -> None:
|
|
192
|
+
"""Update panel content.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
content: New content to display
|
|
196
|
+
"""
|
|
197
|
+
self.query_one(".panel-content", Static).update(content)
|