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.
Files changed (123) hide show
  1. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/client.py +39 -0
  4. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/commands.py +1 -0
  5. kiwi_code-0.0.503/src/kiwi_cli/subagents.py +383 -0
  6. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/main.py +13 -2
  7. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard.py +392 -17
  8. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard_css.py +38 -0
  9. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard_helpers.py +23 -0
  10. kiwi_code-0.0.503/src/kiwi_tui/screens/term_subagent_widgets.py +799 -0
  11. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/slash_commands.py +1 -0
  12. kiwi_code-0.0.503/tests/test_subagents.py +463 -0
  13. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_term_dashboard_ui.py +141 -6
  14. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_tui_palette.py +12 -1
  15. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/uv.lock +1 -1
  16. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/.github/workflows/publish.yml +0 -0
  17. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/.github/workflows/test.yml +0 -0
  18. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/.gitignore +0 -0
  19. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/.python-version +0 -0
  20. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/Makefile +0 -0
  21. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/README.md +0 -0
  22. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/docs/runtime_audit.html +0 -0
  23. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/docs/social_login_e2e.md +0 -0
  24. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/scripts/set_edition.py +0 -0
  25. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/scripts/ws_auth_check.py +0 -0
  26. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/common/__init__.py +0 -0
  27. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/common/banner.py +0 -0
  28. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/__init__.py +0 -0
  29. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/_edition.py +0 -0
  30. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/auth.py +0 -0
  31. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/autocode.py +0 -0
  32. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/checkpoints.py +0 -0
  33. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/cli.py +0 -0
  34. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/edition.py +0 -0
  35. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/human_questions.py +0 -0
  36. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/logger.py +0 -0
  37. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/models.py +0 -0
  38. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/oauth.py +0 -0
  39. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/runtime_manager.py +0 -0
  40. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/server.py +0 -0
  41. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_cli/terminal_mode.py +0 -0
  42. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/__init__.py +0 -0
  43. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/__main__.py +0 -0
  44. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/auth.py +0 -0
  45. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/checkpoints.py +0 -0
  46. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/cli.py +0 -0
  47. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/commands.py +0 -0
  48. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/constants.py +0 -0
  49. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/__init__.py +0 -0
  50. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/batch.py +0 -0
  51. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/codelens.py +0 -0
  52. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/context.py +0 -0
  53. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/handler.py +0 -0
  54. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/paths.py +0 -0
  55. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/read.py +0 -0
  56. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/search.py +0 -0
  57. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/text.py +0 -0
  58. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/upload.py +0 -0
  59. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/write.py +0 -0
  60. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/handlers.py +0 -0
  61. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/link.py +0 -0
  62. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/main.py +0 -0
  63. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/restricted.py +0 -0
  64. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/sessions.py +0 -0
  65. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/state.py +0 -0
  66. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/tui_bridge.py +0 -0
  67. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/ui.py +0 -0
  68. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_runtime/uploads.py +0 -0
  69. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/__init__.py +0 -0
  70. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/inline_file_picker.py +0 -0
  71. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/random_words.py +0 -0
  72. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/role_mode.py +0 -0
  73. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/runtime_agent.py +0 -0
  74. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/__init__.py +0 -0
  75. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/attach_content.py +0 -0
  76. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/command_result.py +0 -0
  77. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/detach_files.py +0 -0
  78. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/file_browser.py +0 -0
  79. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/help.py +0 -0
  80. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/id_picker.py +0 -0
  81. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/login.py +0 -0
  82. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  83. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  84. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/slash_picker.py +0 -0
  85. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
  86. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  87. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/status_words.py +0 -0
  88. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/term_app.py +0 -0
  89. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/ui_settings.py +0 -0
  90. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/widgets.py +0 -0
  91. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/src/kiwi_tui/worktrees.py +0 -0
  92. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/test_hello.py +0 -0
  93. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/__init__.py +0 -0
  94. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/conftest.py +0 -0
  95. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_account_server.py +0 -0
  96. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_autocode_actions.py +0 -0
  97. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_batch_fs_tool.py +0 -0
  98. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_checkpoints.py +0 -0
  99. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_cli_help.py +0 -0
  100. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_copy_path_fs_tool.py +0 -0
  101. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_fs_edit_fidelity.py +0 -0
  102. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_human_questions.py +0 -0
  103. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_imports.py +0 -0
  104. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_login_social.py +0 -0
  105. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_oauth.py +0 -0
  106. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_read_file_streaming.py +0 -0
  107. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_reexec_kiwi.py +0 -0
  108. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_role_mode_injection.py +0 -0
  109. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_run_steering.py +0 -0
  110. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_cleanup_per_account.py +0 -0
  111. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_connection.py +0 -0
  112. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_fixes.py +0 -0
  113. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_imports_on_windows.py +0 -0
  114. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_log_trimming.py +0 -0
  115. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_runtime_social_login.py +0 -0
  116. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_search_in_files_fs_tool.py +0 -0
  117. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_slash_commands.py +0 -0
  118. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_stats_command.py +0 -0
  119. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_terminal_mode.py +0 -0
  120. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_tokens.py +0 -0
  121. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_tui_interactive_runtime.py +0 -0
  122. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_unified_diff.py +0 -0
  123. {kiwi_code-0.0.502 → kiwi_code-0.0.503}/tests/test_worktrees.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kiwi-code
3
- Version: 0.0.502
3
+ Version: 0.0.503
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://kiwicode.ai
6
6
  Author-email: Anurag <anurag@meetkiwi.co>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.502"
3
+ version = "0.0.503"
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"
@@ -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
- super().__init__()
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
- bg = "#1E1E1E"
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"