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.
Files changed (51) hide show
  1. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/PKG-INFO +2 -1
  2. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/pyproject.toml +3 -1
  3. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/main.py +2 -0
  4. kiwi_code-0.0.27/src/kiwi_tui/random_words.py +80 -0
  5. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/dashboard.py +421 -58
  6. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/uv.lock +12 -1
  7. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/.github/workflows/publish.yml +0 -0
  8. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/.github/workflows/test.yml +0 -0
  9. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/.gitignore +0 -0
  10. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/.python-version +0 -0
  11. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/CLAUDE.md +0 -0
  12. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/Makefile +0 -0
  13. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/README.md +0 -0
  14. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/__init__.py +0 -0
  15. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/auth.py +0 -0
  16. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/cli.py +0 -0
  17. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/client.py +0 -0
  18. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/commands.py +0 -0
  19. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/logger.py +0 -0
  20. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/models.py +0 -0
  21. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/runtime_manager.py +0 -0
  22. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_cli/server.py +0 -0
  23. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/__init__.py +0 -0
  24. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/__main__.py +0 -0
  25. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/main.py +0 -0
  26. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  27. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  28. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/__init__.py +0 -0
  29. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/inline_file_picker.py +0 -0
  30. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/runtime_agent.py +0 -0
  31. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/__init__.py +0 -0
  32. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/attach_content.py +0 -0
  33. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/command_result.py +0 -0
  34. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/file_browser.py +0 -0
  35. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/help.py +0 -0
  36. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/id_picker.py +0 -0
  37. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/login.py +0 -0
  38. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  39. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  40. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/screens/slash_picker.py +0 -0
  41. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/slash_commands.py +0 -0
  42. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/src/kiwi_tui/widgets.py +0 -0
  43. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/test_hello.py +0 -0
  44. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/__init__.py +0 -0
  45. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/conftest.py +0 -0
  46. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_cli_help.py +0 -0
  47. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_imports.py +0 -0
  48. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_reexec_kiwi.py +0 -0
  49. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_runtime_log_trimming.py +0 -0
  50. {kiwi_code-0.0.26 → kiwi_code-0.0.27}/tests/test_tokens.py +0 -0
  51. {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.26
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.26"
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
- def __init__(self, markdown_text: str, *, classes: str = "") -> None:
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
- yield Markdown(self._markdown_text, classes="assistant-body")
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", "newline (Mac)", show=True, priority=True)
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", "newline (Win)", show=True, priority=True)
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
- width: 2;
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(AssistantMessageRow(prepared, classes=css_class))
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
- # Kick off as a worker so the UI renders the user message first
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
- status_widget_container = [None]
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].remove()
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
- def _try_fetch_final_result() -> bool:
1834
- """Attempt to fetch and display the final result. Returns True on success/error."""
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
- success, final_result, message = client.get_action_result(run_id)
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
- self.add_message(f"Error: {error_msg}", "error")
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
- _remove_status_widget()
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
- _try_fetch_final_result()
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
- _try_fetch_final_result()
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 _try_fetch_final_result():
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
- # Always clean up status widget when SSE stream ends
1983
- _remove_status_widget()
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
- _try_fetch_final_result()
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(markdown_text, classes="message assistant-message")
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(markdown_text, classes="message assistant-message")
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
- Args:
2119
- result: The result dictionary from get_action_result containing result.result.results[]
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(f"Displaying final result from results array")
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(f"No result.result field found")
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(f"No results array found in ActionDoc")
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
- logger.info(f"Found {len(results_list)} items in results array")
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(f"Last result is not a dict")
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
- logger.info(f"Processing last result, keys: {last_result.keys()}")
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
- # Extract and display only the output (skip input — already shown)
2158
- if "output" in last_result and last_result["output"]:
2159
- output_data = last_result["output"]
2160
- output_text = self.extract_text_from_output(output_data)
2161
- if output_text:
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.26"
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