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.
@@ -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)