kiwi-code 0.0.26__tar.gz → 0.0.27__tar.gz
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.26 → kiwi_code-0.0.27}/PKG-INFO +2 -1
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/pyproject.toml +3 -1
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/main.py +2 -0
- kiwi_code-0.0.27/src/kiwi_tui/random_words.py +80 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/dashboard.py +421 -58
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/uv.lock +12 -1
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/.gitignore +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/.python-version +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/CLAUDE.md +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/Makefile +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/README.md +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/test_hello.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/__init__.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/conftest.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_tui_headless.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kiwi-code
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.27
|
|
4
4
|
Summary: A textual-based terminal user interface application
|
|
5
5
|
Project-URL: Homepage, https://meetkiwi.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
|
|
@@ -25,6 +25,7 @@ Requires-Dist: textual-dev>=1.8.0
|
|
|
25
25
|
Requires-Dist: textual>=8.1.1
|
|
26
26
|
Requires-Dist: typer>=0.24.1
|
|
27
27
|
Requires-Dist: websockets>=14.1
|
|
28
|
+
Requires-Dist: wonderwords>=2.2.0
|
|
28
29
|
Description-Content-Type: text/markdown
|
|
29
30
|
|
|
30
31
|
# Kiwi Code
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kiwi-code"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.27"
|
|
4
4
|
description = "A textual-based terminal user interface application"
|
|
5
5
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
6
6
|
requires-python = ">=3.11,<4.0"
|
|
@@ -15,7 +15,9 @@ dependencies = [
|
|
|
15
15
|
"httpx>=0.25.0",
|
|
16
16
|
"psutil>=5.9.0",
|
|
17
17
|
"setproctitle>=1.3.0",
|
|
18
|
+
"wonderwords>=2.2.0",
|
|
18
19
|
]
|
|
20
|
+
|
|
19
21
|
authors = [
|
|
20
22
|
{ name = "Anurag Jha", email = "anurag@meetkiwi.co" }
|
|
21
23
|
]
|
|
@@ -281,6 +281,8 @@ class AutobotsTUI(App):
|
|
|
281
281
|
# Chat UX: full-width highlight color for user-message rows.
|
|
282
282
|
# Keep it subtle and theme-aware.
|
|
283
283
|
variables["user-msg-bg"] = "#333333" if self._is_dark_theme() else "#e8e8e8"
|
|
284
|
+
# Chat UX: subtle highlight for the *active streaming* assistant message.
|
|
285
|
+
variables["assistant-stream-bg"] = "#222222" if self._is_dark_theme() else "#f2f2f2"
|
|
284
286
|
# Scrollbars: cyan thumb, dark track.
|
|
285
287
|
variables["scrollbar"] = cyan
|
|
286
288
|
variables["scrollbar-hover"] = cyan
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@lru_cache(maxsize=1)
|
|
5
|
+
def _ww() -> object | None:
|
|
6
|
+
try:
|
|
7
|
+
from wonderwords import RandomWord # type: ignore
|
|
8
|
+
|
|
9
|
+
return RandomWord()
|
|
10
|
+
except Exception:
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _to_present_participle(verb: str) -> str:
|
|
15
|
+
v = (verb or "").strip()
|
|
16
|
+
if not v:
|
|
17
|
+
return ""
|
|
18
|
+
w = v.lower()
|
|
19
|
+
|
|
20
|
+
# Common irregulars / spelling rules that a simple suffix approach misses.
|
|
21
|
+
irregular = {
|
|
22
|
+
"be": "being",
|
|
23
|
+
"see": "seeing",
|
|
24
|
+
"flee": "fleeing",
|
|
25
|
+
"knee": "kneeing",
|
|
26
|
+
"die": "dying",
|
|
27
|
+
"tie": "tying",
|
|
28
|
+
"lie": "lying",
|
|
29
|
+
}
|
|
30
|
+
if w in irregular:
|
|
31
|
+
return irregular[w].capitalize()
|
|
32
|
+
|
|
33
|
+
# ie -> ying (die -> dying).
|
|
34
|
+
if w.endswith("ie") and len(w) > 2:
|
|
35
|
+
return (w[:-2] + "ying").capitalize()
|
|
36
|
+
|
|
37
|
+
# Drop trailing e (make -> making), but keep ee/ye/oe (see -> seeing).
|
|
38
|
+
if w.endswith("e") and not w.endswith(("ee", "ye", "oe")) and len(w) > 1:
|
|
39
|
+
return (w[:-1] + "ing").capitalize()
|
|
40
|
+
|
|
41
|
+
# Verbs ending in 'ic' often add 'k' (panic -> panicking).
|
|
42
|
+
if w.endswith("ic") and len(w) > 2:
|
|
43
|
+
return (w + "king").capitalize()
|
|
44
|
+
|
|
45
|
+
vowels = set("aeiou")
|
|
46
|
+
# Double final consonant for short CVC words with a/i/o/u vowel (run -> running).
|
|
47
|
+
if (
|
|
48
|
+
3 <= len(w) <= 4
|
|
49
|
+
and w[-1] not in vowels
|
|
50
|
+
and w[-1] not in "wxy"
|
|
51
|
+
and w[-2] in vowels
|
|
52
|
+
and w[-2] != "e"
|
|
53
|
+
and w[-3] not in vowels
|
|
54
|
+
):
|
|
55
|
+
return (w + w[-1] + "ing").capitalize()
|
|
56
|
+
|
|
57
|
+
return (w + "ing").capitalize()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def random_verb(*, default: str = "Thinking") -> str:
|
|
61
|
+
"""Return a random present-participle verb for display (e.g. "Searching")."""
|
|
62
|
+
rw = _ww()
|
|
63
|
+
if rw is None:
|
|
64
|
+
return default
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
# wonderwords supports parts_of_speech filtering.
|
|
68
|
+
word = rw.word(include_parts_of_speech=["verbs"]) # type: ignore[attr-defined]
|
|
69
|
+
word = str(word or "").strip()
|
|
70
|
+
if not word:
|
|
71
|
+
return default
|
|
72
|
+
ing = _to_present_participle(word)
|
|
73
|
+
return ing if ing else default
|
|
74
|
+
except Exception:
|
|
75
|
+
return default
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Backward-compat alias (older code used adjectives).
|
|
79
|
+
def random_adjective(*, default: str = "Curious") -> str: # pragma: no cover
|
|
80
|
+
return default
|
|
@@ -25,6 +25,8 @@ import time
|
|
|
25
25
|
import re
|
|
26
26
|
import html
|
|
27
27
|
from pathlib import Path
|
|
28
|
+
from kiwi_tui.random_words import random_verb
|
|
29
|
+
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
class UserMessageRow(Horizontal):
|
|
@@ -41,16 +43,42 @@ class UserMessageRow(Horizontal):
|
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
class AssistantMessageRow(Horizontal):
|
|
44
|
-
"""A single assistant message rendered with a left dot + markdown body.
|
|
46
|
+
"""A single assistant message rendered with a left dot + markdown body.
|
|
47
|
+
|
|
48
|
+
While streaming, shows a footer line under the message:
|
|
49
|
+
[Kiwi] <verb> <spinner>
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
Requirement: the footer is visible only while streaming and disappears when
|
|
52
|
+
the final output is available.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
markdown_text: str,
|
|
58
|
+
*,
|
|
59
|
+
verb: str | None = None,
|
|
60
|
+
streaming: bool = False,
|
|
61
|
+
classes: str = "",
|
|
62
|
+
) -> None:
|
|
47
63
|
super().__init__(classes=classes)
|
|
48
64
|
self._markdown_text = markdown_text
|
|
65
|
+
self._verb = (verb or random_verb()).strip() or "Thinking"
|
|
66
|
+
self._streaming = bool(streaming)
|
|
67
|
+
if self._streaming:
|
|
68
|
+
self.add_class("streaming")
|
|
49
69
|
|
|
50
70
|
def compose(self) -> ComposeResult:
|
|
51
71
|
# Solid dot marker on the left side of the model response.
|
|
52
72
|
yield Static("●", classes="assistant-dot", markup=False)
|
|
53
|
-
|
|
73
|
+
|
|
74
|
+
with Vertical(classes="assistant-content"):
|
|
75
|
+
# Message body comes first (user request: streaming message above).
|
|
76
|
+
yield Markdown(self._markdown_text, classes="assistant-body")
|
|
77
|
+
|
|
78
|
+
# Footer comes after the body, and is only visible while streaming.
|
|
79
|
+
with Horizontal(classes="assistant-footer"):
|
|
80
|
+
yield Static(f"[Kiwi] {self._verb}", classes="assistant-name", markup=False)
|
|
81
|
+
yield Static("⣾", classes="assistant-spinner", markup=False)
|
|
54
82
|
|
|
55
83
|
def update_markdown(self, markdown_text: str) -> None:
|
|
56
84
|
"""Update the markdown content in-place."""
|
|
@@ -61,13 +89,42 @@ class AssistantMessageRow(Horizontal):
|
|
|
61
89
|
# Defensive: if the widget tree isn't ready or the child was removed.
|
|
62
90
|
pass
|
|
63
91
|
|
|
92
|
+
def set_streaming(self, streaming: bool) -> None:
|
|
93
|
+
"""Toggle streaming visual state (footer visibility + blink target)."""
|
|
94
|
+
self._streaming = bool(streaming)
|
|
95
|
+
if self._streaming:
|
|
96
|
+
self.add_class("streaming")
|
|
97
|
+
else:
|
|
98
|
+
self.remove_class("streaming")
|
|
99
|
+
# Ensure spinner is cleared when not streaming.
|
|
100
|
+
self.set_spinner_frame("")
|
|
101
|
+
|
|
102
|
+
def set_blink_dim(self, dim: bool) -> None:
|
|
103
|
+
"""Dim/undim the dot to create a blink effect."""
|
|
104
|
+
try:
|
|
105
|
+
dot = self.query_one(".assistant-dot", Static)
|
|
106
|
+
if dim:
|
|
107
|
+
dot.add_class("blink-dim")
|
|
108
|
+
else:
|
|
109
|
+
dot.remove_class("blink-dim")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def set_spinner_frame(self, frame: str) -> None:
|
|
114
|
+
"""Update the spinner glyph (shown only while streaming)."""
|
|
115
|
+
try:
|
|
116
|
+
sp = self.query_one(".assistant-spinner", Static)
|
|
117
|
+
sp.update(frame or "")
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
64
121
|
# Footer hint for inserting a newline differs by OS:
|
|
65
122
|
# - macOS terminals often don't forward modified Enter combos to terminal apps reliably, so we advertise Ctrl+N.
|
|
66
123
|
# - Windows terminals tend to support Shift+Enter for multi-line input.
|
|
67
124
|
NEWLINE_HINT_BINDING = (
|
|
68
|
-
Binding("ctrl+n", "hint_newline", "
|
|
125
|
+
Binding("ctrl+n", "hint_newline", "newline (Mac)", show=True, priority=True)
|
|
69
126
|
if sys.platform == "darwin"
|
|
70
|
-
else Binding("shift+enter", "hint_newline", "
|
|
127
|
+
else Binding("shift+enter", "hint_newline", "newline (Win)", show=True, priority=True)
|
|
71
128
|
)
|
|
72
129
|
|
|
73
130
|
class DashboardScreen(Screen):
|
|
@@ -96,6 +153,15 @@ class DashboardScreen(Screen):
|
|
|
96
153
|
self._metadata: dict = {} # Persistent metadata for all subsequent runs
|
|
97
154
|
self._is_streaming: bool = False # Track streaming state for send/stop toggle
|
|
98
155
|
|
|
156
|
+
# UI helpers for streaming UX: blinking dot + spinner on the active assistant row.
|
|
157
|
+
self._streaming_widget_ref: AssistantMessageRow | None = None
|
|
158
|
+
self._blink_timer = None
|
|
159
|
+
self._blink_dim: bool = False
|
|
160
|
+
self._spinner_timer = None
|
|
161
|
+
self._spinner_i: int = 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
99
165
|
self._cmd_running: bool = False # True while a /command is executing
|
|
100
166
|
|
|
101
167
|
|
|
@@ -219,14 +285,58 @@ class DashboardScreen(Screen):
|
|
|
219
285
|
padding: 0 1;
|
|
220
286
|
}
|
|
221
287
|
|
|
288
|
+
/* Grayish background to indicate "in progress" assistant output. */
|
|
289
|
+
.assistant-message.streaming {
|
|
290
|
+
background: $assistant-stream-bg;
|
|
291
|
+
}
|
|
292
|
+
|
|
222
293
|
.assistant-dot {
|
|
223
|
-
|
|
294
|
+
/* Slightly larger + bolder to improve visibility (requested). */
|
|
295
|
+
width: 4;
|
|
224
296
|
padding: 0 1 0 0;
|
|
225
297
|
color: $brand-cyan;
|
|
226
298
|
text-style: bold;
|
|
227
299
|
content-align: center top;
|
|
228
300
|
}
|
|
229
301
|
|
|
302
|
+
/* Blink effect (we toggle this class in code while streaming). */
|
|
303
|
+
.assistant-dot.blink-dim {
|
|
304
|
+
opacity: 0.15;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.assistant-content {
|
|
308
|
+
width: 1fr;
|
|
309
|
+
/* Critical: keep each assistant row sized to its content (no 1fr stretching). */
|
|
310
|
+
height: auto;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.assistant-footer {
|
|
314
|
+
width: 1fr;
|
|
315
|
+
height: auto;
|
|
316
|
+
background: transparent;
|
|
317
|
+
padding: 0;
|
|
318
|
+
/* Hidden unless the row is actively streaming. */
|
|
319
|
+
display: none;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.assistant-message.streaming .assistant-footer {
|
|
323
|
+
display: block;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.assistant-name {
|
|
327
|
+
width: auto;
|
|
328
|
+
color: $brand-cyan;
|
|
329
|
+
text-style: bold;
|
|
330
|
+
padding: 0 1 0 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.assistant-spinner {
|
|
334
|
+
width: auto;
|
|
335
|
+
height: 1;
|
|
336
|
+
color: $brand-cyan;
|
|
337
|
+
text-style: bold;
|
|
338
|
+
}
|
|
339
|
+
|
|
230
340
|
.assistant-body {
|
|
231
341
|
width: 1fr;
|
|
232
342
|
}
|
|
@@ -235,6 +345,7 @@ class DashboardScreen(Screen):
|
|
|
235
345
|
margin: 0;
|
|
236
346
|
padding: 0;
|
|
237
347
|
width: 1fr;
|
|
348
|
+
background: transparent;
|
|
238
349
|
}
|
|
239
350
|
|
|
240
351
|
.error-message {
|
|
@@ -1571,7 +1682,9 @@ class DashboardScreen(Screen):
|
|
|
1571
1682
|
css_class = f"message {msg_type}-message"
|
|
1572
1683
|
if msg_type == "assistant":
|
|
1573
1684
|
prepared = self._prepare_markdown(text)
|
|
1574
|
-
messages.mount(
|
|
1685
|
+
messages.mount(
|
|
1686
|
+
AssistantMessageRow(prepared, classes=css_class)
|
|
1687
|
+
)
|
|
1575
1688
|
elif msg_type == "user":
|
|
1576
1689
|
# Render a styled "YOU" label without enabling markup (keeps input safe).
|
|
1577
1690
|
messages.mount(UserMessageRow(str(text), classes=css_class))
|
|
@@ -1625,12 +1738,125 @@ class DashboardScreen(Screen):
|
|
|
1625
1738
|
pass
|
|
1626
1739
|
self._apply_blocking_state()
|
|
1627
1740
|
|
|
1741
|
+
def _get_active_streaming_row(self) -> AssistantMessageRow | None:
|
|
1742
|
+
"""Best-effort: find the assistant row currently marked as streaming.
|
|
1743
|
+
|
|
1744
|
+
This makes the spinner resilient even if we temporarily lose the stored
|
|
1745
|
+
widget reference (e.g., stream reconnects / DOM changes).
|
|
1746
|
+
"""
|
|
1747
|
+
try:
|
|
1748
|
+
messages = self.query_one("#messages", VerticalScroll)
|
|
1749
|
+
# Query returns widgets in document order; we want the most recent one.
|
|
1750
|
+
candidates = list(messages.query(".assistant-message"))
|
|
1751
|
+
for w in reversed(candidates):
|
|
1752
|
+
if isinstance(w, AssistantMessageRow) and w.has_class("streaming"):
|
|
1753
|
+
return w
|
|
1754
|
+
except Exception:
|
|
1755
|
+
return None
|
|
1756
|
+
return None
|
|
1757
|
+
|
|
1758
|
+
def _blink_streaming_dot_tick(self) -> None:
|
|
1759
|
+
"""Timer tick: blink the dot for the active streaming assistant row."""
|
|
1760
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
1761
|
+
if not isinstance(w, AssistantMessageRow) or not w.has_class("streaming"):
|
|
1762
|
+
w = self._get_active_streaming_row()
|
|
1763
|
+
if w:
|
|
1764
|
+
self._streaming_widget_ref = w
|
|
1765
|
+
if not w:
|
|
1766
|
+
return
|
|
1767
|
+
try:
|
|
1768
|
+
self._blink_dim = not bool(getattr(self, "_blink_dim", False))
|
|
1769
|
+
w.set_blink_dim(self._blink_dim)
|
|
1770
|
+
except Exception:
|
|
1771
|
+
pass
|
|
1772
|
+
|
|
1773
|
+
# Watchdog: if spinner timer stopped for any reason, restart it while streaming.
|
|
1774
|
+
try:
|
|
1775
|
+
if self._is_streaming and getattr(self, "_spinner_timer", None) is None:
|
|
1776
|
+
self._spinner_i = int(getattr(self, "_spinner_i", 0) or 0)
|
|
1777
|
+
self._spinner_timer = self.set_interval(0.12, self._spinner_tick)
|
|
1778
|
+
except Exception:
|
|
1779
|
+
pass
|
|
1780
|
+
|
|
1781
|
+
_SPINNER_FRAMES: tuple[str, ...] = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷")
|
|
1782
|
+
|
|
1783
|
+
def _spinner_tick(self) -> None:
|
|
1784
|
+
"""Timer tick: animate the spinner for the active streaming assistant row."""
|
|
1785
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
1786
|
+
if not isinstance(w, AssistantMessageRow) or not w.has_class("streaming"):
|
|
1787
|
+
w = self._get_active_streaming_row()
|
|
1788
|
+
if w:
|
|
1789
|
+
self._streaming_widget_ref = w
|
|
1790
|
+
if not w:
|
|
1791
|
+
return
|
|
1792
|
+
try:
|
|
1793
|
+
frames = getattr(self, "_SPINNER_FRAMES", ("⠋",))
|
|
1794
|
+
i = int(getattr(self, "_spinner_i", 0) or 0)
|
|
1795
|
+
i = (i + 1) % max(1, len(frames))
|
|
1796
|
+
self._spinner_i = i
|
|
1797
|
+
w.set_spinner_frame(frames[i])
|
|
1798
|
+
except Exception:
|
|
1799
|
+
pass
|
|
1800
|
+
|
|
1628
1801
|
def _set_streaming(self, streaming: bool) -> None:
|
|
1629
1802
|
"""Set busy/streaming mode (chat send)."""
|
|
1630
1803
|
self._is_streaming = streaming
|
|
1631
|
-
self._apply_blocking_state()
|
|
1632
1804
|
|
|
1805
|
+
try:
|
|
1806
|
+
if streaming:
|
|
1807
|
+
# Blink timer (slower).
|
|
1808
|
+
if getattr(self, "_blink_timer", None) is None:
|
|
1809
|
+
self._blink_timer = self.set_interval(0.5, self._blink_streaming_dot_tick)
|
|
1810
|
+
|
|
1811
|
+
# Spinner timer (faster).
|
|
1812
|
+
if getattr(self, "_spinner_timer", None) is None:
|
|
1813
|
+
self._spinner_i = 0
|
|
1814
|
+
self._spinner_timer = self.set_interval(0.12, self._spinner_tick)
|
|
1815
|
+
|
|
1816
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
1817
|
+
if w:
|
|
1818
|
+
w.set_streaming(True)
|
|
1819
|
+
# Set an initial frame immediately (avoid a blank spinner until first tick).
|
|
1820
|
+
try:
|
|
1821
|
+
w.set_spinner_frame(self._SPINNER_FRAMES[0])
|
|
1822
|
+
except Exception:
|
|
1823
|
+
pass
|
|
1824
|
+
|
|
1825
|
+
try:
|
|
1826
|
+
self._spinner_tick()
|
|
1827
|
+
self._blink_streaming_dot_tick()
|
|
1828
|
+
except Exception:
|
|
1829
|
+
pass
|
|
1830
|
+
else:
|
|
1831
|
+
# Stop blink timer.
|
|
1832
|
+
t = getattr(self, "_blink_timer", None)
|
|
1833
|
+
if t is not None:
|
|
1834
|
+
try:
|
|
1835
|
+
t.stop()
|
|
1836
|
+
except Exception:
|
|
1837
|
+
pass
|
|
1838
|
+
self._blink_timer = None
|
|
1839
|
+
self._blink_dim = False
|
|
1840
|
+
|
|
1841
|
+
# Stop spinner timer.
|
|
1842
|
+
st = getattr(self, "_spinner_timer", None)
|
|
1843
|
+
if st is not None:
|
|
1844
|
+
try:
|
|
1845
|
+
st.stop()
|
|
1846
|
+
except Exception:
|
|
1847
|
+
pass
|
|
1848
|
+
self._spinner_timer = None
|
|
1849
|
+
self._spinner_i = 0
|
|
1850
|
+
|
|
1851
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
1852
|
+
if w:
|
|
1853
|
+
w.set_streaming(False)
|
|
1854
|
+
w.set_blink_dim(False)
|
|
1855
|
+
w.set_spinner_frame("")
|
|
1856
|
+
except Exception:
|
|
1857
|
+
pass
|
|
1633
1858
|
|
|
1859
|
+
self._apply_blocking_state()
|
|
1634
1860
|
|
|
1635
1861
|
def _on_attach_result(self, result: dict) -> None:
|
|
1636
1862
|
"""Callback from AttachContentScreen."""
|
|
@@ -1702,10 +1928,33 @@ class DashboardScreen(Screen):
|
|
|
1702
1928
|
self.run_action_with_polling(message)
|
|
1703
1929
|
|
|
1704
1930
|
def run_action_with_polling(self, user_input: str) -> None:
|
|
1705
|
-
"""Run action and stream results via SSE.
|
|
1931
|
+
"""Run action and stream results via SSE.
|
|
1932
|
+
|
|
1933
|
+
UX requirement: as soon as the user hits Enter, show an "in progress"
|
|
1934
|
+
assistant row (dot + [Kiwi] header + spinner) and animate it until we
|
|
1935
|
+
have a terminal result (success/error), regardless of whether SSE is
|
|
1936
|
+
currently emitting tokens.
|
|
1937
|
+
"""
|
|
1938
|
+
# Mount the streaming placeholder immediately (before any network calls).
|
|
1939
|
+
try:
|
|
1940
|
+
messages = self.query_one("#messages", VerticalScroll)
|
|
1941
|
+
placeholder = AssistantMessageRow(
|
|
1942
|
+
"",
|
|
1943
|
+
verb=random_verb(),
|
|
1944
|
+
streaming=True,
|
|
1945
|
+
classes="message assistant-message",
|
|
1946
|
+
)
|
|
1947
|
+
messages.mount(placeholder)
|
|
1948
|
+
messages.scroll_end(animate=False)
|
|
1949
|
+
self._streaming_widget_ref = placeholder
|
|
1950
|
+
except Exception:
|
|
1951
|
+
# Non-fatal; we'll still run the action and stream/poll results.
|
|
1952
|
+
self._streaming_widget_ref = None
|
|
1953
|
+
|
|
1706
1954
|
# Mark as busy immediately (prevents double-submit before the HTTP request returns)
|
|
1707
1955
|
self._set_streaming(True)
|
|
1708
|
-
|
|
1956
|
+
|
|
1957
|
+
# Kick off as a worker so the UI renders the user message + placeholder first
|
|
1709
1958
|
self.run_worker(self._run_action_worker(user_input), exclusive=True, group="stream")
|
|
1710
1959
|
|
|
1711
1960
|
async def _run_action_worker(self, user_input: str) -> None:
|
|
@@ -1734,11 +1983,32 @@ class DashboardScreen(Screen):
|
|
|
1734
1983
|
# If the user hit Stop before the request returned, restore UI state.
|
|
1735
1984
|
self._set_streaming(False)
|
|
1736
1985
|
raise
|
|
1986
|
+
except Exception as e:
|
|
1987
|
+
# Unexpected error starting the action request.
|
|
1988
|
+
self.add_message(f"Error starting action: {e}", "error")
|
|
1989
|
+
try:
|
|
1990
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
1991
|
+
if w:
|
|
1992
|
+
w.remove()
|
|
1993
|
+
except Exception:
|
|
1994
|
+
pass
|
|
1995
|
+
self._streaming_widget_ref = None
|
|
1996
|
+
self._set_streaming(False)
|
|
1997
|
+
return
|
|
1737
1998
|
|
|
1738
1999
|
if not success:
|
|
1739
2000
|
self.add_message(f"Error starting action: {message}", "error")
|
|
2001
|
+
# Remove the placeholder row (no run was started).
|
|
2002
|
+
try:
|
|
2003
|
+
w = getattr(self, "_streaming_widget_ref", None)
|
|
2004
|
+
if w:
|
|
2005
|
+
w.remove()
|
|
2006
|
+
except Exception:
|
|
2007
|
+
pass
|
|
2008
|
+
self._streaming_widget_ref = None
|
|
1740
2009
|
self._set_streaming(False)
|
|
1741
2010
|
return
|
|
2011
|
+
|
|
1742
2012
|
# Check if this is continuing an existing conversation
|
|
1743
2013
|
if self.current_run_id and run_id == self.current_run_id:
|
|
1744
2014
|
logger.info(f"Continuing conversation with run_id: {run_id}")
|
|
@@ -1752,7 +2022,6 @@ class DashboardScreen(Screen):
|
|
|
1752
2022
|
pass
|
|
1753
2023
|
logger.info(f"Started new conversation with run_id: {run_id}")
|
|
1754
2024
|
|
|
1755
|
-
|
|
1756
2025
|
self._update_run_status_bar()
|
|
1757
2026
|
|
|
1758
2027
|
# Cache run name (best-effort) so the quit-time runtime cleanup prompt can show it.
|
|
@@ -1816,8 +2085,9 @@ class DashboardScreen(Screen):
|
|
|
1816
2085
|
client = self.app.autobots_client
|
|
1817
2086
|
got_final_result = False
|
|
1818
2087
|
|
|
1819
|
-
# Track status widget for streaming transitional messages
|
|
1820
|
-
|
|
2088
|
+
# Track status widget for streaming transitional messages.
|
|
2089
|
+
# If we already mounted a placeholder row on submit, reuse it.
|
|
2090
|
+
status_widget_container = [getattr(self, "_streaming_widget_ref", None)]
|
|
1821
2091
|
|
|
1822
2092
|
logger.info(f"Starting stream_results for {run_id}")
|
|
1823
2093
|
|
|
@@ -1825,17 +2095,42 @@ class DashboardScreen(Screen):
|
|
|
1825
2095
|
"""Remove the streaming status widget if it exists."""
|
|
1826
2096
|
if status_widget_container[0]:
|
|
1827
2097
|
try:
|
|
1828
|
-
status_widget_container[0]
|
|
2098
|
+
removed = status_widget_container[0]
|
|
2099
|
+
removed.remove()
|
|
1829
2100
|
status_widget_container[0] = None
|
|
2101
|
+
# Also clear streaming UI reference if this was the active row.
|
|
2102
|
+
try:
|
|
2103
|
+
if getattr(self, "_streaming_widget_ref", None) is removed:
|
|
2104
|
+
self._streaming_widget_ref = None
|
|
2105
|
+
except Exception:
|
|
2106
|
+
pass
|
|
1830
2107
|
except Exception as e:
|
|
1831
2108
|
logger.warning(f"Failed to remove status widget: {e}")
|
|
1832
2109
|
|
|
1833
|
-
|
|
1834
|
-
|
|
2110
|
+
fetch_lock = asyncio.Lock()
|
|
2111
|
+
|
|
2112
|
+
async def _try_fetch_final_result_async() -> bool:
|
|
2113
|
+
"""Attempt to fetch and display the final result. Returns True on success/error.
|
|
2114
|
+
|
|
2115
|
+
Important: fetching uses a background thread so the Textual event loop
|
|
2116
|
+
stays responsive and UI timers (spinner/dot) continue animating even
|
|
2117
|
+
when SSE is quiet or the backend is slow.
|
|
2118
|
+
"""
|
|
1835
2119
|
nonlocal got_final_result
|
|
1836
2120
|
if got_final_result:
|
|
1837
2121
|
return True
|
|
1838
|
-
|
|
2122
|
+
|
|
2123
|
+
try:
|
|
2124
|
+
async with fetch_lock:
|
|
2125
|
+
if got_final_result:
|
|
2126
|
+
return True
|
|
2127
|
+
success, final_result, _message = await asyncio.to_thread(
|
|
2128
|
+
client.get_action_result, run_id
|
|
2129
|
+
)
|
|
2130
|
+
except Exception as e:
|
|
2131
|
+
logger.warning(f"Poll error: {e}")
|
|
2132
|
+
return False
|
|
2133
|
+
|
|
1839
2134
|
if not success or not final_result:
|
|
1840
2135
|
return False
|
|
1841
2136
|
|
|
@@ -1845,10 +2140,23 @@ class DashboardScreen(Screen):
|
|
|
1845
2140
|
# Handle error/failed states
|
|
1846
2141
|
if status in ("error", "failed"):
|
|
1847
2142
|
logger.info(f"Run failed with status: {status}")
|
|
1848
|
-
_remove_status_widget()
|
|
1849
2143
|
error_msg = final_result.get("message", "") or final_result.get("error", "") or "Action failed"
|
|
1850
|
-
|
|
2144
|
+
|
|
2145
|
+
w = status_widget_container[0]
|
|
2146
|
+
if isinstance(w, AssistantMessageRow):
|
|
2147
|
+
prepared = self._prepare_markdown(f"Error: {error_msg}")
|
|
2148
|
+
w.update_markdown(prepared)
|
|
2149
|
+
w.set_streaming(False)
|
|
2150
|
+
w.set_blink_dim(False)
|
|
2151
|
+
w.set_spinner_frame("")
|
|
2152
|
+
status_widget_container[0] = None
|
|
2153
|
+
self._streaming_widget_ref = None
|
|
2154
|
+
else:
|
|
2155
|
+
self.add_message(f"Error: {error_msg}", "error")
|
|
2156
|
+
|
|
1851
2157
|
got_final_result = True
|
|
2158
|
+
# Stop spinner/blink immediately when we have a terminal state.
|
|
2159
|
+
self._set_streaming(False)
|
|
1852
2160
|
return True
|
|
1853
2161
|
|
|
1854
2162
|
# Only treat as complete if status indicates completion
|
|
@@ -1862,9 +2170,14 @@ class DashboardScreen(Screen):
|
|
|
1862
2170
|
last = results_list[-1]
|
|
1863
2171
|
if isinstance(last, dict) and last.get("output"):
|
|
1864
2172
|
logger.info("Final result fetched successfully")
|
|
1865
|
-
|
|
1866
|
-
self.display_final_result(final_result)
|
|
2173
|
+
w = status_widget_container[0] if isinstance(status_widget_container[0], AssistantMessageRow) else None
|
|
2174
|
+
self.display_final_result(final_result, widget_ref=w)
|
|
2175
|
+
# Keep the rendered message, but prevent cleanup from removing it.
|
|
2176
|
+
status_widget_container[0] = None
|
|
2177
|
+
self._streaming_widget_ref = None
|
|
1867
2178
|
got_final_result = True
|
|
2179
|
+
# Stop spinner/blink immediately when we have a terminal state.
|
|
2180
|
+
self._set_streaming(False)
|
|
1868
2181
|
return True
|
|
1869
2182
|
return False
|
|
1870
2183
|
|
|
@@ -1889,7 +2202,7 @@ class DashboardScreen(Screen):
|
|
|
1889
2202
|
# Detect completion from text signals (server sends these as plain text)
|
|
1890
2203
|
if any(kw in text_lower for kw in ["finishing", "completed", "finished"]):
|
|
1891
2204
|
logger.info(f"Completion signal from SSE text: {text}")
|
|
1892
|
-
|
|
2205
|
+
asyncio.create_task(_try_fetch_final_result_async())
|
|
1893
2206
|
return
|
|
1894
2207
|
|
|
1895
2208
|
# JSON status messages
|
|
@@ -1897,7 +2210,7 @@ class DashboardScreen(Screen):
|
|
|
1897
2210
|
if status:
|
|
1898
2211
|
logger.info(f"SSE JSON status: {status}")
|
|
1899
2212
|
if status in ["completed", "success", "finished", "error", "failed"]:
|
|
1900
|
-
|
|
2213
|
+
asyncio.create_task(_try_fetch_final_result_async())
|
|
1901
2214
|
|
|
1902
2215
|
# ---- Concurrent polling task ----
|
|
1903
2216
|
async def _poll_until_done() -> None:
|
|
@@ -1907,7 +2220,7 @@ class DashboardScreen(Screen):
|
|
|
1907
2220
|
if got_final_result:
|
|
1908
2221
|
return
|
|
1909
2222
|
try:
|
|
1910
|
-
if
|
|
2223
|
+
if await _try_fetch_final_result_async():
|
|
1911
2224
|
return
|
|
1912
2225
|
except Exception as e:
|
|
1913
2226
|
logger.warning(f"Poll error: {e}")
|
|
@@ -1979,13 +2292,14 @@ class DashboardScreen(Screen):
|
|
|
1979
2292
|
except (asyncio.CancelledError, Exception):
|
|
1980
2293
|
pass
|
|
1981
2294
|
|
|
1982
|
-
#
|
|
1983
|
-
|
|
2295
|
+
# Clean up status widget if it still exists (we keep it when it becomes the final output).
|
|
2296
|
+
if status_widget_container[0]:
|
|
2297
|
+
_remove_status_widget()
|
|
1984
2298
|
|
|
1985
2299
|
# Final fallback — if neither SSE nor polling found the result
|
|
1986
2300
|
if not got_final_result:
|
|
1987
2301
|
logger.info(f"Final fallback poll for {run_id}")
|
|
1988
|
-
|
|
2302
|
+
await _try_fetch_final_result_async()
|
|
1989
2303
|
|
|
1990
2304
|
if not got_final_result:
|
|
1991
2305
|
self.add_message("Could not get result. Use /new to start over.", "error")
|
|
@@ -2009,22 +2323,56 @@ class DashboardScreen(Screen):
|
|
|
2009
2323
|
|
|
2010
2324
|
markdown_text = self._prepare_markdown(text_content)
|
|
2011
2325
|
if widget_ref is None:
|
|
2012
|
-
widget_ref = AssistantMessageRow(
|
|
2326
|
+
widget_ref = AssistantMessageRow(
|
|
2327
|
+
markdown_text,
|
|
2328
|
+
verb=random_verb(),
|
|
2329
|
+
streaming=True,
|
|
2330
|
+
classes="message assistant-message",
|
|
2331
|
+
)
|
|
2013
2332
|
messages.mount(widget_ref)
|
|
2333
|
+
self._streaming_widget_ref = widget_ref
|
|
2334
|
+
try:
|
|
2335
|
+
frames = getattr(self, "_SPINNER_FRAMES", ("⠋",))
|
|
2336
|
+
i = int(getattr(self, "_spinner_i", 0) or 0) % max(1, len(frames))
|
|
2337
|
+
widget_ref.set_spinner_frame(frames[i])
|
|
2338
|
+
except Exception:
|
|
2339
|
+
pass
|
|
2014
2340
|
else:
|
|
2015
2341
|
try:
|
|
2016
2342
|
if isinstance(widget_ref, AssistantMessageRow):
|
|
2343
|
+
# Keep the streaming state "on" while we update markdown.
|
|
2344
|
+
self._streaming_widget_ref = widget_ref
|
|
2345
|
+
widget_ref.set_streaming(True)
|
|
2346
|
+
try:
|
|
2347
|
+
frames = getattr(self, "_SPINNER_FRAMES", ("⠋",))
|
|
2348
|
+
i = int(getattr(self, "_spinner_i", 0) or 0) % max(1, len(frames))
|
|
2349
|
+
widget_ref.set_spinner_frame(frames[i])
|
|
2350
|
+
except Exception:
|
|
2351
|
+
pass
|
|
2017
2352
|
widget_ref.update_markdown(markdown_text)
|
|
2018
2353
|
else:
|
|
2019
2354
|
# Backward-compat: if an older ref is a Markdown widget.
|
|
2020
2355
|
widget_ref.update(markdown_text)
|
|
2021
2356
|
except Exception as e:
|
|
2022
2357
|
logger.warning(f"Failed to update widget: {e}")
|
|
2023
|
-
widget_ref = AssistantMessageRow(
|
|
2358
|
+
widget_ref = AssistantMessageRow(
|
|
2359
|
+
markdown_text,
|
|
2360
|
+
verb=random_verb(),
|
|
2361
|
+
streaming=True,
|
|
2362
|
+
classes="message assistant-message",
|
|
2363
|
+
)
|
|
2024
2364
|
messages.mount(widget_ref)
|
|
2365
|
+
self._streaming_widget_ref = widget_ref
|
|
2366
|
+
try:
|
|
2367
|
+
frames = getattr(self, "_SPINNER_FRAMES", ("⠋",))
|
|
2368
|
+
i = int(getattr(self, "_spinner_i", 0) or 0) % max(1, len(frames))
|
|
2369
|
+
widget_ref.set_spinner_frame(frames[i])
|
|
2370
|
+
except Exception:
|
|
2371
|
+
pass
|
|
2025
2372
|
|
|
2026
2373
|
messages.scroll_end(animate=False)
|
|
2027
2374
|
return widget_ref
|
|
2375
|
+
|
|
2028
2376
|
def extract_text_from_output(self, output: any) -> str:
|
|
2029
2377
|
"""Extract text content from output blocks structure and clean it for display."""
|
|
2030
2378
|
if not isinstance(output, dict):
|
|
@@ -2112,61 +2460,76 @@ class DashboardScreen(Screen):
|
|
|
2112
2460
|
|
|
2113
2461
|
messages.scroll_end(animate=False)
|
|
2114
2462
|
self._update_run_status_bar()
|
|
2115
|
-
def display_final_result(self, result: dict) -> None:
|
|
2463
|
+
def display_final_result(self, result: dict, *, widget_ref: AssistantMessageRow | None = None) -> None:
|
|
2116
2464
|
"""Display the final result from results array, showing only the latest output.
|
|
2117
2465
|
|
|
2118
|
-
|
|
2119
|
-
|
|
2466
|
+
If `widget_ref` is provided (streaming placeholder row), we update that row
|
|
2467
|
+
in-place and hide the streaming footer so the transition feels seamless.
|
|
2120
2468
|
"""
|
|
2121
|
-
logger.info(
|
|
2469
|
+
logger.info("Displaying final result from results array")
|
|
2470
|
+
|
|
2471
|
+
def _finalize_streaming_row() -> None:
|
|
2472
|
+
try:
|
|
2473
|
+
if widget_ref:
|
|
2474
|
+
widget_ref.set_streaming(False)
|
|
2475
|
+
widget_ref.set_blink_dim(False)
|
|
2476
|
+
widget_ref.set_spinner_frame("")
|
|
2477
|
+
except Exception:
|
|
2478
|
+
pass
|
|
2122
2479
|
|
|
2123
2480
|
# Extract results array from result.result.results
|
|
2124
2481
|
if "result" not in result or not isinstance(result["result"], dict):
|
|
2125
|
-
logger.warning(
|
|
2482
|
+
logger.warning("No result.result field found")
|
|
2483
|
+
_finalize_streaming_row()
|
|
2126
2484
|
self.add_message("Action completed (no output)", "info")
|
|
2127
2485
|
return
|
|
2128
2486
|
|
|
2129
2487
|
action_doc = result["result"]
|
|
2130
|
-
logger.info(f"ActionDoc keys: {action_doc.keys()}")
|
|
2131
|
-
|
|
2132
2488
|
if "results" not in action_doc or not isinstance(action_doc["results"], list):
|
|
2133
|
-
logger.warning(
|
|
2489
|
+
logger.warning("No results array found in ActionDoc")
|
|
2490
|
+
_finalize_streaming_row()
|
|
2134
2491
|
self.add_message("Action completed (no output)", "info")
|
|
2135
2492
|
return
|
|
2136
2493
|
|
|
2137
2494
|
results_list = action_doc["results"]
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
if len(results_list) == 0:
|
|
2142
|
-
logger.warning(f"Results array is empty")
|
|
2495
|
+
if not results_list:
|
|
2496
|
+
logger.warning("Results array is empty")
|
|
2497
|
+
_finalize_streaming_row()
|
|
2143
2498
|
self.add_message("Action completed (no output)", "info")
|
|
2144
2499
|
return
|
|
2145
2500
|
|
|
2146
2501
|
# Only display the LAST result item's output.
|
|
2147
|
-
# The results array contains full conversation history; the user's input
|
|
2148
|
-
# was already shown when they typed it, so we only need the latest response.
|
|
2149
2502
|
last_result = results_list[-1]
|
|
2150
2503
|
if not isinstance(last_result, dict):
|
|
2151
|
-
logger.warning(
|
|
2504
|
+
logger.warning("Last result is not a dict")
|
|
2505
|
+
_finalize_streaming_row()
|
|
2152
2506
|
self.add_message("Action completed (no output)", "info")
|
|
2153
2507
|
return
|
|
2154
2508
|
|
|
2155
|
-
|
|
2509
|
+
output_data = last_result.get("output")
|
|
2510
|
+
if not output_data:
|
|
2511
|
+
logger.warning("Last result has no output field")
|
|
2512
|
+
_finalize_streaming_row()
|
|
2513
|
+
self.add_message("Action completed (no output)", "info")
|
|
2514
|
+
return
|
|
2156
2515
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
logger.info(f"Last result output: {len(output_text)} chars, {len(output_text.splitlines())} lines")
|
|
2163
|
-
self.add_message(output_text, "assistant")
|
|
2164
|
-
else:
|
|
2165
|
-
logger.warning(f"Last result output extraction returned empty")
|
|
2166
|
-
self.add_message("Action completed (no output)", "info")
|
|
2167
|
-
else:
|
|
2168
|
-
logger.warning(f"Last result has no output field")
|
|
2516
|
+
output_text = self.extract_text_from_output(output_data) if isinstance(output_data, dict) else str(output_data)
|
|
2517
|
+
output_text = (output_text or "").strip()
|
|
2518
|
+
if not output_text:
|
|
2519
|
+
logger.warning("Last result output extraction returned empty")
|
|
2520
|
+
_finalize_streaming_row()
|
|
2169
2521
|
self.add_message("Action completed (no output)", "info")
|
|
2522
|
+
return
|
|
2523
|
+
|
|
2524
|
+
# Update existing streaming row in-place (preferred).
|
|
2525
|
+
if widget_ref and isinstance(widget_ref, AssistantMessageRow):
|
|
2526
|
+
prepared = self._prepare_markdown(output_text)
|
|
2527
|
+
widget_ref.update_markdown(prepared)
|
|
2528
|
+
_finalize_streaming_row()
|
|
2529
|
+
return
|
|
2530
|
+
|
|
2531
|
+
# Fallback: mount a new assistant message.
|
|
2532
|
+
self.add_message(output_text, "assistant")
|
|
2170
2533
|
|
|
2171
2534
|
def format_and_display_output(self, output: any) -> None:
|
|
2172
2535
|
"""Format and display output, extracting text and files from blocks."""
|
|
@@ -397,7 +397,7 @@ wheels = [
|
|
|
397
397
|
|
|
398
398
|
[[package]]
|
|
399
399
|
name = "kiwi-code"
|
|
400
|
-
version = "0.0.
|
|
400
|
+
version = "0.0.27"
|
|
401
401
|
source = { editable = "." }
|
|
402
402
|
dependencies = [
|
|
403
403
|
{ name = "autobots-client" },
|
|
@@ -410,6 +410,7 @@ dependencies = [
|
|
|
410
410
|
{ name = "textual-dev" },
|
|
411
411
|
{ name = "typer" },
|
|
412
412
|
{ name = "websockets" },
|
|
413
|
+
{ name = "wonderwords" },
|
|
413
414
|
]
|
|
414
415
|
|
|
415
416
|
[package.dev-dependencies]
|
|
@@ -430,6 +431,7 @@ requires-dist = [
|
|
|
430
431
|
{ name = "textual-dev", specifier = ">=1.8.0" },
|
|
431
432
|
{ name = "typer", specifier = ">=0.24.1" },
|
|
432
433
|
{ name = "websockets", specifier = ">=14.1" },
|
|
434
|
+
{ name = "wonderwords", specifier = ">=2.2.0" },
|
|
433
435
|
]
|
|
434
436
|
|
|
435
437
|
[package.metadata.requires-dev]
|
|
@@ -1326,6 +1328,15 @@ wheels = [
|
|
|
1326
1328
|
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
|
1327
1329
|
]
|
|
1328
1330
|
|
|
1331
|
+
[[package]]
|
|
1332
|
+
name = "wonderwords"
|
|
1333
|
+
version = "3.0.1"
|
|
1334
|
+
source = { registry = "https://pypi.org/simple" }
|
|
1335
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ff/23/e144fc3dfabb845dc1d94c45315d97b308cf75a664e3db3a89aeb1cb505d/wonderwords-3.0.1.tar.gz", hash = "sha256:5ee43ab6f13823a857a7c3d58c7b4db6a1350bd3aa5f914ed379ad49042a1c36", size = 73339, upload-time = "2025-10-30T17:30:44.231Z" }
|
|
1336
|
+
wheels = [
|
|
1337
|
+
{ url = "https://files.pythonhosted.org/packages/a4/75/855c2062d28b8e9247939f8262fb2f4ff3b12a49e4bab9fd1ba16cc5df82/wonderwords-3.0.1-py3-none-any.whl", hash = "sha256:4dd66deb6a76ca9e0b0422d1d3e111f9b910d7c16922d42de733ee8def98f8d0", size = 51658, upload-time = "2025-10-30T17:30:42.785Z" },
|
|
1338
|
+
]
|
|
1339
|
+
|
|
1329
1340
|
[[package]]
|
|
1330
1341
|
name = "yarl"
|
|
1331
1342
|
version = "1.23.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|