kiwi-code 0.0.502__tar.gz → 0.0.504__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 (134) hide show
  1. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/cli.py +3 -0
  4. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/client.py +55 -0
  5. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/commands.py +1 -0
  6. kiwi_code-0.0.504/src/kiwi_cli/io_threads.py +35 -0
  7. kiwi_code-0.0.504/src/kiwi_cli/run_activity.py +473 -0
  8. kiwi_code-0.0.504/src/kiwi_cli/subagents.py +361 -0
  9. kiwi_code-0.0.504/src/kiwi_tui/latex_unicode.py +224 -0
  10. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/main.py +73 -10
  11. kiwi_code-0.0.504/src/kiwi_tui/screens/term_activity_view.py +290 -0
  12. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_dashboard.py +914 -150
  13. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_dashboard_css.py +100 -1
  14. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_dashboard_helpers.py +163 -17
  15. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_dashboard_widgets.py +44 -3
  16. kiwi_code-0.0.504/src/kiwi_tui/screens/term_status_line.py +107 -0
  17. kiwi_code-0.0.504/src/kiwi_tui/screens/term_subagent_widgets.py +776 -0
  18. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/slash_commands.py +3 -1
  19. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/widgets.py +3 -0
  20. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/conftest.py +12 -0
  21. kiwi_code-0.0.504/tests/test_command_tips.py +77 -0
  22. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_human_questions.py +16 -5
  23. kiwi_code-0.0.504/tests/test_io_threads.py +43 -0
  24. kiwi_code-0.0.504/tests/test_math_and_tables.py +94 -0
  25. kiwi_code-0.0.504/tests/test_run_activity.py +686 -0
  26. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_run_steering.py +10 -5
  27. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_slash_commands.py +5 -2
  28. kiwi_code-0.0.504/tests/test_status_line.py +96 -0
  29. kiwi_code-0.0.504/tests/test_subagents.py +454 -0
  30. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_term_dashboard_ui.py +141 -6
  31. kiwi_code-0.0.504/tests/test_toasts.py +49 -0
  32. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_tui_palette.py +42 -3
  33. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/uv.lock +1 -1
  34. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/.github/workflows/publish.yml +0 -0
  35. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/.github/workflows/test.yml +0 -0
  36. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/.gitignore +0 -0
  37. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/.python-version +0 -0
  38. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/Makefile +0 -0
  39. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/README.md +0 -0
  40. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/docs/runtime_audit.html +0 -0
  41. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/docs/social_login_e2e.md +0 -0
  42. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/scripts/set_edition.py +0 -0
  43. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/scripts/ws_auth_check.py +0 -0
  44. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/common/__init__.py +0 -0
  45. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/common/banner.py +0 -0
  46. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/__init__.py +0 -0
  47. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/_edition.py +0 -0
  48. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/auth.py +0 -0
  49. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/autocode.py +0 -0
  50. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/checkpoints.py +0 -0
  51. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/edition.py +0 -0
  52. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/human_questions.py +0 -0
  53. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/logger.py +0 -0
  54. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/models.py +0 -0
  55. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/oauth.py +0 -0
  56. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/runtime_manager.py +0 -0
  57. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/server.py +0 -0
  58. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_cli/terminal_mode.py +0 -0
  59. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/__init__.py +0 -0
  60. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/__main__.py +0 -0
  61. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/auth.py +0 -0
  62. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/checkpoints.py +0 -0
  63. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/cli.py +0 -0
  64. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/commands.py +0 -0
  65. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/constants.py +0 -0
  66. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/__init__.py +0 -0
  67. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/batch.py +0 -0
  68. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/codelens.py +0 -0
  69. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/context.py +0 -0
  70. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/handler.py +0 -0
  71. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/paths.py +0 -0
  72. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/read.py +0 -0
  73. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/search.py +0 -0
  74. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/text.py +0 -0
  75. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/upload.py +0 -0
  76. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/fs/write.py +0 -0
  77. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/handlers.py +0 -0
  78. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/link.py +0 -0
  79. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/main.py +0 -0
  80. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/restricted.py +0 -0
  81. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/sessions.py +0 -0
  82. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/state.py +0 -0
  83. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/tui_bridge.py +0 -0
  84. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/ui.py +0 -0
  85. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_runtime/uploads.py +0 -0
  86. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/__init__.py +0 -0
  87. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/inline_file_picker.py +0 -0
  88. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/random_words.py +0 -0
  89. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/role_mode.py +0 -0
  90. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/runtime_agent.py +0 -0
  91. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/__init__.py +0 -0
  92. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/attach_content.py +0 -0
  93. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/command_result.py +0 -0
  94. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/detach_files.py +0 -0
  95. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/file_browser.py +0 -0
  96. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/help.py +0 -0
  97. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/id_picker.py +0 -0
  98. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/login.py +0 -0
  99. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  100. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  101. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/slash_picker.py +0 -0
  102. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  103. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/status_words.py +0 -0
  104. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/term_app.py +0 -0
  105. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/ui_settings.py +0 -0
  106. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/src/kiwi_tui/worktrees.py +0 -0
  107. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/test_hello.py +0 -0
  108. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/__init__.py +0 -0
  109. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_account_server.py +0 -0
  110. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_autocode_actions.py +0 -0
  111. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_batch_fs_tool.py +0 -0
  112. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_checkpoints.py +0 -0
  113. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_cli_help.py +0 -0
  114. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_copy_path_fs_tool.py +0 -0
  115. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_fs_edit_fidelity.py +0 -0
  116. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_imports.py +0 -0
  117. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_login_social.py +0 -0
  118. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_oauth.py +0 -0
  119. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_read_file_streaming.py +0 -0
  120. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_reexec_kiwi.py +0 -0
  121. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_role_mode_injection.py +0 -0
  122. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_cleanup_per_account.py +0 -0
  123. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_connection.py +0 -0
  124. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_fixes.py +0 -0
  125. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_imports_on_windows.py +0 -0
  126. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_log_trimming.py +0 -0
  127. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_runtime_social_login.py +0 -0
  128. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_search_in_files_fs_tool.py +0 -0
  129. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_stats_command.py +0 -0
  130. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_terminal_mode.py +0 -0
  131. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_tokens.py +0 -0
  132. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_tui_interactive_runtime.py +0 -0
  133. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/tests/test_unified_diff.py +0 -0
  134. {kiwi_code-0.0.502 → kiwi_code-0.0.504}/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.504
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.504"
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"
@@ -137,10 +137,13 @@ def _get_client() -> AuthenticatedClient:
137
137
  typer.echo("Not authenticated. Run `kiwi login` first.", err=True)
138
138
  raise typer.Exit(code=1)
139
139
 
140
+ from kiwi_cli.client import API_TIMEOUT
141
+
140
142
  return AuthenticatedClient(
141
143
  base_url=_backend_url(),
142
144
  token=str(access),
143
145
  raise_on_unexpected_status=False,
146
+ timeout=API_TIMEOUT,
144
147
  )
145
148
 
146
149
 
@@ -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,
@@ -28,6 +29,11 @@ from .models import AuthTokens, LoginCredentials
28
29
  from .human_questions import WAITING_STATUS, human_event_type, parse_questions
29
30
 
30
31
 
32
+ # Every API call gives up in the end: a request that never answers would otherwise hold its
33
+ # thread, and the app, open for ever. Uploads get the long read; the live stream has its own.
34
+ API_TIMEOUT = httpx.Timeout(120.0, connect=15.0)
35
+
36
+
31
37
  class AutobotsClientWrapper:
32
38
  """Wrapper around autobots_client for API calls."""
33
39
 
@@ -56,12 +62,14 @@ class AutobotsClientWrapper:
56
62
  base_url=base_url,
57
63
  token=token,
58
64
  raise_on_unexpected_status=False,
65
+ timeout=API_TIMEOUT,
59
66
  )
60
67
  logger.info(f"Initialized authenticated client for {base_url}")
61
68
  else:
62
69
  self.client = Client(
63
70
  base_url=base_url,
64
71
  raise_on_unexpected_status=False,
72
+ timeout=API_TIMEOUT,
65
73
  )
66
74
  logger.info(f"Initialized unauthenticated client for {base_url}")
67
75
 
@@ -76,6 +84,7 @@ class AutobotsClientWrapper:
76
84
  base_url=self.base_url,
77
85
  token=self.access_token,
78
86
  raise_on_unexpected_status=False,
87
+ timeout=API_TIMEOUT,
79
88
  )
80
89
  logger.info("Client updated with new access token")
81
90
 
@@ -640,6 +649,7 @@ class AutobotsClientWrapper:
640
649
  topic: str | None = None,
641
650
  stop_on_terminal_status: bool = True,
642
651
  plain_text_type: str = "status",
652
+ on_open: Callable[[], None] | None = None,
643
653
  ) -> None:
644
654
  """Stream action run updates via Server-Sent Events.
645
655
 
@@ -662,6 +672,8 @@ class AutobotsClientWrapper:
662
672
  `status` field. Disable for topics that don't send status (e.g. *-text).
663
673
  plain_text_type: When a frame is not valid JSON, wrap it as
664
674
  `{'type': plain_text_type, 'text': <payload>}`.
675
+ on_open: Called once the stream is connected, e.g. to read back what was
676
+ published before it was.
665
677
  """
666
678
  topic = topic or run_id
667
679
  sse_endpoint = f"{self.base_url}/v1/server_sent_events/stream/{topic}"
@@ -683,6 +695,11 @@ class AutobotsClientWrapper:
683
695
  if response.status_code != 200:
684
696
  logger.error(f"SSE connection failed with status {response.status_code}")
685
697
  return
698
+ if on_open is not None:
699
+ try:
700
+ on_open()
701
+ except Exception as e:
702
+ logger.warning(f"SSE on_open failed: {e}")
686
703
 
687
704
  decoder = codecs.getincrementaldecoder("utf-8")()
688
705
  buffer = ""
@@ -811,6 +828,44 @@ class AutobotsClientWrapper:
811
828
  logger.error(f"Error answering questions on run {run_id}: {e}")
812
829
  return 0, None, f"Error sending answer: {e}"
813
830
 
831
+ def list_subagents(self, run_id: str) -> tuple[int, Optional[dict], str]:
832
+ """The subagents `run_id` has spawned, once each with its status:
833
+ `GET /v1/subagents/runs/{run_id}`.
834
+
835
+ Returns:
836
+ Tuple of (http_status, body, message); body is the `SubagentsOfRun` dict on 200.
837
+ """
838
+ try:
839
+ if not isinstance(self.client, AuthenticatedClient):
840
+ return 401, None, "Authentication required"
841
+ response = list_subagents_of_run_v1_subagents_runs_parent_action_run_id_get.sync_detailed(
842
+ parent_action_run_id=run_id, client=self.client
843
+ )
844
+ code = int(response.status_code)
845
+ if code == 200 and response.parsed is not None and hasattr(response.parsed, "subagents"):
846
+ return 200, response.parsed.to_dict(), "ok"
847
+ return code, None, f"HTTP {code}"
848
+ except Exception as e:
849
+ logger.warning(f"Listing subagents of run {run_id} failed: {e}")
850
+ return 0, None, f"Error listing subagents: {e}"
851
+
852
+ def get_run_activity(self, run_id: str) -> tuple[int, list[dict]]:
853
+ """A run's recent typed activity events, oldest first
854
+ (`GET /v1/server_sent_events/activity/{run_id}`), for a client that subscribes to
855
+ `{run_id}-activity` late. A backend without the activity stream answers 404.
856
+ """
857
+ try:
858
+ if not isinstance(self.client, AuthenticatedClient):
859
+ return 401, []
860
+ response = self.client.get_httpx_client().get(f"/v1/server_sent_events/activity/{run_id}")
861
+ if response.status_code != 200:
862
+ return response.status_code, []
863
+ events = (response.json() or {}).get("events")
864
+ return 200, [e for e in events if isinstance(e, dict)] if isinstance(events, list) else []
865
+ except Exception as e:
866
+ logger.warning(f"Reading activity of run {run_id} failed: {e}")
867
+ return 0, []
868
+
814
869
  def close(self) -> None:
815
870
  """Close the client connection."""
816
871
  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,35 @@
1
+ """Where the TUI's blocking calls run: one daemon thread each.
2
+
3
+ asyncio runs `asyncio.to_thread` and Textual's thread workers on the loop's default
4
+ executor, whose threads Python waits for on exit, up to five minutes. A request still
5
+ waiting on the network when the user quits would then keep the closed app alive. These
6
+ threads end with the app; the calls they make are bounded by their own timeouts.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from concurrent.futures import Future, ThreadPoolExecutor
12
+ from typing import Any, Callable
13
+
14
+
15
+ class DaemonThreadExecutor(ThreadPoolExecutor):
16
+ """Runs each call on a thread of its own that does not hold the process open. A
17
+ `ThreadPoolExecutor` only so asyncio accepts it as the default; it keeps no pool,
18
+ whose threads Python would wait for."""
19
+
20
+ def submit(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Future:
21
+ future: Future = Future()
22
+
23
+ def run() -> None:
24
+ if not future.set_running_or_notify_cancel():
25
+ return
26
+ try:
27
+ future.set_result(fn(*args, **kwargs))
28
+ except BaseException as exc: # noqa: BLE001 - handed to whoever awaits it
29
+ future.set_exception(exc)
30
+
31
+ threading.Thread(target=run, name="kiwi-io", daemon=True).start()
32
+ return future
33
+
34
+ def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
35
+ """Nothing to wait for: the threads end with the process."""
@@ -0,0 +1,473 @@
1
+ """A run's own turns as the TUI shows them, read off the backend's typed activity stream
2
+ (`{run_id}-activity`), laid out the way Agent Studio lays them out: the answer as it
3
+ happened (text, tool calls, notices and thinking in order), then one summary line.
4
+
5
+ The stream sends one JSON event per message: `turn.started/finished`, `step.started`,
6
+ `text.delta`, `reasoning.delta`, `tool.started/args.delta/ready/running/finished`,
7
+ `notice`, plus `subagent.*` and `question.*`, which other parts of the TUI handle. A
8
+ client that connects late reads the run's recent events back first
9
+ (`GET /v1/server_sent_events/activity/{run_id}`); that read-back and the live stream
10
+ overlap, so an event already seen (same `src` and `seq`) is dropped.
11
+
12
+ Memory stays bounded however long a run goes: each turn keeps its latest steps only,
13
+ the text of its latest thinking only, and the trace keeps its latest lines only.
14
+
15
+ Nothing here talks to the network or draws anything.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from collections import deque
21
+ from dataclasses import dataclass, field
22
+ from typing import Iterable
23
+
24
+ #: Sent only by the process running a turn. While a turn is open, these from another
25
+ #: process are an earlier attempt's (the backend retried the turn while it still ran).
26
+ TURN_OWN = frozenset({
27
+ "turn.finished", "step.started", "text.delta", "reasoning.delta",
28
+ "tool.started", "tool.args.delta", "tool.ready", "tool.running", "tool.finished",
29
+ })
30
+
31
+ #: The steps a turn keeps; older ones are counted, not kept.
32
+ TURN_ITEMS = 300
33
+ #: The tool calls of a turn whose arguments are kept, for their details; the latest only.
34
+ ARGS_KEPT = 50
35
+ ARGS_CHARS = 2000
36
+ NOTES_KEPT = 3
37
+ #: The thinking blocks of a turn whose text is kept, and how much of each.
38
+ THOUGHTS_KEPT = 20
39
+ THOUGHT_CHARS = 8000
40
+ #: The trace lines kept for the trace panel.
41
+ TRACE_LINES = 200
42
+ _TRACE_TEXT = 220
43
+ _MAX_EVENTS = 3000
44
+ _KEPT_TURNS = 8
45
+
46
+
47
+ @dataclass
48
+ class ToolLine:
49
+ kind = "tool"
50
+ call_id: str
51
+ name: str
52
+ step: int | None = None
53
+ label: str = ""
54
+ state: str = "forming" # forming / ready / running / ok / fail
55
+ parent: str | None = None
56
+ hosted: bool = False
57
+ duration_ms: int | None = None
58
+ error: str = ""
59
+ note: str = "" # its latest notice, shown while it runs
60
+ notes: list[str] = field(default_factory=list) # its latest few, for its details
61
+ args: str = "" # the server's preview of its arguments, as indented JSON
62
+ args_chars: int | None = None
63
+ args_truncated: bool = False
64
+
65
+
66
+ @dataclass
67
+ class Block:
68
+ kind: str # text / reasoning / notice
69
+ step: int | None = None
70
+ text: str = ""
71
+ chars: int = 0 # all of it, however much of `text` is kept
72
+ id: int = 0 # numbers a turn's blocks, so one can be opened on screen
73
+ dropped: bool = False # its text was let go, to keep the latest turns' thinking
74
+
75
+
76
+ @dataclass
77
+ class TurnView:
78
+ key: tuple[str, int]
79
+ src: str | None
80
+ name: str = ""
81
+ started_at: int = 0
82
+ finished: dict | None = None # {"ok", "error", "at"}
83
+ items: list = field(default_factory=list) # ToolLine / Block, in the order they happened
84
+ tools: dict[str, ToolLine] = field(default_factory=dict)
85
+ tool_total: int = 0
86
+ dropped: int = 0 # steps no longer kept
87
+ step: int | None = None # the model call it is on
88
+ text_chars: int = 0 # all the answer text it has streamed
89
+ #: How much of that text the screen has revealed so far: text arrives in bursts, and
90
+ #: the screen shows it at a steady pace. None (the default) shows it all.
91
+ revealed: int | None = None
92
+ _with_args: deque = field(default_factory=deque, repr=False)
93
+ _thoughts: deque = field(default_factory=deque, repr=False)
94
+ _blocks: int = 0
95
+
96
+ def new_block(self, kind: str, step: int | None) -> "Block":
97
+ self._blocks += 1
98
+ block = Block(kind=kind, step=step, id=self._blocks)
99
+ self._add(block)
100
+ if kind == "reasoning":
101
+ self._thoughts.append(block)
102
+ while len(self._thoughts) > THOUGHTS_KEPT:
103
+ old = self._thoughts.popleft()
104
+ old.text, old.dropped = "", True
105
+ return block
106
+
107
+ @property
108
+ def has_text(self) -> bool:
109
+ return any(isinstance(i, Block) and i.kind == "text" and i.text.strip() for i in self.items)
110
+
111
+ def doing(self) -> tuple[str, str]:
112
+ """What the turn is doing now, for the status line: (kind, text), kind one of
113
+ tool / thinking / writing / working."""
114
+ running = [t for t in self.tools.values() if t.state == "running" and not t.parent]
115
+ if running:
116
+ tool = running[-1]
117
+ return "tool", tool.label or tool.name
118
+ last = self.items[-1] if self.items else None
119
+ if isinstance(last, Block) and last.kind == "reasoning":
120
+ return "thinking", "Thinking"
121
+ if isinstance(last, Block) and last.kind == "text":
122
+ return "writing", "Writing"
123
+ if isinstance(last, ToolLine) and last.state in ("forming", "ready"):
124
+ return "tool", f"Preparing {last.label or last.name}"
125
+ return "working", "Working"
126
+
127
+ @property
128
+ def steps_key(self) -> str:
129
+ """What opens or closes this turn's steps on screen."""
130
+ return f"steps:{self.key[0]}:{self.key[1]}"
131
+
132
+ @property
133
+ def has_steps(self) -> bool:
134
+ """Whether there is more to the turn than its answer."""
135
+ return any(not (isinstance(i, Block) and i.kind == "text") for i in self.items) or self.dropped > 0
136
+
137
+ def summary(self) -> str:
138
+ """"Used 3 tools · 12.4s", as Agent Studio ends a finished turn."""
139
+ if self.finished is None:
140
+ return ""
141
+ took = _took(max(0, (self.finished.get("at") or 0) - (self.started_at or 0)))
142
+ n = self.tool_total
143
+ used = f"Used {n} tool{'s' if n != 1 else ''} · {took}" if n else took
144
+ if self.finished.get("ok"):
145
+ return used
146
+ return f"{used} · {self.finished.get('error') or 'failed'}"
147
+
148
+ def keep_args(self, tool: "ToolLine", args: object, chars: object, truncated: object) -> None:
149
+ """Keep a call's argument preview; the oldest calls give theirs up."""
150
+ try:
151
+ text = json.dumps(args, indent=2, ensure_ascii=False, default=str) if args else ""
152
+ except (TypeError, ValueError):
153
+ text = str(args)
154
+ tool.args = text if len(text) <= ARGS_CHARS else text[: ARGS_CHARS - 1] + "…"
155
+ tool.args_chars = chars if isinstance(chars, int) else None
156
+ tool.args_truncated = bool(truncated)
157
+ if tool.call_id not in self._with_args:
158
+ self._with_args.append(tool.call_id)
159
+ while len(self._with_args) > ARGS_KEPT:
160
+ old = self.tools.get(self._with_args.popleft())
161
+ if old is not None:
162
+ old.args = ""
163
+
164
+ def _add(self, item) -> None:
165
+ self.items.append(item)
166
+ if len(self.items) > TURN_ITEMS:
167
+ old = self.items.pop(0)
168
+ self.dropped += 1
169
+ if isinstance(old, ToolLine):
170
+ self.tools.pop(old.call_id, None)
171
+
172
+
173
+ @dataclass
174
+ class TraceLine:
175
+ seq: int
176
+ at: int
177
+ type: str
178
+ what: str
179
+ count: int = 1
180
+
181
+
182
+ def _took(ms: int) -> str:
183
+ secs = ms / 1000
184
+ if secs < 60:
185
+ return f"{secs:.1f}s"
186
+ if secs < 3600:
187
+ return f"{int(secs // 60)}m {int(secs % 60)}s"
188
+ return f"{int(secs // 3600)}h {int(secs % 3600 // 60)}m"
189
+
190
+
191
+ def _event_key(event: dict) -> tuple[str, int]:
192
+ try:
193
+ return str(event.get("src") or ""), int(event.get("seq") or 0)
194
+ except (TypeError, ValueError):
195
+ return str(event.get("src") or ""), 0
196
+
197
+
198
+ def _order(event: dict) -> tuple:
199
+ src, seq = _event_key(event)
200
+ return (event.get("at") or 0, src, seq)
201
+
202
+
203
+ def _one_line(text: object, limit: int = _TRACE_TEXT) -> str:
204
+ line = " ".join(str(text or "").split())
205
+ return line if len(line) <= limit else line[: limit - 1] + "…"
206
+
207
+
208
+ def describe(event: dict) -> str:
209
+ """One line on what an event says, as Agent Studio's trace shows it."""
210
+ kind = str(event.get("type") or "")
211
+ if kind.endswith(".delta") or kind == "notice":
212
+ return _one_line(event.get("text"))
213
+ if kind.startswith("subagent."):
214
+ who = event.get("name") or f"…{str(event.get('child_run_id') or '')[-6:]}"
215
+ if kind == "subagent.progress":
216
+ what = event.get("label")
217
+ elif kind == "subagent.finished":
218
+ what = "ok" if event.get("ok") else (event.get("error") or "failed")
219
+ elif kind == "subagent.started":
220
+ what = event.get("goal")
221
+ else:
222
+ what = "sent a message"
223
+ return _one_line(f"{who} · {what or ''}")
224
+ if kind.startswith("tool."):
225
+ text = str(event.get("label") or event.get("name") or "")
226
+ if kind == "tool.finished":
227
+ ms = event.get("duration_ms")
228
+ text += f" · {'ok' if event.get('ok') else 'failed'} · {ms if ms is not None else '–'} ms"
229
+ return _one_line(text)
230
+ if kind.startswith("question."):
231
+ n = len(event.get("questions") or event.get("question_ids") or [])
232
+ return _one_line(f"{n} question{'s' if n != 1 else ''}" + (f" · {event['status']}" if event.get("status") else ""))
233
+ if kind == "step.started":
234
+ return f"step {event.get('step')}"
235
+ if kind == "turn.started":
236
+ return _one_line(event.get("name"))
237
+ if kind == "turn.finished":
238
+ return "ok" if event.get("ok") else _one_line(event.get("error") or "failed")
239
+ return ""
240
+
241
+
242
+ class RunActivityView:
243
+ """The turns of one run, built from its activity events; which of them the streaming
244
+ row follows; and a short trace of the latest events."""
245
+
246
+ def __init__(self) -> None:
247
+ self._events: list[dict] = []
248
+ self._seen: set[tuple[str, int]] = set()
249
+ self._turns: list[TurnView] = []
250
+ self._open: TurnView | None = None
251
+ self._stale: set[tuple[str, int]] = set()
252
+ self._followed: tuple[str, int] | None = None
253
+ self._following = False
254
+ self.trace: deque[TraceLine] = deque(maxlen=TRACE_LINES)
255
+ self.trace_start: int | None = None
256
+ #: Counts every change, so a redraw can tell whether there is anything new.
257
+ self.version = 0
258
+
259
+ # ---- input ----
260
+
261
+ def take(self, event: object) -> bool:
262
+ """Add one event; whether it was new."""
263
+ if not isinstance(event, dict) or not event.get("type"):
264
+ return False
265
+ key = _event_key(event)
266
+ if key[0]:
267
+ if key in self._seen:
268
+ return False
269
+ self._seen.add(key)
270
+ in_order = not self._events or _order(event) >= _order(self._events[-1])
271
+ self._events.append(event)
272
+ self._trace(event)
273
+ if len(self._events) > _MAX_EVENTS:
274
+ del self._events[: len(self._events) - _MAX_EVENTS]
275
+ self._seen = {_event_key(e) for e in self._events}
276
+ in_order = False
277
+ if in_order:
278
+ self._apply(event)
279
+ else:
280
+ self._rebuild()
281
+ self.version += 1
282
+ return True
283
+
284
+ def take_all(self, events: Iterable[object]) -> bool:
285
+ """Add a batch, such as a read-back; whether anything was new."""
286
+ fresh = []
287
+ for e in events:
288
+ if not isinstance(e, dict) or not e.get("type"):
289
+ continue
290
+ key = _event_key(e)
291
+ if key[0] and key in self._seen:
292
+ continue
293
+ if key[0]:
294
+ self._seen.add(key)
295
+ fresh.append(e)
296
+ if not fresh:
297
+ return False
298
+ self._events.extend(fresh)
299
+ self._events.sort(key=_order)
300
+ del self._events[: max(0, len(self._events) - _MAX_EVENTS)]
301
+ for e in sorted(fresh, key=_order):
302
+ self._trace(e)
303
+ self._rebuild()
304
+ self.version += 1
305
+ return True
306
+
307
+ # ---- following ----
308
+
309
+ def start_following(self) -> None:
310
+ """From now on the row follows a turn that has not already finished: what the
311
+ read-back holds of earlier turns is not this prompt's."""
312
+ self._stale = {t.key for t in self._turns if t.finished is not None}
313
+ self._followed = None
314
+ self._following = True
315
+ self.version += 1
316
+
317
+ def follow_next(self) -> None:
318
+ """The run has moved on to a newer turn: follow the next one that has not finished
319
+ (one that has is already on screen from the stored run). A turn still running is
320
+ that newer turn, already being followed, and stays followed."""
321
+ self._stale |= {t.key for t in self._turns if t.finished is not None}
322
+ if self._followed in self._stale:
323
+ self._followed = None
324
+ self.version += 1
325
+
326
+ @property
327
+ def followed(self) -> TurnView | None:
328
+ if not self._following:
329
+ return None
330
+ last = self._turns[-1] if self._turns else None
331
+ if self._followed is not None:
332
+ for t in self._turns:
333
+ if t.key == self._followed and not self._abandoned(t, last):
334
+ return t
335
+ for t in self._turns:
336
+ if t.key not in self._stale and not self._abandoned(t, last):
337
+ self._followed = t.key
338
+ return t
339
+ return None
340
+
341
+ @staticmethod
342
+ def _abandoned(turn: TurnView, last: TurnView | None) -> bool:
343
+ """A turn that never finished yet has a newer one after it: a run runs one turn
344
+ at a time, so it was given up (a retried or lost attempt)."""
345
+ return turn.finished is None and last is not None and turn is not last
346
+
347
+ def followed_finished(self) -> bool:
348
+ turn = self.followed
349
+ return turn is not None and turn.finished is not None
350
+
351
+ # ---- trace ----
352
+
353
+ def _trace(self, event: dict) -> None:
354
+ kind = str(event.get("type") or "")
355
+ at = int(event.get("at") or 0)
356
+ if self.trace_start is None:
357
+ self.trace_start = at
358
+ last = self.trace[-1] if self.trace else None
359
+ # Deltas arrive many to a sentence: one line counts them.
360
+ if kind.endswith(".delta") and last is not None and last.type == kind:
361
+ last.count += 1
362
+ last.what = _one_line(last.what + str(event.get("text") or ""))
363
+ return
364
+ self.trace.append(TraceLine(seq=_event_key(event)[1], at=at, type=kind, what=describe(event)))
365
+
366
+ # ---- reducer ----
367
+
368
+ def _rebuild(self) -> None:
369
+ # How far each turn's text was revealed is the screen's, not the events': keep it.
370
+ revealed = {t.key: t.revealed for t in self._turns}
371
+ self._turns = []
372
+ self._open = None
373
+ for e in self._events:
374
+ self._apply(e)
375
+ for t in self._turns:
376
+ if t.key in revealed:
377
+ t.revealed = revealed[t.key] if revealed[t.key] is None else min(revealed[t.key], t.text_chars)
378
+
379
+ def _turn_for(self, event: dict) -> TurnView:
380
+ if self._open is None:
381
+ src, seq = _event_key(event)
382
+ # Its `turn.started` is not in what was read back.
383
+ self._open = TurnView(key=(f"~{src}", seq), src=None, started_at=event.get("at") or 0)
384
+ self._add_turn(self._open)
385
+ return self._open
386
+
387
+ def _add_turn(self, turn: TurnView) -> None:
388
+ self._turns.append(turn)
389
+ # Only the latest few turns can be followed; the rest are on screen already.
390
+ if len(self._turns) > _KEPT_TURNS:
391
+ del self._turns[:-_KEPT_TURNS]
392
+
393
+ def _apply(self, event: dict) -> None:
394
+ kind = str(event.get("type") or "")
395
+ if kind.startswith("subagent.") or kind.startswith("question."):
396
+ return
397
+ if kind == "turn.started":
398
+ src, seq = _event_key(event)
399
+ self._open = TurnView(
400
+ key=(src, seq), src=src or None, name=str(event.get("name") or ""),
401
+ started_at=event.get("at") or 0,
402
+ )
403
+ self._add_turn(self._open)
404
+ return
405
+ if kind not in TURN_OWN and kind != "notice":
406
+ return
407
+ turn = self._turn_for(event)
408
+ if (
409
+ turn.src and kind in TURN_OWN and event.get("src") != turn.src
410
+ and not (kind == "turn.finished" and event.get("status"))
411
+ ):
412
+ return
413
+ if kind == "turn.finished":
414
+ turn.finished = {"ok": bool(event.get("ok")), "error": str(event.get("error") or ""), "at": event.get("at") or 0}
415
+ self._open = None
416
+ return
417
+ if kind == "step.started":
418
+ if isinstance(event.get("step"), int):
419
+ turn.step = event["step"]
420
+ return
421
+ last = turn.items[-1] if turn.items else None
422
+ step = event.get("step")
423
+ if kind in ("text.delta", "reasoning.delta"):
424
+ block_kind = "text" if kind == "text.delta" else "reasoning"
425
+ text = str(event.get("text") or "")
426
+ if not (isinstance(last, Block) and last.kind == block_kind and last.step == step):
427
+ last = turn.new_block(block_kind, step)
428
+ last.chars += len(text)
429
+ if block_kind == "text":
430
+ last.text += text
431
+ turn.text_chars += len(text)
432
+ elif not last.dropped and len(last.text) < THOUGHT_CHARS:
433
+ last.text = (last.text + text)[:THOUGHT_CHARS]
434
+ return
435
+ call_id = str(event.get("call_id") or "")
436
+ if kind == "notice":
437
+ text = str(event.get("text") or "")
438
+ tool = turn.tools.get(call_id) if call_id else None
439
+ if tool is not None:
440
+ tool.note = text
441
+ tool.notes.append(text)
442
+ del tool.notes[:-NOTES_KEPT]
443
+ elif text:
444
+ turn.new_block("notice", None).text = text
445
+ return
446
+ if not call_id:
447
+ return
448
+ tool = turn.tools.get(call_id)
449
+ if tool is None:
450
+ tool = ToolLine(
451
+ call_id=call_id, name=str(event.get("name") or "tool"), step=step,
452
+ parent=event.get("parent_call_id"),
453
+ )
454
+ turn.tools[call_id] = tool
455
+ turn.tool_total += 1
456
+ turn._add(tool)
457
+ if event.get("name"):
458
+ tool.name = str(event["name"])
459
+ if event.get("label"):
460
+ tool.label = str(event["label"])
461
+ if event.get("hosted"):
462
+ tool.hosted = True
463
+ if kind == "tool.ready":
464
+ turn.keep_args(tool, event.get("args"), event.get("args_chars"), event.get("args_truncated"))
465
+ if tool.state == "forming":
466
+ tool.state = "ready"
467
+ elif kind == "tool.running":
468
+ tool.state = "running"
469
+ elif kind == "tool.finished":
470
+ tool.state = "ok" if event.get("ok") else "fail"
471
+ tool.error = str(event.get("error") or "")
472
+ tool.duration_ms = event.get("duration_ms")
473
+ tool.note = ""