kiwi-code 0.0.502__tar.gz → 0.0.503__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.502 → kiwi_code-0.0.503}/PKG-INFO +1 -1
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/pyproject.toml +1 -1
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/client.py +39 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/commands.py +1 -0
- kiwi_code-0.0.503/src/kiwi_cli/subagents.py +383 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/main.py +13 -2
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard.py +392 -17
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard_css.py +38 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard_helpers.py +23 -0
- kiwi_code-0.0.503/src/kiwi_tui/screens/term_subagent_widgets.py +799 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/slash_commands.py +1 -0
- kiwi_code-0.0.503/tests/test_subagents.py +463 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_term_dashboard_ui.py +141 -6
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_tui_palette.py +12 -1
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/uv.lock +1 -1
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/.gitignore +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/.python-version +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/Makefile +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/README.md +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/docs/runtime_audit.html +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/docs/social_login_e2e.md +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/scripts/set_edition.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/scripts/ws_auth_check.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/common/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/common/banner.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/_edition.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/autocode.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/edition.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/human_questions.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/oauth.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/auth.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/checkpoints.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/cli.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/commands.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/constants.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/batch.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/codelens.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/context.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/handler.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/paths.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/read.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/search.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/text.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/upload.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/write.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/handlers.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/link.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/restricted.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/sessions.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/state.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/tui_bridge.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/ui.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/uploads.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/role_mode.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/ui_settings.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/test_hello.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/__init__.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/conftest.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_account_server.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_autocode_actions.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_fs_edit_fidelity.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_human_questions.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_login_social.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_oauth.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_role_mode_injection.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_run_steering.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_cleanup_per_account.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_connection.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_fixes.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_imports_on_windows.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_social_login.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_stats_command.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_worktrees.py +0 -0
|
@@ -16,6 +16,7 @@ from pykiwiai.api.actions import (
|
|
|
16
16
|
async_run_action_v1_actions_id_async_run_post,
|
|
17
17
|
)
|
|
18
18
|
from pykiwiai.api.action_results import get_action_result_v1_action_results_id_get
|
|
19
|
+
from pykiwiai.api.subagents import list_subagents_of_run_v1_subagents_runs_parent_action_run_id_get
|
|
19
20
|
from pykiwiai.models.human_answers_request import HumanAnswersRequest
|
|
20
21
|
from pykiwiai.models.body_return_user_and_session_v1_auth_post import (
|
|
21
22
|
BodyReturnUserAndSessionV1AuthPost,
|
|
@@ -811,6 +812,44 @@ class AutobotsClientWrapper:
|
|
|
811
812
|
logger.error(f"Error answering questions on run {run_id}: {e}")
|
|
812
813
|
return 0, None, f"Error sending answer: {e}"
|
|
813
814
|
|
|
815
|
+
def list_subagents(self, run_id: str) -> tuple[int, Optional[dict], str]:
|
|
816
|
+
"""The subagents `run_id` has spawned, once each with its status:
|
|
817
|
+
`GET /v1/subagents/runs/{run_id}`.
|
|
818
|
+
|
|
819
|
+
Returns:
|
|
820
|
+
Tuple of (http_status, body, message); body is the `SubagentsOfRun` dict on 200.
|
|
821
|
+
"""
|
|
822
|
+
try:
|
|
823
|
+
if not isinstance(self.client, AuthenticatedClient):
|
|
824
|
+
return 401, None, "Authentication required"
|
|
825
|
+
response = list_subagents_of_run_v1_subagents_runs_parent_action_run_id_get.sync_detailed(
|
|
826
|
+
parent_action_run_id=run_id, client=self.client
|
|
827
|
+
)
|
|
828
|
+
code = int(response.status_code)
|
|
829
|
+
if code == 200 and response.parsed is not None and hasattr(response.parsed, "subagents"):
|
|
830
|
+
return 200, response.parsed.to_dict(), "ok"
|
|
831
|
+
return code, None, f"HTTP {code}"
|
|
832
|
+
except Exception as e:
|
|
833
|
+
logger.warning(f"Listing subagents of run {run_id} failed: {e}")
|
|
834
|
+
return 0, None, f"Error listing subagents: {e}"
|
|
835
|
+
|
|
836
|
+
def get_run_activity(self, run_id: str) -> tuple[int, list[dict]]:
|
|
837
|
+
"""A run's recent typed activity events, oldest first
|
|
838
|
+
(`GET /v1/server_sent_events/activity/{run_id}`), for a client that subscribes to
|
|
839
|
+
`{run_id}-activity` late. A backend without the activity stream answers 404.
|
|
840
|
+
"""
|
|
841
|
+
try:
|
|
842
|
+
if not isinstance(self.client, AuthenticatedClient):
|
|
843
|
+
return 401, []
|
|
844
|
+
response = self.client.get_httpx_client().get(f"/v1/server_sent_events/activity/{run_id}")
|
|
845
|
+
if response.status_code != 200:
|
|
846
|
+
return response.status_code, []
|
|
847
|
+
events = (response.json() or {}).get("events")
|
|
848
|
+
return 200, [e for e in events if isinstance(e, dict)] if isinstance(events, list) else []
|
|
849
|
+
except Exception as e:
|
|
850
|
+
logger.warning(f"Reading activity of run {run_id} failed: {e}")
|
|
851
|
+
return 0, []
|
|
852
|
+
|
|
814
853
|
def close(self) -> None:
|
|
815
854
|
"""Close the client connection."""
|
|
816
855
|
try:
|
|
@@ -770,6 +770,7 @@ Session:
|
|
|
770
770
|
/queue remove <n> Drop one waiting prompt, by its number in /queue list
|
|
771
771
|
/queue clear Drop every prompt waiting to run
|
|
772
772
|
/stats [7d|30d|90d|1y] Show run statistics: runs, tokens, spend (default: 7d)
|
|
773
|
+
/agents Show this run's subagents (also ctrl+g)
|
|
773
774
|
/usage [run_id] Show token usage for a run and its subagents (default: current run)
|
|
774
775
|
|
|
775
776
|
Files:
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""The subagents a run has spawned, as the TUI shows them.
|
|
2
|
+
|
|
3
|
+
`GET /v1/subagents/runs/{run_id}` is the source of truth: every subagent once, with its
|
|
4
|
+
status. The parent's typed activity stream (`{run_id}-activity`, where the backend has it)
|
|
5
|
+
makes the list live: `subagent.started` / `.progress` / `.finished` / `.message` events,
|
|
6
|
+
keyed by `child_run_id`. Older backends only publish display strings on the run topic
|
|
7
|
+
(`Spawned subagents: a, b`, `Subagent "a" finished: success`, `Subagent "a" sent a
|
|
8
|
+
message`); those are used as a signal to refetch, never shown as text.
|
|
9
|
+
|
|
10
|
+
Nothing here talks to the network or draws anything.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from typing import Any, Iterable
|
|
19
|
+
|
|
20
|
+
RUNNING = "running"
|
|
21
|
+
DONE = "done"
|
|
22
|
+
FAILED = "failed"
|
|
23
|
+
CANCELLED = "cancelled"
|
|
24
|
+
#: Filter chips, in display order.
|
|
25
|
+
CATEGORIES = (RUNNING, DONE, FAILED, CANCELLED)
|
|
26
|
+
|
|
27
|
+
_STATUS_CATEGORY = {
|
|
28
|
+
"processing": RUNNING,
|
|
29
|
+
"running": RUNNING,
|
|
30
|
+
"pending": RUNNING,
|
|
31
|
+
"request_cancel": RUNNING,
|
|
32
|
+
"waiting": RUNNING,
|
|
33
|
+
"success": DONE,
|
|
34
|
+
"completed": DONE,
|
|
35
|
+
"finished": DONE,
|
|
36
|
+
"error": FAILED,
|
|
37
|
+
"failed": FAILED,
|
|
38
|
+
"stuck": FAILED,
|
|
39
|
+
"cancelled": CANCELLED,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
ICONS = {RUNNING: "●", DONE: "✓", FAILED: "✗", CANCELLED: "⊘"}
|
|
43
|
+
|
|
44
|
+
# The display strings older backends publish on the parent's run topic.
|
|
45
|
+
_SPAWNED = re.compile(r"^Spawned subagents: (?P<names>.+)$")
|
|
46
|
+
_FINISHED = re.compile(r'^Subagent "(?P<name>.+)" finished: (?P<status>\S+)$')
|
|
47
|
+
_MESSAGE = re.compile(r'^Subagent "(?P<name>.+)" sent a message$')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def category_of(status: str | None) -> str:
|
|
51
|
+
"""running / done / failed / cancelled; a run without a status yet is starting."""
|
|
52
|
+
return _STATUS_CATEGORY.get(str(status or "").lower(), RUNNING)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def legacy_subagent_line(text: str) -> bool:
|
|
56
|
+
"""Whether a status line on the run topic is one of the subagent display strings."""
|
|
57
|
+
line = (text or "").strip()
|
|
58
|
+
return bool(_SPAWNED.match(line) or _FINISHED.match(line) or _MESSAGE.match(line))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _epoch(value: Any) -> float:
|
|
62
|
+
if isinstance(value, (int, float)):
|
|
63
|
+
return float(value)
|
|
64
|
+
if isinstance(value, datetime):
|
|
65
|
+
return value.timestamp()
|
|
66
|
+
if isinstance(value, str) and value:
|
|
67
|
+
try:
|
|
68
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
69
|
+
except ValueError:
|
|
70
|
+
return 0.0
|
|
71
|
+
return 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Subagent:
|
|
76
|
+
run_id: str
|
|
77
|
+
name: str
|
|
78
|
+
goal: str = ""
|
|
79
|
+
action_id: str = ""
|
|
80
|
+
status: str | None = None
|
|
81
|
+
error: str | None = None
|
|
82
|
+
group_id: str = ""
|
|
83
|
+
spawned_at: float = 0.0
|
|
84
|
+
#: When it finished, as seen by this client (for "finished newest first").
|
|
85
|
+
finished_at: float = 0.0
|
|
86
|
+
#: What it is doing now, from `subagent.progress`.
|
|
87
|
+
activity: str = ""
|
|
88
|
+
activity_at: float = 0.0
|
|
89
|
+
#: The last time it messaged its parent, from `subagent.message`.
|
|
90
|
+
messaged_at: float = 0.0
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def category(self) -> str:
|
|
94
|
+
return category_of(self.status)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def icon(self) -> str:
|
|
98
|
+
return ICONS[self.category]
|
|
99
|
+
|
|
100
|
+
def elapsed(self, now: float | None = None) -> float | None:
|
|
101
|
+
"""Seconds it has run (running) or ran (finished). None when unknown: no spawn
|
|
102
|
+
time, or it had finished before this client saw it end."""
|
|
103
|
+
if not self.spawned_at:
|
|
104
|
+
return None
|
|
105
|
+
if self.category != RUNNING:
|
|
106
|
+
if not self.finished_at:
|
|
107
|
+
return None
|
|
108
|
+
return max(0.0, self.finished_at - self.spawned_at)
|
|
109
|
+
return max(0.0, (now or time.time()) - self.spawned_at)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def format_elapsed(seconds: float | None) -> str:
|
|
113
|
+
if seconds is None:
|
|
114
|
+
return "\u2014"
|
|
115
|
+
seconds = int(seconds)
|
|
116
|
+
if seconds < 60:
|
|
117
|
+
return f"{seconds}s"
|
|
118
|
+
minutes, seconds = divmod(seconds, 60)
|
|
119
|
+
if minutes < 60:
|
|
120
|
+
return f"{minutes}m {seconds:02d}s"
|
|
121
|
+
hours, minutes = divmod(minutes, 60)
|
|
122
|
+
return f"{hours}h {minutes:02d}m"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Batch:
|
|
127
|
+
"""One `spawn_subagents` call: its subagents, in spawn order."""
|
|
128
|
+
|
|
129
|
+
group_id: str
|
|
130
|
+
spawned_at: float
|
|
131
|
+
subagents: list[Subagent] = field(default_factory=list)
|
|
132
|
+
#: 1 for the run's first batch, in spawn order.
|
|
133
|
+
number: int = 0
|
|
134
|
+
|
|
135
|
+
def counts(self) -> dict[str, int]:
|
|
136
|
+
return counts_of(self.subagents)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def counts_of(subagents: Iterable[Subagent]) -> dict[str, int]:
|
|
140
|
+
"""How many are running / done / failed / cancelled."""
|
|
141
|
+
counts = {c: 0 for c in CATEGORIES}
|
|
142
|
+
for s in subagents:
|
|
143
|
+
counts[s.category] += 1
|
|
144
|
+
return counts
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _attention_key(s: Subagent) -> tuple:
|
|
148
|
+
"""Failed first, then running (most recently active first), then the rest."""
|
|
149
|
+
rank = {FAILED: 0, RUNNING: 1, CANCELLED: 2, DONE: 3}[s.category]
|
|
150
|
+
recent = s.activity_at or s.spawned_at
|
|
151
|
+
return (rank, -recent, s.name)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _panel_key(s: Subagent) -> tuple:
|
|
155
|
+
"""Inside a batch: running, then failed, then finished newest first."""
|
|
156
|
+
rank = {RUNNING: 0, FAILED: 1, CANCELLED: 2, DONE: 2}[s.category]
|
|
157
|
+
return (rank, -(s.finished_at or 0.0), s.spawned_at, s.name)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SubagentBoard:
|
|
161
|
+
"""Every subagent of one parent run, merged from snapshots and live events."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, parent_run_id: str | None = None) -> None:
|
|
164
|
+
self.parent_run_id = parent_run_id
|
|
165
|
+
self._by_id: dict[str, Subagent] = {}
|
|
166
|
+
#: group_id -> index into the parent's `result.results` of the turn that spawned it.
|
|
167
|
+
self.turn_of_group: dict[str, int] = {}
|
|
168
|
+
self.version = 0
|
|
169
|
+
|
|
170
|
+
def __len__(self) -> int:
|
|
171
|
+
return len(self._by_id)
|
|
172
|
+
|
|
173
|
+
def get(self, run_id: str) -> Subagent | None:
|
|
174
|
+
return self._by_id.get(run_id)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def subagents(self) -> list[Subagent]:
|
|
178
|
+
return list(self._by_id.values())
|
|
179
|
+
|
|
180
|
+
def counts(self) -> dict[str, int]:
|
|
181
|
+
return counts_of(self._by_id.values())
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def running(self) -> int:
|
|
185
|
+
return self.counts()[RUNNING]
|
|
186
|
+
|
|
187
|
+
# ---- input ----
|
|
188
|
+
|
|
189
|
+
def apply_snapshot(self, payload: dict | None) -> bool:
|
|
190
|
+
"""Merge `GET /subagents/runs/{run_id}`. Statuses and names come from it; live-only
|
|
191
|
+
fields (activity, finished time) are kept. Returns whether anything changed."""
|
|
192
|
+
if not isinstance(payload, dict):
|
|
193
|
+
return False
|
|
194
|
+
changed = False
|
|
195
|
+
now = time.time()
|
|
196
|
+
for raw in payload.get("subagents") or []:
|
|
197
|
+
if not isinstance(raw, dict) or not raw.get("run_id"):
|
|
198
|
+
continue
|
|
199
|
+
run_id = str(raw["run_id"])
|
|
200
|
+
s = self._by_id.get(run_id)
|
|
201
|
+
seen_running = s is not None and s.category == RUNNING
|
|
202
|
+
if s is None:
|
|
203
|
+
s = Subagent(run_id=run_id, name=str(raw.get("name") or run_id))
|
|
204
|
+
self._by_id[run_id] = s
|
|
205
|
+
changed = True
|
|
206
|
+
fields = {
|
|
207
|
+
"name": str(raw.get("name") or s.name),
|
|
208
|
+
"goal": str(raw.get("goal") or s.goal),
|
|
209
|
+
"action_id": str(raw.get("action_id") or s.action_id),
|
|
210
|
+
"status": raw.get("status") or s.status,
|
|
211
|
+
"error": raw.get("error_message") or None,
|
|
212
|
+
"group_id": str(raw.get("group_id") or s.group_id),
|
|
213
|
+
"spawned_at": _epoch(raw.get("spawned_at")) or s.spawned_at,
|
|
214
|
+
}
|
|
215
|
+
for key, value in fields.items():
|
|
216
|
+
if getattr(s, key) != value:
|
|
217
|
+
setattr(s, key, value)
|
|
218
|
+
changed = True
|
|
219
|
+
# Only an end this client saw has a time; one that ended earlier has none.
|
|
220
|
+
if seen_running and s.category != RUNNING and not s.finished_at:
|
|
221
|
+
s.finished_at = now
|
|
222
|
+
if changed:
|
|
223
|
+
self.version += 1
|
|
224
|
+
return changed
|
|
225
|
+
|
|
226
|
+
def apply_activity(self, event: dict, *, turn_index: int | None = None) -> bool:
|
|
227
|
+
"""Apply one event from the parent's activity stream. `turn_index` is the parent
|
|
228
|
+
turn being streamed, so a batch started now is drawn under it."""
|
|
229
|
+
if not isinstance(event, dict):
|
|
230
|
+
return False
|
|
231
|
+
kind = event.get("type")
|
|
232
|
+
run_id = event.get("child_run_id")
|
|
233
|
+
if not run_id or kind not in (
|
|
234
|
+
"subagent.started", "subagent.progress", "subagent.finished", "subagent.message"
|
|
235
|
+
):
|
|
236
|
+
return False
|
|
237
|
+
run_id = str(run_id)
|
|
238
|
+
at = (event.get("at") or 0) / 1000.0 or time.time()
|
|
239
|
+
s = self._by_id.get(run_id)
|
|
240
|
+
if s is None:
|
|
241
|
+
s = Subagent(run_id=run_id, name=str(event.get("name") or run_id), spawned_at=at)
|
|
242
|
+
self._by_id[run_id] = s
|
|
243
|
+
if kind == "subagent.started":
|
|
244
|
+
s.name = str(event.get("name") or s.name)
|
|
245
|
+
s.goal = str(event.get("goal") or s.goal)
|
|
246
|
+
s.group_id = str(event.get("group_id") or s.group_id)
|
|
247
|
+
s.status = s.status or "processing"
|
|
248
|
+
s.spawned_at = s.spawned_at or at
|
|
249
|
+
if turn_index is not None and s.group_id:
|
|
250
|
+
self.turn_of_group.setdefault(s.group_id, turn_index)
|
|
251
|
+
elif kind == "subagent.progress":
|
|
252
|
+
s.activity = str(event.get("label") or "")
|
|
253
|
+
s.activity_at = at
|
|
254
|
+
elif kind == "subagent.finished":
|
|
255
|
+
ok = bool(event.get("ok"))
|
|
256
|
+
error = event.get("error")
|
|
257
|
+
# The event says ok or not; "cancelled" arrives as its error.
|
|
258
|
+
s.status = "success" if ok else ("cancelled" if error == "cancelled" else "error")
|
|
259
|
+
s.error = None if ok else (str(error) if error else None)
|
|
260
|
+
s.finished_at = at
|
|
261
|
+
s.activity = ""
|
|
262
|
+
elif kind == "subagent.message":
|
|
263
|
+
s.messaged_at = at
|
|
264
|
+
self.version += 1
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
def place_batches(self, turn_index: int) -> None:
|
|
268
|
+
"""Draw every batch not placed yet under turn `turn_index`."""
|
|
269
|
+
for s in self._by_id.values():
|
|
270
|
+
if s.group_id and s.group_id not in self.turn_of_group:
|
|
271
|
+
self.turn_of_group[s.group_id] = turn_index
|
|
272
|
+
|
|
273
|
+
# ---- views ----
|
|
274
|
+
|
|
275
|
+
def batches(self, *, turn_index: int | None = None) -> list[Batch]:
|
|
276
|
+
"""Spawn batches, newest first; only those under `turn_index` when given."""
|
|
277
|
+
groups: dict[str, Batch] = {}
|
|
278
|
+
for s in sorted(self._by_id.values(), key=lambda s: (s.spawned_at, s.name)):
|
|
279
|
+
gid = s.group_id or "_"
|
|
280
|
+
if turn_index is not None and self.turn_of_group.get(gid) != turn_index:
|
|
281
|
+
continue
|
|
282
|
+
batch = groups.get(gid)
|
|
283
|
+
if batch is None:
|
|
284
|
+
batch = groups[gid] = Batch(group_id=gid, spawned_at=s.spawned_at)
|
|
285
|
+
batch.subagents.append(s)
|
|
286
|
+
numbers = self._batch_numbers()
|
|
287
|
+
out = sorted(groups.values(), key=lambda b: b.spawned_at, reverse=True)
|
|
288
|
+
for b in out:
|
|
289
|
+
b.number = numbers.get(b.group_id, 0)
|
|
290
|
+
b.subagents.sort(key=_panel_key)
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
def _batch_numbers(self) -> dict[str, int]:
|
|
294
|
+
first: dict[str, float] = {}
|
|
295
|
+
for s in self._by_id.values():
|
|
296
|
+
gid = s.group_id or "_"
|
|
297
|
+
first[gid] = min(first.get(gid, s.spawned_at), s.spawned_at)
|
|
298
|
+
return {gid: i + 1 for i, gid in enumerate(sorted(first, key=lambda g: (first[g], g)))}
|
|
299
|
+
|
|
300
|
+
def of_turn(self, turn_index: int) -> list[Subagent]:
|
|
301
|
+
return [s for b in self.batches(turn_index=turn_index) for s in b.subagents]
|
|
302
|
+
|
|
303
|
+
def turns(self) -> set[int]:
|
|
304
|
+
"""Parent turns that have subagents drawn under them."""
|
|
305
|
+
return {t for g, t in self.turn_of_group.items() if any(s.group_id == g for s in self._by_id.values())}
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def attention(subagents: Iterable[Subagent], limit: int = 3) -> list[Subagent]:
|
|
309
|
+
"""The few rows worth showing inline: failed, then running, then the rest."""
|
|
310
|
+
return sorted(subagents, key=_attention_key)[:limit]
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def matches(s: Subagent, *, category: str | None = None, query: str = "") -> bool:
|
|
314
|
+
if category and s.category != category:
|
|
315
|
+
return False
|
|
316
|
+
q = query.strip().lower()
|
|
317
|
+
if q and q not in s.name.lower() and q not in s.goal.lower() and q not in (s.activity or "").lower():
|
|
318
|
+
return False
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class ActivityFeed:
|
|
323
|
+
"""A subagent's own activity stream as lines to show: tools it runs, notices, and the
|
|
324
|
+
text it is writing (one growing entry). Events already seen (same `src` and `seq`) are
|
|
325
|
+
dropped, so a replay and the live stream can overlap."""
|
|
326
|
+
|
|
327
|
+
def __init__(self, limit: int = 300) -> None:
|
|
328
|
+
self.limit = limit
|
|
329
|
+
self.lines: list[tuple[str, str]] = [] # (kind, text): kind in tool/fail/note/text/turn/agent
|
|
330
|
+
self._seen: set[tuple[str, int]] = set()
|
|
331
|
+
self._text_open = False
|
|
332
|
+
|
|
333
|
+
def _push(self, kind: str, text: str) -> None:
|
|
334
|
+
self._text_open = False
|
|
335
|
+
self.lines.append((kind, text))
|
|
336
|
+
del self.lines[: -self.limit]
|
|
337
|
+
|
|
338
|
+
def apply(self, event: dict) -> bool:
|
|
339
|
+
"""Apply one event; whether the lines changed."""
|
|
340
|
+
if not isinstance(event, dict):
|
|
341
|
+
return False
|
|
342
|
+
key = (str(event.get("src") or ""), int(event.get("seq") or 0))
|
|
343
|
+
if key[0] and key in self._seen:
|
|
344
|
+
return False
|
|
345
|
+
if key[0]:
|
|
346
|
+
self._seen.add(key)
|
|
347
|
+
kind = event.get("type")
|
|
348
|
+
label = event.get("label") or event.get("name") or ""
|
|
349
|
+
if kind == "text.delta":
|
|
350
|
+
text = str(event.get("text") or "")
|
|
351
|
+
if not text:
|
|
352
|
+
return False
|
|
353
|
+
if self._text_open and self.lines and self.lines[-1][0] == "text":
|
|
354
|
+
self.lines[-1] = ("text", self.lines[-1][1] + text)
|
|
355
|
+
else:
|
|
356
|
+
self._push("text", text)
|
|
357
|
+
self._text_open = True
|
|
358
|
+
return True
|
|
359
|
+
if kind == "tool.running":
|
|
360
|
+
self._push("tool", str(label))
|
|
361
|
+
elif kind == "tool.finished" and event.get("ok") is False:
|
|
362
|
+
self._push("fail", f"{label}: {event.get('error') or 'failed'}")
|
|
363
|
+
elif kind == "notice" and event.get("text"):
|
|
364
|
+
self._push("note", str(event["text"]))
|
|
365
|
+
elif kind == "turn.started":
|
|
366
|
+
self._push("turn", "Started")
|
|
367
|
+
elif kind == "turn.finished":
|
|
368
|
+
self._push("turn" if event.get("ok") else "fail", "Finished" if event.get("ok") else f"Failed: {event.get('error') or 'error'}")
|
|
369
|
+
elif kind == "subagent.started":
|
|
370
|
+
self._push("agent", f"Spawned {event.get('name') or 'a subagent'}")
|
|
371
|
+
elif kind == "subagent.finished":
|
|
372
|
+
self._push("agent", f"{event.get('name') or 'A subagent'} finished")
|
|
373
|
+
else:
|
|
374
|
+
return False
|
|
375
|
+
return True
|
|
376
|
+
|
|
377
|
+
def apply_legacy(self, text: str) -> bool:
|
|
378
|
+
"""A display string from a backend without the activity stream."""
|
|
379
|
+
line = " ".join((text or "").split())
|
|
380
|
+
if not line or (self.lines and self.lines[-1] == ("note", line)):
|
|
381
|
+
return False
|
|
382
|
+
self._push("note", line)
|
|
383
|
+
return True
|
|
@@ -175,7 +175,9 @@ class AutobotsTUI(App):
|
|
|
175
175
|
config: Runtime configuration (no persistence)
|
|
176
176
|
token_manager: Token manager instance
|
|
177
177
|
"""
|
|
178
|
-
|
|
178
|
+
# Painting with the terminal's own default colour needs ANSI colours passed through
|
|
179
|
+
# to the terminal; Textual otherwise converts them to a fixed RGB guess.
|
|
180
|
+
super().__init__(ansi_color=True if self.use_terminal_background() else None)
|
|
179
181
|
self.config: AppConfig = config or AppConfig()
|
|
180
182
|
|
|
181
183
|
try:
|
|
@@ -372,6 +374,13 @@ class AutobotsTUI(App):
|
|
|
372
374
|
# Fallback for older Textual versions that only expose `dark`.
|
|
373
375
|
return bool(getattr(self, "dark", True))
|
|
374
376
|
|
|
377
|
+
@staticmethod
|
|
378
|
+
def use_terminal_background() -> bool:
|
|
379
|
+
"""Kiwi's dark palette uses the terminal's own background colour, so the app meets
|
|
380
|
+
the terminal's window padding without a frame. `KIWI_TERMINAL_BACKGROUND=0` keeps
|
|
381
|
+
the fixed colour instead."""
|
|
382
|
+
return os.environ.get("KIWI_TERMINAL_BACKGROUND", "1").strip().lower() not in ("0", "false", "no", "off")
|
|
383
|
+
|
|
375
384
|
def get_css_variables(self) -> dict[str, str]:
|
|
376
385
|
variables = super().get_css_variables()
|
|
377
386
|
theme_name = str(getattr(self, "theme", "") or "").strip()
|
|
@@ -382,7 +391,9 @@ class AutobotsTUI(App):
|
|
|
382
391
|
use_kiwi_palette = (not theme_name) or (theme_name == self.DEFAULT_THEME)
|
|
383
392
|
if use_kiwi_palette:
|
|
384
393
|
if dark:
|
|
385
|
-
|
|
394
|
+
# The terminal's default background (a light theme keeps its own: its dark
|
|
395
|
+
# text could not be read on a dark terminal).
|
|
396
|
+
bg = "ansi_default" if self.use_terminal_background() else "#1E1E1E"
|
|
386
397
|
surface = "#252526"
|
|
387
398
|
raised = "#2D2D2D"
|
|
388
399
|
panel = "#333333"
|