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,608 @@
1
+ """Dashboard screen for Autobots TUI."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.screen import Screen
5
+ from textual.widgets import Header, Footer, Input, Static
6
+ from textual.containers import Vertical, VerticalScroll
7
+ from textual.worker import Worker, WorkerState
8
+ from loguru import logger
9
+ import json
10
+ import asyncio
11
+ import re
12
+ import html
13
+
14
+
15
+ class DashboardScreen(Screen):
16
+ """Main dashboard screen showing overview and stats."""
17
+
18
+ DEFAULT_ACTION_ID = "69295914ccbcd104b2e7446f"
19
+
20
+ def __init__(self, *args, **kwargs):
21
+ """Initialize the dashboard screen."""
22
+ super().__init__(*args, **kwargs)
23
+ self.current_action_id = self.DEFAULT_ACTION_ID
24
+ self.current_run_id = None # Track the current conversation run_id
25
+ self.active_stream_tasks = [] # Track active SSE stream tasks to cancel them
26
+
27
+ CSS = """
28
+ DashboardScreen {
29
+ background: $surface;
30
+ }
31
+
32
+ #messages {
33
+ height: 1fr;
34
+ width: 100%;
35
+ }
36
+
37
+ .message {
38
+ width: 100%;
39
+ height: auto;
40
+ padding: 0 1;
41
+ margin: 0;
42
+ }
43
+
44
+ .user-message {
45
+ color: #00d4d4;
46
+ }
47
+
48
+ .assistant-message {
49
+ color: $text;
50
+ }
51
+
52
+ .error-message {
53
+ color: #ff5555;
54
+ }
55
+
56
+ .info-message {
57
+ color: #4db8e8;
58
+ text-style: italic;
59
+ }
60
+
61
+ #input-bar {
62
+ dock: bottom;
63
+ height: 3;
64
+ width: 100%;
65
+ }
66
+
67
+ Input {
68
+ width: 100%;
69
+ border: solid #00d4d4;
70
+ }
71
+
72
+ Input:focus {
73
+ border: solid #00ffff;
74
+ }
75
+ """
76
+
77
+ def compose(self) -> ComposeResult:
78
+ """Compose dashboard widgets."""
79
+ yield Header()
80
+
81
+ with VerticalScroll(id="messages"):
82
+ pass
83
+
84
+ with Vertical(id="input-bar"):
85
+ yield Input(placeholder="Message...", id="chat-input")
86
+
87
+ yield Footer()
88
+
89
+ def on_mount(self) -> None:
90
+ """Called when screen is mounted."""
91
+ self.query_one("#chat-input", Input).focus()
92
+
93
+ def on_input_submitted(self, event: Input.Submitted) -> None:
94
+ """Handle message submission."""
95
+ message = event.value.strip()
96
+ if not message:
97
+ return
98
+
99
+ # Clear input immediately for responsive feel
100
+ event.input.value = ""
101
+
102
+ # Handle "/" commands
103
+ if message.startswith("/"):
104
+ self.handle_slash_command(message)
105
+ return
106
+
107
+ # Add user message immediately to chat
108
+ self.add_message(f"You: {message}", "user")
109
+
110
+ # Process command asynchronously
111
+ self.process_message(message)
112
+
113
+ def handle_slash_command(self, command: str) -> None:
114
+ """Dispatch slash commands — TUI-specific first, then shared commands."""
115
+ parts = command.strip().split()
116
+ cmd = parts[0].lower()
117
+ args = parts[1:]
118
+
119
+ # --- TUI-specific commands ---
120
+
121
+ if cmd == "/cancel":
122
+ # Cancel the active stream worker and reset for new input
123
+ workers = self.workers
124
+ for worker in workers:
125
+ if not worker.is_finished:
126
+ worker.cancel()
127
+ self.add_message("Cancelled active request.", "info")
128
+ return
129
+
130
+ if cmd == "/new":
131
+ # Cancel any active stream first
132
+ for worker in self.workers:
133
+ if not worker.is_finished:
134
+ worker.cancel()
135
+ self.current_action_id = self.DEFAULT_ACTION_ID
136
+ self.current_run_id = None
137
+ self.add_message(f"Starting new conversation with default action ({self.DEFAULT_ACTION_ID})...", "info")
138
+ return
139
+
140
+ if cmd == "/use":
141
+ # /use <action_id> — switch active action
142
+ if not args:
143
+ self.add_message("Usage: /use <action_id>", "info")
144
+ return
145
+ action_id = args[0]
146
+ self.add_message(f"> /use {action_id}", "user")
147
+ # Validate action exists
148
+ api_client = self._get_api_client()
149
+ if not api_client:
150
+ return
151
+ from ..commands import actions_get
152
+ lines = actions_get(api_client, id=action_id)
153
+ if lines and lines[0].startswith("Error"):
154
+ self.add_message(f"Action not found: {action_id}", "error")
155
+ return
156
+ self.current_action_id = action_id
157
+ self.current_run_id = None
158
+ self.add_message("\n".join(["Switched to action:"] + lines), "info")
159
+ return
160
+
161
+ if cmd == "/continue":
162
+ # /continue <run_id> — attach to an existing run
163
+ if not args:
164
+ self.add_message("Usage: /continue <run_id>", "info")
165
+ return
166
+ run_id = args[0]
167
+ self.add_message(f"> /continue {run_id}", "user")
168
+ # Validate run exists and get its action_id
169
+ api_client = self._get_api_client()
170
+ if not api_client:
171
+ return
172
+ from ..commands import runs_get
173
+ lines = runs_get(api_client, id=run_id)
174
+ if lines and lines[0].startswith("Error"):
175
+ self.add_message(f"Run not found: {run_id}", "error")
176
+ return
177
+ self.current_run_id = run_id
178
+ self.add_message("\n".join(["Continuing run:"] + lines), "info")
179
+ return
180
+
181
+ if cmd == "/status":
182
+ # Show current action and run
183
+ self.add_message(f"> /status", "user")
184
+ info = [
185
+ f"Action ID: {self.current_action_id}",
186
+ f"Run ID: {self.current_run_id or '(none — next message starts new run)'}",
187
+ ]
188
+ # Fetch action name
189
+ api_client = self._get_api_client()
190
+ if api_client:
191
+ from ..commands import actions_get
192
+ action_lines = actions_get(api_client, id=self.current_action_id)
193
+ if action_lines and not action_lines[0].startswith("Error"):
194
+ info.append("")
195
+ info.extend(action_lines)
196
+ self.add_message("\n".join(info), "assistant")
197
+ return
198
+
199
+ # --- Shared commands (dispatch to commands.py) ---
200
+
201
+ self.add_message(f"> {command}", "user")
202
+
203
+ api_client = self._get_api_client()
204
+ if not api_client:
205
+ return
206
+
207
+ from ..commands import dispatch
208
+ try:
209
+ lines = dispatch(command, api_client)
210
+ self.add_message("\n".join(lines), "assistant")
211
+ except Exception as e:
212
+ self.add_message(f"Command error: {e}", "error")
213
+
214
+ def _get_api_client(self):
215
+ """Get the AuthenticatedClient for API calls. Returns None with error message if not available."""
216
+ from autobots_client import AuthenticatedClient
217
+
218
+ if not hasattr(self.app, 'autobots_client'):
219
+ self.add_message("Error: Client not initialized", "error")
220
+ return None
221
+
222
+ api_client = self.app.autobots_client.client
223
+ if not isinstance(api_client, AuthenticatedClient):
224
+ self.add_message("Error: Not authenticated", "error")
225
+ return None
226
+ return api_client
227
+
228
+ def add_message(self, text: str, msg_type: str = "assistant") -> None:
229
+ """Add a message to the chat."""
230
+ messages = self.query_one("#messages", VerticalScroll)
231
+ css_class = f"message {msg_type}-message"
232
+ messages.mount(Static(text, classes=css_class))
233
+ messages.scroll_end(animate=False)
234
+
235
+ def process_message(self, message: str) -> None:
236
+ """Process user message by running action."""
237
+ if not hasattr(self.app, 'autobots_client'):
238
+ self.add_message("Error: Client not initialized", "error")
239
+ return
240
+
241
+ # Run action async and poll for result
242
+ self.run_action_with_polling(message)
243
+
244
+ def run_action_with_polling(self, user_input: str) -> None:
245
+ """Run action and stream results via SSE."""
246
+ client = self.app.autobots_client
247
+
248
+ # Send the message to the action
249
+ # If continuing a conversation, pass the current_run_id as action_result_id
250
+ success, run_id, message = client.run_action_async(
251
+ self.current_action_id,
252
+ user_input,
253
+ action_result_id=self.current_run_id
254
+ )
255
+
256
+ if not success:
257
+ self.add_message(f"Error starting action: {message}", "error")
258
+ return
259
+
260
+ # Check if this is continuing an existing conversation
261
+ if self.current_run_id and run_id == self.current_run_id:
262
+ logger.info(f"Continuing conversation with run_id: {run_id}")
263
+ else:
264
+ # New conversation started
265
+ self.current_run_id = run_id
266
+ logger.info(f"Started new conversation with run_id: {run_id}")
267
+
268
+ # exclusive=True cancels any previous stream worker entirely
269
+ self.run_worker(self.stream_results(run_id), exclusive=True, group="stream")
270
+
271
+ async def stream_results(self, run_id: str) -> None:
272
+ """Stream action status and display final result from results array.
273
+
274
+ Runs SSE streaming and result-polling concurrently. Whichever
275
+ detects a terminal state first (success *or* error) wins.
276
+ """
277
+ client = self.app.autobots_client
278
+ got_final_result = False
279
+
280
+ # Track status widget for streaming transitional messages
281
+ status_widget_container = [None]
282
+
283
+ logger.info(f"Starting stream_results for {run_id}")
284
+
285
+ def _remove_status_widget() -> None:
286
+ """Remove the streaming status widget if it exists."""
287
+ if status_widget_container[0]:
288
+ try:
289
+ status_widget_container[0].remove()
290
+ status_widget_container[0] = None
291
+ except Exception as e:
292
+ logger.warning(f"Failed to remove status widget: {e}")
293
+
294
+ def _try_fetch_final_result() -> bool:
295
+ """Attempt to fetch and display the final result. Returns True on success/error."""
296
+ nonlocal got_final_result
297
+ if got_final_result:
298
+ return True
299
+ success, final_result, message = client.get_action_result(run_id)
300
+ if not success or not final_result:
301
+ return False
302
+
303
+ status = final_result.get("status", "").lower()
304
+ logger.info(f"Polled run status: {status}")
305
+
306
+ # Handle error/failed states
307
+ if status in ("error", "failed"):
308
+ logger.info(f"Run failed with status: {status}")
309
+ _remove_status_widget()
310
+ error_msg = final_result.get("message", "") or final_result.get("error", "") or "Action failed"
311
+ self.add_message(f"Error: {error_msg}", "error")
312
+ got_final_result = True
313
+ return True
314
+
315
+ # Only treat as complete if status indicates completion
316
+ if status not in ("completed", "success", "finished"):
317
+ return False
318
+
319
+ action_doc = final_result.get("result", {})
320
+ results_list = action_doc.get("results", []) if isinstance(action_doc, dict) else []
321
+ # Verify the last result actually has output
322
+ if results_list:
323
+ last = results_list[-1]
324
+ if isinstance(last, dict) and last.get("output"):
325
+ logger.info("Final result fetched successfully")
326
+ _remove_status_widget()
327
+ self.display_final_result(final_result)
328
+ got_final_result = True
329
+ return True
330
+ return False
331
+
332
+ def handle_status_message(data: dict) -> None:
333
+ """Handle SSE messages — status updates and completion signals."""
334
+ if not isinstance(data, dict) or got_final_result:
335
+ return
336
+
337
+ # Plain-text status messages (type="status" set by client.py)
338
+ if data.get("type") == "status":
339
+ text = data.get("text", "")
340
+ text_lower = text.lower()
341
+ logger.debug(f"SSE status: {text}")
342
+
343
+ # Show all non-empty status messages as progress
344
+ if text.strip():
345
+ status_widget_container[0] = self.update_streaming_message(
346
+ {"blocks": [{"text": text}]},
347
+ status_widget_container[0],
348
+ )
349
+
350
+ # Detect completion from text signals (server sends these as plain text)
351
+ if any(kw in text_lower for kw in ["finishing", "completed", "finished"]):
352
+ logger.info(f"Completion signal from SSE text: {text}")
353
+ _try_fetch_final_result()
354
+ return
355
+
356
+ # JSON status messages
357
+ status = data.get("status", "").lower()
358
+ if status:
359
+ logger.info(f"SSE JSON status: {status}")
360
+ if status in ["completed", "success", "finished", "error", "failed"]:
361
+ _try_fetch_final_result()
362
+
363
+ # ---- Concurrent polling task ----
364
+ async def _poll_until_done() -> None:
365
+ """Poll the run result every few seconds until it reaches a terminal state."""
366
+ while not got_final_result:
367
+ await asyncio.sleep(5)
368
+ if got_final_result:
369
+ return
370
+ try:
371
+ if _try_fetch_final_result():
372
+ return
373
+ except Exception as e:
374
+ logger.warning(f"Poll error: {e}")
375
+
376
+ poll_task = asyncio.create_task(_poll_until_done())
377
+
378
+ try:
379
+ status_task = asyncio.create_task(
380
+ asyncio.wait_for(
381
+ client.stream_action_result(run_id, handle_status_message),
382
+ timeout=300.0 # 5 minutes — avoids indefinite hangs
383
+ )
384
+ )
385
+
386
+ try:
387
+ # Wait for EITHER the SSE stream to end OR polling to find the result
388
+ done, pending = await asyncio.wait(
389
+ [status_task, poll_task],
390
+ return_when=asyncio.FIRST_COMPLETED,
391
+ )
392
+ # Cancel whichever is still running
393
+ for task in pending:
394
+ task.cancel()
395
+ try:
396
+ await task
397
+ except (asyncio.CancelledError, Exception):
398
+ pass
399
+ # Check for exceptions in completed tasks
400
+ for task in done:
401
+ if task.exception() and not isinstance(task.exception(), (asyncio.CancelledError, asyncio.TimeoutError)):
402
+ logger.error(f"Stream/poll error: {task.exception()}")
403
+ except asyncio.CancelledError:
404
+ logger.info(f"Stream cancelled for {run_id}")
405
+ status_task.cancel()
406
+ poll_task.cancel()
407
+ _remove_status_widget()
408
+ return
409
+ except asyncio.TimeoutError:
410
+ logger.warning(f"SSE timeout for {run_id}")
411
+ poll_task.cancel()
412
+ _remove_status_widget()
413
+ self.add_message("Action timed out waiting for response", "error")
414
+ return
415
+ except asyncio.CancelledError:
416
+ logger.info(f"Stream worker cancelled for {run_id}")
417
+ poll_task.cancel()
418
+ _remove_status_widget()
419
+ return
420
+ except Exception as e:
421
+ logger.error(f"SSE error for {run_id}: {e}")
422
+ poll_task.cancel()
423
+
424
+ # Always clean up status widget when SSE stream ends
425
+ _remove_status_widget()
426
+
427
+ # Final fallback — if neither SSE nor polling found the result
428
+ if not got_final_result:
429
+ logger.info(f"Final fallback poll for {run_id}")
430
+ _try_fetch_final_result()
431
+
432
+ if not got_final_result:
433
+ self.add_message("Could not get result. Use /new to start over.", "error")
434
+
435
+ def update_streaming_message(self, output: any, widget_ref: any = None) -> Static:
436
+ """Update or create a streaming message widget with new output.
437
+
438
+ Returns the widget being updated/created for future updates.
439
+ """
440
+ text_content = self.extract_text_from_output(output)
441
+ if not text_content:
442
+ return widget_ref
443
+
444
+ messages = self.query_one("#messages", VerticalScroll)
445
+
446
+ if widget_ref is None:
447
+ widget_ref = Static(text_content, classes="message assistant-message", markup=False, expand=True)
448
+ messages.mount(widget_ref)
449
+ else:
450
+ try:
451
+ widget_ref.update(text_content)
452
+ except Exception as e:
453
+ logger.warning(f"Failed to update widget: {e}")
454
+ widget_ref = Static(text_content, classes="message assistant-message", markup=False, expand=True)
455
+ messages.mount(widget_ref)
456
+
457
+ messages.scroll_end(animate=False)
458
+ return widget_ref
459
+
460
+ def extract_text_from_output(self, output: any) -> str:
461
+ """Extract text content from output blocks structure and clean it for display."""
462
+ if not isinstance(output, dict):
463
+ return ""
464
+
465
+ text_parts = []
466
+
467
+ if "blocks" in output:
468
+ blocks = output["blocks"]
469
+ if isinstance(blocks, list):
470
+ for block in blocks:
471
+ if isinstance(block, dict):
472
+ text = block.get("text", "").strip()
473
+ if text:
474
+ text_parts.append(self._clean_text_for_display(text))
475
+
476
+ elif "text" in output:
477
+ text = output["text"]
478
+ if text:
479
+ text = str(text) if not isinstance(text, str) else text
480
+ text_parts.append(self._clean_text_for_display(text))
481
+
482
+ return "\n".join(text_parts)
483
+
484
+ def _clean_text_for_display(self, text: str) -> str:
485
+ """Clean text for safe display in Textual (remove HTML, unescape entities, etc.)."""
486
+ if not text:
487
+ return ""
488
+
489
+ # Unescape HTML entities (e.g., &amp; -> &, &lt; -> <)
490
+ text = html.unescape(text)
491
+
492
+ # Remove HTML tags if present
493
+ text = re.sub(r'<[^>]+>', '', text)
494
+
495
+ # Unescape literal \n to actual newlines if present
496
+ if '\\n' in text:
497
+ text = text.replace('\\n', '\n')
498
+
499
+ return text.strip()
500
+
501
+ def display_final_result(self, result: dict) -> None:
502
+ """Display the final result from results array, showing only the latest output.
503
+
504
+ Args:
505
+ result: The result dictionary from get_action_result containing result.result.results[]
506
+ """
507
+ logger.info(f"Displaying final result from results array")
508
+
509
+ # Extract results array from result.result.results
510
+ if "result" not in result or not isinstance(result["result"], dict):
511
+ logger.warning(f"No result.result field found")
512
+ self.add_message("Action completed (no output)", "info")
513
+ return
514
+
515
+ action_doc = result["result"]
516
+ logger.info(f"ActionDoc keys: {action_doc.keys()}")
517
+
518
+ if "results" not in action_doc or not isinstance(action_doc["results"], list):
519
+ logger.warning(f"No results array found in ActionDoc")
520
+ self.add_message("Action completed (no output)", "info")
521
+ return
522
+
523
+ results_list = action_doc["results"]
524
+ logger.info(f"Found {len(results_list)} items in results array")
525
+
526
+ if len(results_list) == 0:
527
+ logger.warning(f"Results array is empty")
528
+ self.add_message("Action completed (no output)", "info")
529
+ return
530
+
531
+ # Only display the LAST result item's output.
532
+ # The results array contains full conversation history; the user's input
533
+ # was already shown when they typed it, so we only need the latest response.
534
+ last_result = results_list[-1]
535
+ if not isinstance(last_result, dict):
536
+ logger.warning(f"Last result is not a dict")
537
+ self.add_message("Action completed (no output)", "info")
538
+ return
539
+
540
+ logger.info(f"Processing last result, keys: {last_result.keys()}")
541
+
542
+ # Extract and display only the output (skip input — already shown)
543
+ if "output" in last_result and last_result["output"]:
544
+ output_data = last_result["output"]
545
+ output_text = self.extract_text_from_output(output_data)
546
+ if output_text:
547
+ logger.info(f"Last result output: {len(output_text)} chars, {len(output_text.splitlines())} lines")
548
+ self.add_message(output_text, "assistant")
549
+ else:
550
+ logger.warning(f"Last result output extraction returned empty")
551
+ self.add_message("Action completed (no output)", "info")
552
+ else:
553
+ logger.warning(f"Last result has no output field")
554
+ self.add_message("Action completed (no output)", "info")
555
+
556
+ def format_and_display_output(self, output: any) -> None:
557
+ """Format and display output, extracting text and files from blocks."""
558
+ if isinstance(output, dict):
559
+ # Check for blocks structure
560
+ if "blocks" in output:
561
+ blocks = output["blocks"]
562
+ if isinstance(blocks, list):
563
+ for block in blocks:
564
+ if isinstance(block, dict):
565
+ # Extract text
566
+ text = block.get("text", "").strip()
567
+ if text:
568
+ # Unescape unicode characters
569
+ text = text.encode().decode('unicode_escape')
570
+ self.add_message(text, "assistant")
571
+
572
+ # Extract files
573
+ files = block.get("files", [])
574
+ if files and isinstance(files, list):
575
+ for file_info in files:
576
+ if isinstance(file_info, dict):
577
+ file_name = file_info.get("name", "unnamed file")
578
+ self.add_message(f"📎 File: {file_name}", "info")
579
+ elif isinstance(file_info, str):
580
+ self.add_message(f"📎 File: {file_info}", "info")
581
+ return
582
+
583
+ # Check for direct text field
584
+ if "text" in output:
585
+ text = output["text"]
586
+ if text:
587
+ text = text.encode().decode('unicode_escape') if isinstance(text, str) else str(text)
588
+ self.add_message(text, "assistant")
589
+ return
590
+
591
+ # Fallback: display as-is
592
+ if isinstance(output, dict):
593
+ self.add_message(json.dumps(output, indent=2), "assistant")
594
+ else:
595
+ self.add_message(str(output), "assistant")
596
+
597
+ def update_last_assistant_message(self, text: str) -> None:
598
+ """Update the last assistant message with new text."""
599
+ messages = self.query_one("#messages", VerticalScroll)
600
+ assistant_messages = messages.query(".assistant-message")
601
+
602
+ if assistant_messages:
603
+ # Update the last assistant message
604
+ last_msg = assistant_messages[-1]
605
+ last_msg.update(text)
606
+ else:
607
+ # No existing message, create new one
608
+ self.add_message(text, "assistant")