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,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., & -> &, < -> <)
|
|
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")
|