kiwi-code 0.0.431__tar.gz → 0.0.433__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 (59) hide show
  1. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/auth.py +0 -3
  4. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/cli.py +0 -4
  5. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/client.py +68 -55
  6. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/commands.py +0 -13
  7. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/models.py +0 -2
  8. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/server.py +0 -4
  9. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/terminal_mode.py +0 -2
  10. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_runtime/main.py +4 -31
  11. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/dashboard.py +213 -11
  12. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/slash_commands.py +1 -0
  13. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/status_words.py +22 -0
  14. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/widgets.py +4 -1
  15. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/conftest.py +0 -2
  16. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_cli_help.py +0 -2
  17. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_imports.py +0 -3
  18. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_reexec_kiwi.py +0 -4
  19. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_runtime_log_trimming.py +0 -5
  20. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_terminal_mode.py +0 -4
  21. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_tokens.py +0 -3
  22. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_tui_headless.py +0 -3
  23. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_tui_interactive_runtime.py +0 -4
  24. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_tui_palette.py +0 -1
  25. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_worktrees.py +1 -6
  26. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/uv.lock +1 -1
  27. kiwi_code-0.0.431/src/kiwi_runtime/snake_game/.gitignore +0 -3
  28. kiwi_code-0.0.431/src/kiwi_runtime/snake_game/requirements.txt +0 -3
  29. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/.github/workflows/publish.yml +0 -0
  30. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/.github/workflows/test.yml +0 -0
  31. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/.gitignore +0 -0
  32. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/.python-version +0 -0
  33. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/CLAUDE.md +0 -0
  34. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/Makefile +0 -0
  35. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/README.md +0 -0
  36. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/__init__.py +0 -0
  37. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/logger.py +0 -0
  38. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_cli/runtime_manager.py +0 -0
  39. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_runtime/__init__.py +0 -0
  40. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_runtime/__main__.py +0 -0
  41. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/__init__.py +0 -0
  42. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/inline_file_picker.py +0 -0
  43. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/main.py +0 -0
  44. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/random_words.py +0 -0
  45. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/runtime_agent.py +0 -0
  46. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/__init__.py +0 -0
  47. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/attach_content.py +0 -0
  48. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/command_result.py +0 -0
  49. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/file_browser.py +0 -0
  50. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/help.py +0 -0
  51. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/id_picker.py +0 -0
  52. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/login.py +0 -0
  53. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  54. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  55. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/screens/slash_picker.py +0 -0
  56. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/src/kiwi_tui/worktrees.py +0 -0
  57. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/test_hello.py +0 -0
  58. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/__init__.py +0 -0
  59. {kiwi_code-0.0.431 → kiwi_code-0.0.433}/tests/test_slash_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.431
3
+ Version: 0.0.433
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.431"
3
+ version = "0.0.433"
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"
@@ -1,7 +1,4 @@
1
1
  """Authentication and token management."""
2
-
3
- from __future__ import annotations
4
-
5
2
  import json
6
3
  import os
7
4
  import sys
@@ -1,13 +1,9 @@
1
1
  """Typer CLI for Autobots — thin wrapper over commands.py."""
2
-
3
2
  from typing import Optional, Callable
4
-
5
3
  from loguru import logger
6
4
  logger.remove() # Suppress loguru console output in CLI mode
7
-
8
5
  import typer
9
6
  from autobots_client import AuthenticatedClient
10
-
11
7
  from .auth import TokenManager
12
8
  from .models import AppConfig
13
9
  from .server import http_url_from_server
@@ -1,5 +1,4 @@
1
1
  """Autobots client wrapper for API interactions."""
2
-
3
2
  from datetime import datetime, timedelta
4
3
  from typing import Optional, Callable
5
4
  import time
@@ -11,7 +10,6 @@ from autobots_client import Client, AuthenticatedClient
11
10
  from autobots_client.api.hello import hello_v1_hello_get
12
11
  from autobots_client.api.auth import (
13
12
  return_user_and_session_v1_auth_post,
14
- refresh_password_email_v1_auth_session_refresh_post,
15
13
  )
16
14
  from autobots_client.api.actions import async_run_action_v1_actions_id_async_run_post
17
15
  from autobots_client.api.action_results import get_action_result_v1_action_results_id_get
@@ -24,7 +22,6 @@ from autobots_client.models.body_async_run_action_v1_actions_id_async_run_post i
24
22
  from autobots_client.models.body_async_run_action_v1_actions_id_async_run_post_input import (
25
23
  BodyAsyncRunActionV1ActionsIdAsyncRunPostInput,
26
24
  )
27
-
28
25
  from .models import AuthTokens, LoginCredentials
29
26
 
30
27
 
@@ -525,18 +522,42 @@ class AutobotsClientWrapper:
525
522
 
526
523
  return False, result, f"Polling timeout after {max_attempts * interval}s - action may still be running"
527
524
 
528
- async def stream_action_result(self, run_id: str, on_message: Callable[[dict], None]) -> None:
529
- """Stream action result updates via Server-Sent Events.
525
+ async def stream_action_result(
526
+ self,
527
+ run_id: str,
528
+ on_message: Callable[[dict], None],
529
+ *,
530
+ topic: str | None = None,
531
+ stop_on_terminal_status: bool = True,
532
+ plain_text_type: str = "status",
533
+ ) -> None:
534
+ """Stream action run updates via Server-Sent Events.
535
+
536
+ The backend publishes plain text frames to Redis topics. Historically Kiwi Code
537
+ subscribed only to the `run_id` topic and wrapped non-JSON frames as
538
+ `{type: 'status', text: ...}`.
539
+
540
+ Some providers (notably OpenAI Responses) publish the assistant text stream to
541
+ a separate topic: `f"{run_id}-text"`. Callers can subscribe to that topic by
542
+ passing `topic=f"{run_id}-text"` and setting `plain_text_type='text'`.
543
+ That topic does not emit JSON status payloads, so callers should typically set
544
+ `stop_on_terminal_status=False` and cancel the stream when polling detects
545
+ completion.
530
546
 
531
547
  Args:
532
- run_id: ID of the action run
533
- on_message: Callback function to handle incoming messages
548
+ run_id: Action run id (used for auth and (optionally) terminal detection).
549
+ on_message: Callback for parsed payloads (dict).
550
+ topic: Redis topic to subscribe to. Defaults to `run_id`.
551
+ stop_on_terminal_status: Stop the stream when a JSON payload includes a terminal
552
+ `status` field. Disable for topics that don't send status (e.g. *-text).
553
+ plain_text_type: When a frame is not valid JSON, wrap it as
554
+ `{'type': plain_text_type, 'text': <payload>}`.
534
555
  """
535
- sse_endpoint = f"{self.base_url}/v1/server_sent_events/stream/{run_id}"
556
+ topic = topic or run_id
557
+ sse_endpoint = f"{self.base_url}/v1/server_sent_events/stream/{topic}"
536
558
  logger.info(f"Connecting to SSE: {sse_endpoint}")
537
559
 
538
560
  try:
539
- # Create headers
540
561
  headers = {
541
562
  "Accept": "text/event-stream",
542
563
  "Cache-Control": "no-cache",
@@ -545,7 +566,6 @@ class AutobotsClientWrapper:
545
566
  if self.access_token:
546
567
  headers["Authorization"] = f"Bearer {self.access_token}"
547
568
 
548
- # Use httpx to stream SSE with longer timeout
549
569
  async with httpx.AsyncClient(timeout=None) as client:
550
570
  async with client.stream("GET", sse_endpoint, headers=headers) as response:
551
571
  logger.info(f"SSE connected, status: {response.status_code}")
@@ -554,68 +574,61 @@ class AutobotsClientWrapper:
554
574
  logger.error(f"SSE connection failed with status {response.status_code}")
555
575
  return
556
576
 
557
- # Use incremental UTF-8 decoder to handle multi-byte characters across chunk boundaries
558
577
  decoder = codecs.getincrementaldecoder("utf-8")()
559
578
  buffer = ""
579
+ event_data_lines: list[str] = []
580
+
581
+ def _flush_event() -> bool:
582
+ if not event_data_lines:
583
+ return False
584
+ data_str = "\n".join(event_data_lines).rstrip()
585
+ event_data_lines.clear()
586
+ if not data_str:
587
+ return False
588
+ try:
589
+ data = json.loads(data_str)
590
+ on_message(data)
591
+ if stop_on_terminal_status and isinstance(data, dict):
592
+ status = str(data.get("status", "") or "").lower()
593
+ if status in ["completed", "success", "finished", "failed", "error"]:
594
+ return True
595
+ except json.JSONDecodeError:
596
+ on_message({"type": plain_text_type, "text": data_str})
597
+ except Exception as e:
598
+ logger.error(f"Error processing SSE data: {e}")
599
+ return False
560
600
 
561
- # Process SSE stream byte by byte with incremental decoder
562
601
  async for chunk in response.aiter_bytes():
563
- # Decode chunk with incremental decoder (handles partial multi-byte chars)
564
602
  text_chunk = decoder.decode(chunk, final=False)
565
603
  buffer += text_chunk
566
604
 
567
- # Process complete lines from buffer
568
605
  while "\n" in buffer:
569
606
  line, buffer = buffer.split("\n", 1)
570
- line = line.strip()
607
+ line = line.rstrip("\r")
571
608
 
572
- # Skip empty lines
573
- if not line:
609
+ # SSE event boundary (blank line)
610
+ if line == "":
611
+ if _flush_event():
612
+ return
574
613
  continue
575
614
 
576
- # Skip keep-alive comments (`:keep-alive`)
577
615
  if line.startswith(":"):
578
- logger.debug("Received SSE keep-alive")
616
+ # keep-alive comments
579
617
  continue
580
618
 
581
- # Parse SSE data format: "data: {...}" or "data: plain text"
582
- if line.startswith("data: "):
583
- data_str = line[6:].strip()
584
-
585
- # Try to parse as JSON first
586
- try:
587
- data = json.loads(data_str)
588
- logger.info(f"SSE JSON received, keys: {data.keys() if isinstance(data, dict) else type(data)}")
589
-
590
- # Call the callback with parsed data
591
- on_message(data)
592
-
593
- # Check if action is complete
594
- if isinstance(data, dict):
595
- status = data.get("status", "").lower()
596
- if status in ["completed", "success", "finished", "failed", "error"]:
597
- logger.info(f"Action completed with status: {status}")
598
- return
599
-
600
- except json.JSONDecodeError:
601
- # Not JSON, treat as plain text status message
602
- logger.info(f"SSE text message: {data_str}")
603
-
604
- # Mark as status so dashboard can distinguish from real output
605
- text_data = {
606
- "type": "status",
607
- "text": data_str,
608
- }
609
- on_message(text_data)
610
-
611
- except Exception as e:
612
- logger.error(f"Error processing SSE data: {e}")
619
+ if line.startswith("data:"):
620
+ val = line[5:]
621
+ if val.startswith(" "):
622
+ val = val[1:]
623
+ event_data_lines.append(val)
624
+ continue
613
625
 
614
- # Flush any remaining bytes in decoder
615
- remaining = decoder.decode(b"", final=True)
616
- if remaining:
617
- buffer += remaining
626
+ if event_data_lines:
627
+ event_data_lines.append(line)
618
628
 
629
+ # Flush any remaining bytes in decoder + any pending event data.
630
+ decoder.decode(b"", final=True)
631
+ _flush_event()
619
632
  except Exception as e:
620
633
  logger.error(f"SSE error: {e}")
621
634
  raise
@@ -5,7 +5,6 @@ and returns a list of strings (output lines).
5
5
  """
6
6
 
7
7
  from typing import Optional
8
-
9
8
  from autobots_client import AuthenticatedClient
10
9
  from autobots_client.api.actions import (
11
10
  list_actions_v1_actions_get,
@@ -26,9 +25,7 @@ from autobots_client.api.action_graphs_results import (
26
25
  from autobots_client.types import UNSET
27
26
 
28
27
 
29
- # ---------------------------------------------------------------------------
30
28
  # Helpers
31
- # ---------------------------------------------------------------------------
32
29
 
33
30
  def _fmt_date(dt) -> str:
34
31
  if dt is None or isinstance(dt, type(UNSET)):
@@ -45,9 +42,7 @@ def _val(v) -> str:
45
42
  return str(v)
46
43
 
47
44
 
48
- # ---------------------------------------------------------------------------
49
45
  # actions
50
- # ---------------------------------------------------------------------------
51
46
 
52
47
  def actions_list(client: AuthenticatedClient, *, name: Optional[str] = None, limit: int = 20, offset: int = 0) -> list[str]:
53
48
  """List actions. Returns output lines."""
@@ -95,9 +90,7 @@ def actions_get(client: AuthenticatedClient, *, id: str) -> list[str]:
95
90
  ]
96
91
 
97
92
 
98
- # ---------------------------------------------------------------------------
99
93
  # runs (action results)
100
- # ---------------------------------------------------------------------------
101
94
 
102
95
  def runs_list(client: AuthenticatedClient, *, action_id: Optional[str] = None, action_name: Optional[str] = None,
103
96
  status: Optional[str] = None, limit: int = 20, offset: int = 0) -> list[str]:
@@ -164,9 +157,7 @@ def runs_get(client: AuthenticatedClient, *, id: str) -> list[str]:
164
157
  return lines
165
158
 
166
159
 
167
- # ---------------------------------------------------------------------------
168
160
  # graphs (action graphs)
169
- # ---------------------------------------------------------------------------
170
161
 
171
162
  def graphs_list(client: AuthenticatedClient, *, name: Optional[str] = None, limit: int = 20, offset: int = 0) -> list[str]:
172
163
  """List action graphs. Returns output lines."""
@@ -217,9 +208,7 @@ def graphs_get(client: AuthenticatedClient, *, id: str) -> list[str]:
217
208
  return [str(doc)]
218
209
 
219
210
 
220
- # ---------------------------------------------------------------------------
221
211
  # graph-runs (action graph results)
222
- # ---------------------------------------------------------------------------
223
212
 
224
213
  def graph_runs_list(client: AuthenticatedClient, *, action_graph_id: Optional[str] = None,
225
214
  action_graph_name: Optional[str] = None, status: Optional[str] = None,
@@ -286,9 +275,7 @@ def graph_runs_get(client: AuthenticatedClient, *, id: str) -> list[str]:
286
275
  return lines
287
276
 
288
277
 
289
- # ---------------------------------------------------------------------------
290
278
  # Command dispatcher — parses "/command args" from TUI input
291
- # ---------------------------------------------------------------------------
292
279
 
293
280
  HELP_TEXT = """\
294
281
  Session:
@@ -1,7 +1,5 @@
1
1
  """Pydantic models for Autobots TUI."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import base64
6
4
  import json
7
5
  from datetime import datetime, timedelta
@@ -10,10 +10,6 @@ The value can be:
10
10
 
11
11
  This module intentionally contains *no persistence*. It is runtime-only.
12
12
  """
13
-
14
- from __future__ import annotations
15
-
16
-
17
13
  SERVER_PRESETS: dict[str, dict[str, str]] = {
18
14
  "app": {"ws": "wss://api.meetkiwi.ai", "http": "https://api.meetkiwi.ai"},
19
15
  "dev": {"ws": "wss://dev.api.myautobots.com", "http": "https://dev.api.myautobots.com"},
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import asyncio
4
2
  import getpass
5
3
  import html
@@ -12,11 +12,7 @@ Usage:
12
12
  kiwi connect --server dev --scope full
13
13
  kiwi connect --server dev --allow /path/to/extra/dir
14
14
  """
15
-
16
- from __future__ import annotations
17
-
18
15
  from datetime import datetime
19
-
20
16
  import argparse
21
17
  import asyncio
22
18
  import getpass
@@ -29,6 +25,10 @@ import subprocess
29
25
  import sys
30
26
  from pathlib import Path
31
27
  from typing import Any
28
+ import shutil
29
+ import tempfile
30
+ import uuid
31
+ from dataclasses import dataclass
32
32
 
33
33
 
34
34
  IS_WINDOWS = sys.platform == "win32"
@@ -44,10 +44,7 @@ import websockets
44
44
  import threading
45
45
  FS_MAX_CONCURRENCY = 8
46
46
 
47
- # ---------------------------------------------------------------------------
48
47
  # Shared token storage (used by both kiwi-code and standalone kiwi-runtime)
49
- # ---------------------------------------------------------------------------
50
-
51
48
  TOKENS_PATH = Path.home() / ".kiwi" / "tokens.json"
52
49
  TOKENS_LOCK_PATH = Path.home() / ".kiwi" / "tokens.lock"
53
50
  TOKEN_EXPIRY_SKEW_SEC = 60 # refresh a bit early to avoid edge cases
@@ -311,12 +308,6 @@ _WINDOWS_SENTINEL_RESULT = re.compile(
311
308
  re.IGNORECASE,
312
309
  )
313
310
 
314
- import difflib
315
- import shutil
316
- import tempfile
317
- import uuid
318
- from dataclasses import dataclass
319
-
320
311
 
321
312
  @dataclass
322
313
  class ChunkedWriteState:
@@ -1203,10 +1194,7 @@ SERVER_PRESETS = {
1203
1194
  "local": {"ws": "ws://localhost:8000", "http": "http://localhost:8000"},
1204
1195
  }
1205
1196
 
1206
- # ---------------------------------------------------------------------------
1207
1197
  # ANSI colors
1208
- # ---------------------------------------------------------------------------
1209
-
1210
1198
  RESET = "\033[0m"
1211
1199
  BOLD = "\033[1m"
1212
1200
 
@@ -1219,10 +1207,7 @@ YELLOW = "\033[93m"
1219
1207
  GREEN = "\033[92m"
1220
1208
 
1221
1209
 
1222
- # ---------------------------------------------------------------------------
1223
1210
  # Banner — Kiwi logo (from SVG) + KIWI AI text side by side
1224
- # ---------------------------------------------------------------------------
1225
-
1226
1211
  # Logo: 32 wide x 16 tall (half-block, perfectly symmetric from SVG)
1227
1212
  LOGO = [
1228
1213
  " ▄██▄ ",
@@ -1257,10 +1242,7 @@ TAGLINE = "Terminal Agent for AI-Powered Automation"
1257
1242
 
1258
1243
 
1259
1244
 
1260
- # ---------------------------------------------------------------------------
1261
1245
  # Mini block-text renderer (3-row height, no dependencies)
1262
- # ---------------------------------------------------------------------------
1263
-
1264
1246
  _BLOCK_CHARS = {
1265
1247
  "A": ["█▀█", "█▀█", "▀ ▀"], "B": ["█▀▄", "█▀▄", "▀▀ "], "C": ["█▀▀", "█ ", "▀▀▀"],
1266
1248
  "D": ["█▀▄", "█ █", "▀▀ "], "E": ["█▀▀", "█▀▀", "▀▀▀"], "F": ["█▀▀", "█▀ ", "▀ "],
@@ -1408,10 +1390,7 @@ def print_pty_log(session_id: str, message: str, level: str = "info"):
1408
1390
  _safe_print(f" {icon} {tag} {message}")
1409
1391
 
1410
1392
 
1411
- # ---------------------------------------------------------------------------
1412
1393
  # Path validator for restricted mode
1413
- # ---------------------------------------------------------------------------
1414
-
1415
1394
  # Regex to detect Windows absolute paths like C:\ or D:/
1416
1395
  _WIN_ABS_PATH = re.compile(r'^[A-Za-z]:[/\\]')
1417
1396
  # Regex to detect UNC paths like \\server\share
@@ -1593,10 +1572,7 @@ def _is_within_allowed(resolved_path: str, allowed_dirs: list[str]) -> bool:
1593
1572
  return False
1594
1573
 
1595
1574
 
1596
- # ---------------------------------------------------------------------------
1597
1575
  # Core logic
1598
- # ---------------------------------------------------------------------------
1599
-
1600
1576
  async def login(http_base_url: str, email: str, password: str) -> str:
1601
1577
  """Authenticate with email/password and return the access token.
1602
1578
 
@@ -1916,9 +1892,6 @@ if not IS_WINDOWS:
1916
1892
  pass
1917
1893
  self.slave_fd = None
1918
1894
 
1919
-
1920
-
1921
-
1922
1895
  class PipeProcess:
1923
1896
  """Manages a persistent shell session using subprocess pipes (Windows-compatible)."""
1924
1897
 
@@ -1028,6 +1028,84 @@ class DashboardScreen(Screen):
1028
1028
  self.process_message(prompt)
1029
1029
  return
1030
1030
 
1031
+ if cmd == "/disconnect-cli":
1032
+ """Disconnect (kill) the local CLI runtime for the current run (or pending runtime)."""
1033
+ from kiwi_tui.runtime_agent import (
1034
+ get_running_pid_for_pending,
1035
+ get_running_pid_for_run,
1036
+ kill_pid,
1037
+ pid_path_for_pending,
1038
+ pid_path_for_run,
1039
+ )
1040
+
1041
+ killed = False
1042
+ pid: int | None = None
1043
+ target: str | None = None
1044
+
1045
+ try:
1046
+ if self.current_run_id:
1047
+ target = f"run {self.current_run_id}"
1048
+ pid = get_running_pid_for_run(self.current_run_id)
1049
+ if pid:
1050
+ killed = bool(kill_pid(int(pid)))
1051
+ if killed:
1052
+ # Remove pid file so the next /connect-cli spawns a fresh runtime.
1053
+ try:
1054
+ pid_path_for_run(self.current_run_id).unlink(missing_ok=True)
1055
+ except Exception:
1056
+ pass
1057
+ else:
1058
+ pending_id = getattr(self.app, "pending_runtime_id", None)
1059
+ if pending_id:
1060
+ target = f"pending {pending_id}"
1061
+ pid = get_running_pid_for_pending(pending_id)
1062
+ if pid:
1063
+ killed = bool(kill_pid(int(pid)))
1064
+ if killed:
1065
+ try:
1066
+ pid_path_for_pending(pending_id).unlink(missing_ok=True)
1067
+ except Exception:
1068
+ pass
1069
+ # Clear the session-scoped pending runtime id only when we killed it.
1070
+ try:
1071
+ self.app.pending_runtime_id = None
1072
+ except Exception:
1073
+ pass
1074
+ else:
1075
+ # No PID found => stale pending id in memory; clear it.
1076
+ try:
1077
+ self.app.pending_runtime_id = None
1078
+ except Exception:
1079
+ pass
1080
+ except Exception:
1081
+ killed = False
1082
+
1083
+ if pid and killed:
1084
+ toast(
1085
+ f"Disconnected CLI runtime ({target}) by killing PID {pid}.",
1086
+ title="/disconnect-cli",
1087
+ severity="information",
1088
+ )
1089
+ elif pid and not killed:
1090
+ toast(
1091
+ f"Failed to disconnect CLI runtime ({target}). Unable to kill PID {pid}.",
1092
+ title="/disconnect-cli",
1093
+ severity="error",
1094
+ )
1095
+ elif target:
1096
+ toast(
1097
+ f"No running CLI runtime found for {target}.",
1098
+ title="/disconnect-cli",
1099
+ severity="warning",
1100
+ )
1101
+ else:
1102
+ toast(
1103
+ "No CLI runtime is associated with this session yet.",
1104
+ title="/disconnect-cli",
1105
+ severity="warning",
1106
+ )
1107
+ return
1108
+
1031
1109
  if cmd == "/show-logs":
1032
1110
  self.open_runtime_logs()
1033
1111
  return
@@ -3051,6 +3129,69 @@ class DashboardScreen(Screen):
3051
3129
  # If we already mounted a placeholder row on submit, reuse it.
3052
3130
  status_widget_container = [getattr(self, "_streaming_widget_ref", None)]
3053
3131
 
3132
+ # Streaming state: main assistant text + latest status/tool output.
3133
+ main_text: str = ""
3134
+ status_text: str = ""
3135
+ tool_text: str = ""
3136
+ openai_text_stream_seen: bool = False
3137
+ text_task: asyncio.Task | None = None
3138
+
3139
+ _last_render: str = ""
3140
+ _last_render_at: float = 0.0
3141
+
3142
+ def _looks_like_tool_text(t: str) -> bool:
3143
+ tt = (t or "").strip()
3144
+ if not tt:
3145
+ return False
3146
+ return (
3147
+ tt.startswith("Calling tool:")
3148
+ or tt.startswith("Calling tool ")
3149
+ or "\nArguments:" in tt
3150
+ or tt.startswith("Arguments:")
3151
+ or tt.startswith("Function call")
3152
+ )
3153
+
3154
+ def _build_stream_markdown() -> str:
3155
+ base = (main_text or "").strip()
3156
+ if not openai_text_stream_seen:
3157
+ # Non-OpenAI providers stream their main output on the run_id topic.
3158
+ # Preserve the previous behavior: show whatever the backend is streaming.
3159
+ return base
3160
+
3161
+ extra_parts: list[str] = []
3162
+ if status_text.strip():
3163
+ extra_parts.append(status_text.strip())
3164
+ if tool_text.strip():
3165
+ extra_parts.append(tool_text.strip())
3166
+
3167
+ if not extra_parts:
3168
+ return base
3169
+ if not base:
3170
+ return "\n\n".join(extra_parts)
3171
+ return base + "\n\n" + "\n\n".join(extra_parts)
3172
+
3173
+ def _maybe_render(*, force: bool = False) -> None:
3174
+ nonlocal _last_render, _last_render_at
3175
+ try:
3176
+ now = time.monotonic()
3177
+ if not force and (now - _last_render_at) < 0.03:
3178
+ return
3179
+ rendered = _build_stream_markdown()
3180
+ if not rendered.strip():
3181
+ return
3182
+ if not force and rendered == _last_render:
3183
+ return
3184
+ status_widget_container[0] = self.update_streaming_message(
3185
+ {"blocks": [{"text": rendered}]},
3186
+ status_widget_container[0],
3187
+ )
3188
+ _last_render = rendered
3189
+ _last_render_at = now
3190
+ except Exception:
3191
+ # Do not break streaming if a render fails mid-refresh.
3192
+ return
3193
+
3194
+
3054
3195
  logger.info(f"Starting stream_results for {run_id}")
3055
3196
 
3056
3197
  def _remove_status_widget() -> None:
@@ -3184,34 +3325,58 @@ class DashboardScreen(Screen):
3184
3325
  return True
3185
3326
  return False
3186
3327
 
3328
+ def handle_text_message(data: dict) -> None:
3329
+ """Handle OpenAI *-text topic stream (assistant text)."""
3330
+ nonlocal main_text, openai_text_stream_seen
3331
+ if not isinstance(data, dict) or got_final_result:
3332
+ return
3333
+ if data.get("type") != "text":
3334
+ return
3335
+ text = str(data.get("text", "") or "")
3336
+ if not text.strip():
3337
+ return
3338
+ openai_text_stream_seen = True
3339
+ main_text = text
3340
+ _maybe_render()
3341
+
3187
3342
  def handle_status_message(data: dict) -> None:
3188
- """Handle SSE messages — status updates and completion signals."""
3343
+ """Handle SSE messages — provider-dependent streaming + status/tool updates."""
3344
+ nonlocal main_text, status_text, tool_text
3189
3345
  if not isinstance(data, dict) or got_final_result:
3190
3346
  return
3191
3347
 
3192
- # Plain-text status messages (type="status" set by client.py)
3348
+ # Plain-text messages (wrapped as type="status" by the SSE client).
3193
3349
  if data.get("type") == "status":
3194
- text = data.get("text", "")
3350
+ text = str(data.get("text", "") or "")
3195
3351
  text_lower = text.lower()
3196
3352
  logger.debug(f"SSE status: {text}")
3197
3353
 
3198
- # Show all non-empty status messages as progress
3354
+ if not openai_text_stream_seen:
3355
+ # Non-OpenAI providers stream their main output on run_id; preserve old behavior.
3356
+ main_text = text
3357
+ else:
3358
+ # OpenAI: keep the streamed answer from *-text and render status/tool separately.
3359
+ if _looks_like_tool_text(text):
3360
+ tool_text = text
3361
+ else:
3362
+ status_text = text
3363
+
3199
3364
  if text.strip():
3200
- status_widget_container[0] = self.update_streaming_message(
3201
- {"blocks": [{"text": text}]},
3202
- status_widget_container[0],
3203
- )
3365
+ _maybe_render()
3204
3366
 
3205
- # Detect completion from text signals (server sends these as plain text)
3367
+ # Detect completion from text signals (server sends these as plain text).
3206
3368
  if any(kw in text_lower for kw in ["finishing", "completed", "finished"]):
3207
3369
  logger.info(f"Completion signal from SSE text: {text}")
3208
3370
  asyncio.create_task(_try_fetch_final_result_async())
3209
3371
  return
3210
3372
 
3211
- # JSON status messages
3212
- status = data.get("status", "").lower()
3373
+ # JSON status messages (rare, but some server paths send them).
3374
+ status = str(data.get("status", "") or "").lower()
3213
3375
  if status:
3214
3376
  logger.info(f"SSE JSON status: {status}")
3377
+ if openai_text_stream_seen:
3378
+ status_text = f"Status: {status}"
3379
+ _maybe_render()
3215
3380
  if status in ["completed", "success", "finished", "error", "failed"]:
3216
3381
  asyncio.create_task(_try_fetch_final_result_async())
3217
3382
 
@@ -3239,11 +3404,30 @@ class DashboardScreen(Screen):
3239
3404
  client.stream_action_result(run_id, handle_status_message)
3240
3405
  )
3241
3406
 
3407
+ # OpenAI Responses streams assistant text to a separate topic: f"{run_id}-text".
3408
+ # Subscribe here; for non-OpenAI providers this is a no-op (no messages will be published).
3409
+ try:
3410
+ text_task = asyncio.create_task(
3411
+ client.stream_action_result(
3412
+ run_id,
3413
+ handle_text_message,
3414
+ topic=f"{run_id}-text",
3415
+ stop_on_terminal_status=False,
3416
+ plain_text_type="text",
3417
+ )
3418
+ )
3419
+ except Exception:
3420
+ text_task = None
3421
+
3422
+
3242
3423
  try:
3243
3424
  while not got_final_result:
3244
3425
  tasks: list[asyncio.Task] = [poll_task]
3245
3426
  if status_task is not None:
3246
3427
  tasks.append(status_task)
3428
+ if text_task is not None:
3429
+ tasks.append(text_task)
3430
+
3247
3431
 
3248
3432
  done, _pending = await asyncio.wait(
3249
3433
  tasks,
@@ -3264,6 +3448,15 @@ class DashboardScreen(Screen):
3264
3448
  logger.warning(f"SSE stream ended for {run_id}: {e}")
3265
3449
  status_task = None
3266
3450
 
3451
+ if text_task is not None and text_task in done:
3452
+ try:
3453
+ text_task.result()
3454
+ except asyncio.CancelledError:
3455
+ pass
3456
+ except Exception as e:
3457
+ logger.warning(f"SSE text stream ended for {run_id}: {e}")
3458
+ text_task = None
3459
+
3267
3460
  # Ensure polling completes (it exits when got_final_result becomes True).
3268
3461
  if not poll_task.done():
3269
3462
  await poll_task
@@ -3271,6 +3464,8 @@ class DashboardScreen(Screen):
3271
3464
  logger.info(f"Stream cancelled for {run_id}")
3272
3465
  if status_task is not None:
3273
3466
  status_task.cancel()
3467
+ if text_task is not None:
3468
+ text_task.cancel()
3274
3469
  poll_task.cancel()
3275
3470
  _remove_status_widget()
3276
3471
  self._set_streaming(False)
@@ -3295,6 +3490,13 @@ class DashboardScreen(Screen):
3295
3490
  except (asyncio.CancelledError, Exception):
3296
3491
  pass
3297
3492
 
3493
+ if text_task is not None and not text_task.done():
3494
+ text_task.cancel()
3495
+ try:
3496
+ await text_task
3497
+ except (asyncio.CancelledError, Exception):
3498
+ pass
3499
+
3298
3500
  # Clean up status widget if it still exists (we keep it when it becomes the final output).
3299
3501
  if status_widget_container[0]:
3300
3502
  _remove_status_widget()
@@ -41,6 +41,7 @@ SLASH_COMMANDS: list[SlashCommand] = [
41
41
 
42
42
  # Runtime (local CLI agent)
43
43
  SlashCommand("Runtime", "/connect-cli", "Tell the agent to connect to the local CLI", "/connect-cli"),
44
+ SlashCommand("Runtime", "/disconnect-cli", "Disconnect the local CLI runtime for the current run", "/disconnect-cli"),
44
45
  SlashCommand("Runtime", "/show-logs", "Show local CLI (runtime) logs", "/show-logs"),
45
46
  SlashCommand("Runtime", "/runtime", "Runtime commands (currently disabled)", "/runtime"),
46
47
 
@@ -417,6 +417,28 @@ _STATUS_WORDS_RAW: tuple[str, ...] = (
417
417
  "Alchemizing",
418
418
  "Treasure-hunting",
419
419
  "Dragon-wrangling",
420
+ "Mapping",
421
+ "Correlating",
422
+ "Quantifying",
423
+ "Cascading",
424
+ "Reconnoitering",
425
+ "Dissecting",
426
+ "Unifying",
427
+ "Segmenting",
428
+ "Partitioning",
429
+ "Deconstructing",
430
+ "Reframing",
431
+ "Bootstrapping",
432
+ "Factoring",
433
+ "Defragmenting",
434
+ "Elucidating",
435
+ "Decoupling",
436
+ "Vectorizing",
437
+ "Rasterizing",
438
+ "Hashing",
439
+ "Checkpointing",
440
+ "Backtracking",
441
+ "Demultiplexing",
420
442
  )
421
443
 
422
444
 
@@ -124,6 +124,9 @@ class ChatInput(TextArea):
124
124
  if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in {'"', "'"}:
125
125
  candidate = candidate[1:-1]
126
126
 
127
+ if sys.platform == "win32":
128
+ candidate = candidate.replace("\\ ", " ")
129
+
127
130
  if candidate.startswith("file://"):
128
131
  parsed = urlparse(candidate)
129
132
  if parsed.scheme != "file":
@@ -144,7 +147,7 @@ class ChatInput(TextArea):
144
147
  token_sets: list[list[str]] = []
145
148
 
146
149
  try:
147
- split_tokens = shlex.split(normalized, posix=True)
150
+ split_tokens = shlex.split(normalized, posix=(sys.platform != "win32"))
148
151
  except ValueError:
149
152
  split_tokens = []
150
153
  if split_tokens:
@@ -4,8 +4,6 @@ Every test that touches the filesystem through Kiwi's managers goes
4
4
  through the ``isolated_home`` fixture so that ``~/.kiwi/`` writes land in
5
5
  a tmp dir instead of polluting the developer's real home.
6
6
  """
7
- from __future__ import annotations
8
-
9
7
  import os
10
8
  from pathlib import Path
11
9
 
@@ -5,8 +5,6 @@ mirror what ``pipx install kiwi-code`` users experience. They are the
5
5
  first line of defense against regressions in argument parsing,
6
6
  imports, and packaging.
7
7
  """
8
- from __future__ import annotations
9
-
10
8
  import subprocess
11
9
  import sys
12
10
  from pathlib import Path
@@ -4,11 +4,8 @@ Walks every submodule of the three shipped packages and imports it.
4
4
  This is the cheapest way to catch syntax errors, circular imports and
5
5
  stale references before they reach end users via ``pipx install``.
6
6
  """
7
- from __future__ import annotations
8
-
9
7
  import importlib
10
8
  import pkgutil
11
-
12
9
  import pytest
13
10
 
14
11
  PACKAGES = ["kiwi_cli", "kiwi_tui", "kiwi_runtime"]
@@ -6,15 +6,11 @@ The goal of the re-exec is to make the kernel-reported process name
6
6
  We verify that by spawning a child that exec's through the symlink and
7
7
  prints its own ``comm``.
8
8
  """
9
- from __future__ import annotations
10
-
11
9
  import os
12
10
  import subprocess
13
11
  import sys
14
12
  from pathlib import Path
15
-
16
13
  import pytest
17
-
18
14
  pytestmark = pytest.mark.skipif(
19
15
  sys.platform == "win32",
20
16
  reason="Windows uses native kiwi.exe launcher; no re-exec needed",
@@ -1,9 +1,4 @@
1
- from __future__ import annotations
2
-
3
1
  from pathlib import Path
4
-
5
- import pytest
6
-
7
2
  from kiwi_tui.runtime_agent import MAX_RUNTIME_LOG_BYTES, _trim_file_to_tail_bytes
8
3
  from kiwi_tui.screens.runtime_logs import _tail_lines
9
4
 
@@ -1,9 +1,5 @@
1
- from __future__ import annotations
2
-
3
1
  import io
4
-
5
2
  import pytest
6
-
7
3
  from kiwi_cli.terminal_mode import TerminalModeArgs, TerminalModeError, validate_terminal_mode_args
8
4
  from kiwi_tui import main as kiwi_main
9
5
 
@@ -1,9 +1,6 @@
1
1
  """TokenManager tests."""
2
- from __future__ import annotations
3
-
4
2
  from pathlib import Path
5
3
 
6
-
7
4
  def test_no_tokens_when_fresh(isolated_home: Path) -> None:
8
5
  from kiwi_cli.auth import TokenManager
9
6
 
@@ -4,12 +4,9 @@ These exercise the real ``AutobotsTUI`` app the way a user would see it
4
4
  but drive it through Textual's Pilot so they run anywhere (including CI
5
5
  without a real terminal).
6
6
  """
7
- from __future__ import annotations
8
-
9
7
  import asyncio
10
8
  from pathlib import Path
11
9
  from types import SimpleNamespace
12
-
13
10
  import json
14
11
  import pytest
15
12
 
@@ -1,12 +1,8 @@
1
- from __future__ import annotations
2
-
3
1
  import asyncio
4
2
  import json
5
3
  from pathlib import Path
6
4
  from types import SimpleNamespace
7
-
8
5
  import pytest
9
-
10
6
  from kiwi_runtime.main import _LocalInteractivePTYBridge, _tui_managed_control_dir
11
7
  from kiwi_tui.runtime_agent import control_dir_from_meta
12
8
 
@@ -1,6 +1,5 @@
1
1
  from textual.css.stylesheet import Stylesheet
2
2
  from pathlib import Path
3
-
4
3
  from kiwi_tui.main import AutobotsTUI
5
4
  from kiwi_tui.screens.attach_content import AttachContentScreen
6
5
  from kiwi_tui.screens.command_result import CommandResultScreen
@@ -1,13 +1,8 @@
1
- from __future__ import annotations
2
-
3
1
  import os
4
2
  import shutil
5
3
  import subprocess
6
- import sys
7
4
  from pathlib import Path
8
-
9
5
  import pytest
10
-
11
6
  from kiwi_tui.worktrees import WorktreeError, ensure_worktree
12
7
 
13
8
 
@@ -61,7 +56,7 @@ def test_ensure_worktree_creates_under_dot_kiwi(tmp_path: Path) -> None:
61
56
  cwd=str(repo),
62
57
  text=True,
63
58
  )
64
- assert str(expected) in wt_list
59
+ assert expected.as_posix() in wt_list.replace("\\", "/")
65
60
 
66
61
 
67
62
  @pytest.mark.skipif(shutil.which("git") is None, reason="git is required for worktree tests")
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.431"
400
+ version = "0.0.433"
401
401
  source = { editable = "." }
402
402
  dependencies = [
403
403
  { name = "autobots-client" },
@@ -1,3 +0,0 @@
1
- highscore.json
2
- *.tmp
3
- __pycache__/
@@ -1,3 +0,0 @@
1
- # No dependencies needed on macOS/Linux.
2
- # On Windows, install curses support:
3
- windows-curses; platform_system == "Windows"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes