kiwi-code 0.0.503__tar.gz → 0.0.505__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 (139) hide show
  1. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/cli.py +3 -0
  4. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/client.py +16 -0
  5. kiwi_code-0.0.505/src/kiwi_cli/io_threads.py +35 -0
  6. kiwi_code-0.0.505/src/kiwi_cli/run_activity.py +532 -0
  7. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/subagents.py +3 -25
  8. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/cli.py +6 -0
  9. kiwi_code-0.0.505/src/kiwi_runtime/fs/edits.py +185 -0
  10. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/handler.py +10 -2
  11. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/ui.py +23 -0
  12. kiwi_code-0.0.505/src/kiwi_tui/latex_unicode.py +224 -0
  13. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/main.py +80 -11
  14. kiwi_code-0.0.505/src/kiwi_tui/screens/term_activity_view.py +329 -0
  15. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard.py +593 -138
  16. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard_css.py +62 -1
  17. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard_helpers.py +140 -17
  18. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard_widgets.py +89 -3
  19. kiwi_code-0.0.505/src/kiwi_tui/screens/term_status_line.py +107 -0
  20. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_subagent_widgets.py +14 -37
  21. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/slash_commands.py +2 -1
  22. kiwi_code-0.0.505/src/kiwi_tui/terminal_theme.py +91 -0
  23. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/widgets.py +3 -0
  24. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/conftest.py +18 -0
  25. kiwi_code-0.0.505/tests/test_command_tips.py +77 -0
  26. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_human_questions.py +16 -5
  27. kiwi_code-0.0.505/tests/test_io_threads.py +43 -0
  28. kiwi_code-0.0.505/tests/test_math_and_tables.py +94 -0
  29. kiwi_code-0.0.505/tests/test_reply_selection.py +69 -0
  30. kiwi_code-0.0.505/tests/test_run_activity.py +767 -0
  31. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_run_steering.py +10 -5
  32. kiwi_code-0.0.505/tests/test_runtime_edit_diffs.py +130 -0
  33. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_slash_commands.py +5 -2
  34. kiwi_code-0.0.505/tests/test_status_line.py +96 -0
  35. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_subagents.py +9 -18
  36. kiwi_code-0.0.505/tests/test_terminal_theme.py +19 -0
  37. kiwi_code-0.0.505/tests/test_toasts.py +49 -0
  38. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_tui_palette.py +48 -3
  39. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/uv.lock +1 -1
  40. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/.github/workflows/publish.yml +0 -0
  41. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/.github/workflows/test.yml +0 -0
  42. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/.gitignore +0 -0
  43. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/.python-version +0 -0
  44. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/Makefile +0 -0
  45. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/README.md +0 -0
  46. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/docs/runtime_audit.html +0 -0
  47. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/docs/social_login_e2e.md +0 -0
  48. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/scripts/set_edition.py +0 -0
  49. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/scripts/ws_auth_check.py +0 -0
  50. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/common/__init__.py +0 -0
  51. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/common/banner.py +0 -0
  52. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/__init__.py +0 -0
  53. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/_edition.py +0 -0
  54. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/auth.py +0 -0
  55. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/autocode.py +0 -0
  56. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/checkpoints.py +0 -0
  57. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/commands.py +0 -0
  58. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/edition.py +0 -0
  59. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/human_questions.py +0 -0
  60. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/logger.py +0 -0
  61. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/models.py +0 -0
  62. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/oauth.py +0 -0
  63. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/runtime_manager.py +0 -0
  64. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/server.py +0 -0
  65. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_cli/terminal_mode.py +0 -0
  66. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/__init__.py +0 -0
  67. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/__main__.py +0 -0
  68. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/auth.py +0 -0
  69. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/checkpoints.py +0 -0
  70. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/commands.py +0 -0
  71. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/constants.py +0 -0
  72. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/__init__.py +0 -0
  73. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/batch.py +0 -0
  74. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/codelens.py +0 -0
  75. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/context.py +0 -0
  76. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/paths.py +0 -0
  77. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/read.py +0 -0
  78. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/search.py +0 -0
  79. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/text.py +0 -0
  80. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/upload.py +0 -0
  81. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/write.py +0 -0
  82. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/handlers.py +0 -0
  83. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/link.py +0 -0
  84. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/main.py +0 -0
  85. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/restricted.py +0 -0
  86. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/sessions.py +0 -0
  87. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/state.py +0 -0
  88. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/tui_bridge.py +0 -0
  89. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_runtime/uploads.py +0 -0
  90. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/__init__.py +0 -0
  91. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/inline_file_picker.py +0 -0
  92. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/random_words.py +0 -0
  93. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/role_mode.py +0 -0
  94. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/runtime_agent.py +0 -0
  95. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/__init__.py +0 -0
  96. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/attach_content.py +0 -0
  97. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/command_result.py +0 -0
  98. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/detach_files.py +0 -0
  99. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/file_browser.py +0 -0
  100. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/help.py +0 -0
  101. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/id_picker.py +0 -0
  102. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/login.py +0 -0
  103. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  104. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  105. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/slash_picker.py +0 -0
  106. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  107. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/status_words.py +0 -0
  108. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/term_app.py +0 -0
  109. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/ui_settings.py +0 -0
  110. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/src/kiwi_tui/worktrees.py +0 -0
  111. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/test_hello.py +0 -0
  112. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/__init__.py +0 -0
  113. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_account_server.py +0 -0
  114. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_autocode_actions.py +0 -0
  115. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_batch_fs_tool.py +0 -0
  116. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_checkpoints.py +0 -0
  117. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_cli_help.py +0 -0
  118. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_copy_path_fs_tool.py +0 -0
  119. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_fs_edit_fidelity.py +0 -0
  120. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_imports.py +0 -0
  121. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_login_social.py +0 -0
  122. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_oauth.py +0 -0
  123. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_read_file_streaming.py +0 -0
  124. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_reexec_kiwi.py +0 -0
  125. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_role_mode_injection.py +0 -0
  126. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_runtime_cleanup_per_account.py +0 -0
  127. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_runtime_connection.py +0 -0
  128. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_runtime_fixes.py +0 -0
  129. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_runtime_imports_on_windows.py +0 -0
  130. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_runtime_log_trimming.py +0 -0
  131. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_runtime_social_login.py +0 -0
  132. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_search_in_files_fs_tool.py +0 -0
  133. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_stats_command.py +0 -0
  134. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_term_dashboard_ui.py +0 -0
  135. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_terminal_mode.py +0 -0
  136. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_tokens.py +0 -0
  137. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_tui_interactive_runtime.py +0 -0
  138. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/tests/test_unified_diff.py +0 -0
  139. {kiwi_code-0.0.503 → kiwi_code-0.0.505}/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.503
3
+ Version: 0.0.505
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.503"
3
+ version = "0.0.505"
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
 
@@ -29,6 +29,11 @@ from .models import AuthTokens, LoginCredentials
29
29
  from .human_questions import WAITING_STATUS, human_event_type, parse_questions
30
30
 
31
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
+
32
37
  class AutobotsClientWrapper:
33
38
  """Wrapper around autobots_client for API calls."""
34
39
 
@@ -57,12 +62,14 @@ class AutobotsClientWrapper:
57
62
  base_url=base_url,
58
63
  token=token,
59
64
  raise_on_unexpected_status=False,
65
+ timeout=API_TIMEOUT,
60
66
  )
61
67
  logger.info(f"Initialized authenticated client for {base_url}")
62
68
  else:
63
69
  self.client = Client(
64
70
  base_url=base_url,
65
71
  raise_on_unexpected_status=False,
72
+ timeout=API_TIMEOUT,
66
73
  )
67
74
  logger.info(f"Initialized unauthenticated client for {base_url}")
68
75
 
@@ -77,6 +84,7 @@ class AutobotsClientWrapper:
77
84
  base_url=self.base_url,
78
85
  token=self.access_token,
79
86
  raise_on_unexpected_status=False,
87
+ timeout=API_TIMEOUT,
80
88
  )
81
89
  logger.info("Client updated with new access token")
82
90
 
@@ -641,6 +649,7 @@ class AutobotsClientWrapper:
641
649
  topic: str | None = None,
642
650
  stop_on_terminal_status: bool = True,
643
651
  plain_text_type: str = "status",
652
+ on_open: Callable[[], None] | None = None,
644
653
  ) -> None:
645
654
  """Stream action run updates via Server-Sent Events.
646
655
 
@@ -663,6 +672,8 @@ class AutobotsClientWrapper:
663
672
  `status` field. Disable for topics that don't send status (e.g. *-text).
664
673
  plain_text_type: When a frame is not valid JSON, wrap it as
665
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.
666
677
  """
667
678
  topic = topic or run_id
668
679
  sse_endpoint = f"{self.base_url}/v1/server_sent_events/stream/{topic}"
@@ -684,6 +695,11 @@ class AutobotsClientWrapper:
684
695
  if response.status_code != 200:
685
696
  logger.error(f"SSE connection failed with status {response.status_code}")
686
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}")
687
703
 
688
704
  decoder = codecs.getincrementaldecoder("utf-8")()
689
705
  buffer = ""
@@ -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,532 @@
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 file changes whose diffs are kept, the latest only; older ones keep their counts.
41
+ DIFFS_KEPT = 30
42
+ #: The trace lines kept for the trace panel.
43
+ TRACE_LINES = 200
44
+ _TRACE_TEXT = 220
45
+ _MAX_EVENTS = 3000
46
+ _KEPT_TURNS = 8
47
+
48
+
49
+ @dataclass
50
+ class ToolLine:
51
+ kind = "tool"
52
+ call_id: str
53
+ name: str
54
+ step: int | None = None
55
+ label: str = ""
56
+ state: str = "forming" # forming / ready / running / ok / fail
57
+ parent: str | None = None
58
+ hosted: bool = False
59
+ duration_ms: int | None = None
60
+ error: str = ""
61
+ note: str = "" # its latest notice, shown while it runs
62
+ notes: list[str] = field(default_factory=list) # its latest few, for its details
63
+ args: str = "" # the server's preview of its arguments, as indented JSON
64
+ args_chars: int | None = None
65
+ args_truncated: bool = False
66
+ #: The changes it made to files, as the user's runtime recorded them: path, added,
67
+ #: removed, diff (unified, without its header), truncated, created, deleted.
68
+ edits: list[dict] = field(default_factory=list)
69
+
70
+
71
+ @dataclass
72
+ class Block:
73
+ kind: str # text / reasoning / notice
74
+ step: int | None = None
75
+ text: str = ""
76
+ chars: int = 0 # all of it, however much of `text` is kept
77
+ id: int = 0 # numbers a turn's blocks, so one can be opened on screen
78
+ dropped: bool = False # its text was let go, to keep the latest turns' thinking
79
+
80
+
81
+ @dataclass
82
+ class TurnView:
83
+ key: tuple[str, int]
84
+ src: str | None
85
+ name: str = ""
86
+ started_at: int = 0
87
+ finished: dict | None = None # {"ok", "error", "at"}
88
+ items: list = field(default_factory=list) # ToolLine / Block, in the order they happened
89
+ tools: dict[str, ToolLine] = field(default_factory=dict)
90
+ tool_total: int = 0
91
+ dropped: int = 0 # steps no longer kept
92
+ step: int | None = None # the model call it is on
93
+ text_chars: int = 0 # all the answer text it has streamed
94
+ #: How much of that text the screen has revealed so far: text arrives in bursts, and
95
+ #: the screen shows it at a steady pace. None (the default) shows it all.
96
+ revealed: int | None = None
97
+ _with_args: deque = field(default_factory=deque, repr=False)
98
+ _thoughts: deque = field(default_factory=deque, repr=False)
99
+ _blocks: int = 0
100
+
101
+ def new_block(self, kind: str, step: int | None) -> "Block":
102
+ self._blocks += 1
103
+ block = Block(kind=kind, step=step, id=self._blocks)
104
+ self._add(block)
105
+ if kind == "reasoning":
106
+ self._thoughts.append(block)
107
+ while len(self._thoughts) > THOUGHTS_KEPT:
108
+ old = self._thoughts.popleft()
109
+ old.text, old.dropped = "", True
110
+ return block
111
+
112
+ @property
113
+ def has_text(self) -> bool:
114
+ return any(isinstance(i, Block) and i.kind == "text" and i.text.strip() for i in self.items)
115
+
116
+ def doing(self) -> tuple[str, str]:
117
+ """What the turn is doing now, for the status line: (kind, text), kind one of
118
+ tool / thinking / writing / working."""
119
+ running = [t for t in self.tools.values() if t.state == "running" and not t.parent]
120
+ if running:
121
+ tool = running[-1]
122
+ return "tool", tool.label or tool.name
123
+ last = self.items[-1] if self.items else None
124
+ if isinstance(last, Block) and last.kind == "reasoning":
125
+ return "thinking", "Thinking"
126
+ if isinstance(last, Block) and last.kind == "text":
127
+ return "writing", "Writing"
128
+ if isinstance(last, ToolLine) and last.state in ("forming", "ready"):
129
+ return "tool", f"Preparing {last.label or last.name}"
130
+ return "working", "Working"
131
+
132
+ @property
133
+ def steps_key(self) -> str:
134
+ """What opens or closes this turn's steps on screen."""
135
+ return f"steps:{self.key[0]}:{self.key[1]}"
136
+
137
+ @property
138
+ def has_steps(self) -> bool:
139
+ """Whether there is more to the turn than its answer."""
140
+ return any(not (isinstance(i, Block) and i.kind == "text") for i in self.items) or self.dropped > 0
141
+
142
+ def summary(self) -> str:
143
+ """"Used 3 tools · 12.4s", as Agent Studio ends a finished turn."""
144
+ if self.finished is None:
145
+ return ""
146
+ took = _took(max(0, (self.finished.get("at") or 0) - (self.started_at or 0)))
147
+ n = self.tool_total
148
+ used = f"Used {n} tool{'s' if n != 1 else ''} · {took}" if n else took
149
+ if self.finished.get("ok"):
150
+ return used
151
+ return f"{used} · {self.finished.get('error') or 'failed'}"
152
+
153
+ def keep_args(self, tool: "ToolLine", args: object, chars: object, truncated: object) -> None:
154
+ """Keep a call's argument preview; the oldest calls give theirs up."""
155
+ try:
156
+ text = json.dumps(args, indent=2, ensure_ascii=False, default=str) if args else ""
157
+ except (TypeError, ValueError):
158
+ text = str(args)
159
+ tool.args = text if len(text) <= ARGS_CHARS else text[: ARGS_CHARS - 1] + "…"
160
+ tool.args_chars = chars if isinstance(chars, int) else None
161
+ tool.args_truncated = bool(truncated)
162
+ if tool.call_id not in self._with_args:
163
+ self._with_args.append(tool.call_id)
164
+ while len(self._with_args) > ARGS_KEPT:
165
+ old = self.tools.get(self._with_args.popleft())
166
+ if old is not None:
167
+ old.args = ""
168
+
169
+ def _add(self, item) -> None:
170
+ self.items.append(item)
171
+ if len(self.items) > TURN_ITEMS:
172
+ old = self.items.pop(0)
173
+ self.dropped += 1
174
+ if isinstance(old, ToolLine):
175
+ self.tools.pop(old.call_id, None)
176
+
177
+
178
+ @dataclass
179
+ class TraceLine:
180
+ seq: int
181
+ at: int
182
+ type: str
183
+ what: str
184
+ count: int = 1
185
+
186
+
187
+ def _took(ms: int) -> str:
188
+ secs = ms / 1000
189
+ if secs < 60:
190
+ return f"{secs:.1f}s"
191
+ if secs < 3600:
192
+ return f"{int(secs // 60)}m {int(secs % 60)}s"
193
+ return f"{int(secs // 3600)}h {int(secs % 3600 // 60)}m"
194
+
195
+
196
+ def _event_key(event: dict) -> tuple[str, int]:
197
+ try:
198
+ return str(event.get("src") or ""), int(event.get("seq") or 0)
199
+ except (TypeError, ValueError):
200
+ return str(event.get("src") or ""), 0
201
+
202
+
203
+ def _order(event: dict) -> tuple:
204
+ src, seq = _event_key(event)
205
+ return (event.get("at") or 0, src, seq)
206
+
207
+
208
+ def _one_line(text: object, limit: int = _TRACE_TEXT) -> str:
209
+ line = " ".join(str(text or "").split())
210
+ return line if len(line) <= limit else line[: limit - 1] + "…"
211
+
212
+
213
+ def describe(event: dict) -> str:
214
+ """One line on what an event says, as Agent Studio's trace shows it."""
215
+ kind = str(event.get("type") or "")
216
+ if kind.endswith(".delta") or kind == "notice":
217
+ return _one_line(event.get("text"))
218
+ if kind.startswith("subagent."):
219
+ who = event.get("name") or f"…{str(event.get('child_run_id') or '')[-6:]}"
220
+ if kind == "subagent.progress":
221
+ what = event.get("label")
222
+ elif kind == "subagent.finished":
223
+ what = "ok" if event.get("ok") else (event.get("error") or "failed")
224
+ elif kind == "subagent.started":
225
+ what = event.get("goal")
226
+ else:
227
+ what = "sent a message"
228
+ return _one_line(f"{who} · {what or ''}")
229
+ if kind.startswith("tool."):
230
+ text = str(event.get("label") or event.get("name") or "")
231
+ if kind == "tool.finished":
232
+ ms = event.get("duration_ms")
233
+ text += f" · {'ok' if event.get('ok') else 'failed'} · {ms if ms is not None else '–'} ms"
234
+ return _one_line(text)
235
+ if kind.startswith("question."):
236
+ n = len(event.get("questions") or event.get("question_ids") or [])
237
+ return _one_line(f"{n} question{'s' if n != 1 else ''}" + (f" · {event['status']}" if event.get("status") else ""))
238
+ if kind == "step.started":
239
+ return f"step {event.get('step')}"
240
+ if kind == "turn.started":
241
+ return _one_line(event.get("name"))
242
+ if kind == "turn.finished":
243
+ return "ok" if event.get("ok") else _one_line(event.get("error") or "failed")
244
+ return ""
245
+
246
+
247
+ class RunActivityView:
248
+ """The turns of one run, built from its activity events; which of them the streaming
249
+ row follows; and a short trace of the latest events."""
250
+
251
+ def __init__(self) -> None:
252
+ self._events: list[dict] = []
253
+ self._seen: set[tuple[str, int]] = set()
254
+ self._turns: list[TurnView] = []
255
+ self._open: TurnView | None = None
256
+ self._stale: set[tuple[str, int]] = set()
257
+ self._followed: tuple[str, int] | None = None
258
+ self._following = False
259
+ self.trace: deque[TraceLine] = deque(maxlen=TRACE_LINES)
260
+ # Each call's file changes, kept apart from the turns so a rebuild keeps them.
261
+ self._edits: dict[str, list[dict]] = {}
262
+ self._with_diff: deque[dict] = deque()
263
+ self._edits_seen: deque[tuple] = deque(maxlen=DIFFS_KEPT * 8)
264
+ self.trace_start: int | None = None
265
+ #: Counts every change, so a redraw can tell whether there is anything new.
266
+ self.version = 0
267
+
268
+ # ---- input ----
269
+
270
+ def take(self, event: object) -> bool:
271
+ """Add one event; whether it was new."""
272
+ if not isinstance(event, dict) or not event.get("type"):
273
+ return False
274
+ key = _event_key(event)
275
+ if key[0]:
276
+ if key in self._seen:
277
+ return False
278
+ self._seen.add(key)
279
+ in_order = not self._events or _order(event) >= _order(self._events[-1])
280
+ self._events.append(event)
281
+ self._trace(event)
282
+ if len(self._events) > _MAX_EVENTS:
283
+ del self._events[: len(self._events) - _MAX_EVENTS]
284
+ self._seen = {_event_key(e) for e in self._events}
285
+ in_order = False
286
+ if in_order:
287
+ self._apply(event)
288
+ else:
289
+ self._rebuild()
290
+ self.version += 1
291
+ return True
292
+
293
+ def take_all(self, events: Iterable[object]) -> bool:
294
+ """Add a batch, such as a read-back; whether anything was new."""
295
+ fresh = []
296
+ for e in events:
297
+ if not isinstance(e, dict) or not e.get("type"):
298
+ continue
299
+ key = _event_key(e)
300
+ if key[0] and key in self._seen:
301
+ continue
302
+ if key[0]:
303
+ self._seen.add(key)
304
+ fresh.append(e)
305
+ if not fresh:
306
+ return False
307
+ self._events.extend(fresh)
308
+ self._events.sort(key=_order)
309
+ del self._events[: max(0, len(self._events) - _MAX_EVENTS)]
310
+ for e in sorted(fresh, key=_order):
311
+ self._trace(e)
312
+ self._rebuild()
313
+ self.version += 1
314
+ return True
315
+
316
+ # ---- following ----
317
+
318
+ def start_following(self) -> None:
319
+ """From now on the row follows a turn that has not already finished: what the
320
+ read-back holds of earlier turns is not this prompt's."""
321
+ self._stale = {t.key for t in self._turns if t.finished is not None}
322
+ self._followed = None
323
+ self._following = True
324
+ self.version += 1
325
+
326
+ def follow_next(self) -> None:
327
+ """The run has moved on to a newer turn: follow the next one that has not finished
328
+ (one that has is already on screen from the stored run). A turn still running is
329
+ that newer turn, already being followed, and stays followed."""
330
+ self._stale |= {t.key for t in self._turns if t.finished is not None}
331
+ if self._followed in self._stale:
332
+ self._followed = None
333
+ self.version += 1
334
+
335
+ @property
336
+ def followed(self) -> TurnView | None:
337
+ if not self._following:
338
+ return None
339
+ last = self._turns[-1] if self._turns else None
340
+ if self._followed is not None:
341
+ for t in self._turns:
342
+ if t.key == self._followed and not self._abandoned(t, last):
343
+ return t
344
+ for t in self._turns:
345
+ if t.key not in self._stale and not self._abandoned(t, last):
346
+ self._followed = t.key
347
+ return t
348
+ return None
349
+
350
+ @staticmethod
351
+ def _abandoned(turn: TurnView, last: TurnView | None) -> bool:
352
+ """A turn that never finished yet has a newer one after it: a run runs one turn
353
+ at a time, so it was given up (a retried or lost attempt)."""
354
+ return turn.finished is None and last is not None and turn is not last
355
+
356
+ def followed_finished(self) -> bool:
357
+ turn = self.followed
358
+ return turn is not None and turn.finished is not None
359
+
360
+ # ---- file changes ----
361
+
362
+ def add_edit(self, record: object) -> bool:
363
+ """A change the user's runtime made to a file, shown under the call that made it.
364
+ A record that names no call (an older backend) goes to the latest file tool of
365
+ the turn being followed. Whether it found a call."""
366
+ if not isinstance(record, dict):
367
+ return False
368
+ seen = (record.get("at"), record.get("call_id"), record.get("path"), record.get("op"))
369
+ if seen in self._edits_seen:
370
+ return False # read again after the record file was trimmed
371
+ self._edits_seen.append(seen)
372
+ call_id = str(record.get("call_id") or "")
373
+ tool = self._tool(call_id) if call_id else None
374
+ if not call_id:
375
+ tool = self._latest_file_tool()
376
+ if tool is None:
377
+ return False
378
+ call_id = tool.call_id
379
+ edit = {k: record.get(k) for k in ("path", "added", "removed", "diff", "truncated", "created", "deleted")}
380
+ edits = self._edits.setdefault(call_id, [])
381
+ edits.append(edit)
382
+ if tool is not None:
383
+ tool.edits = edits
384
+ self._with_diff.append(edit)
385
+ while len(self._with_diff) > DIFFS_KEPT:
386
+ self._with_diff.popleft()["diff"] = ""
387
+ if len(self._edits) > DIFFS_KEPT * 4:
388
+ for key in list(self._edits)[: len(self._edits) - DIFFS_KEPT * 4]:
389
+ del self._edits[key]
390
+ self.version += 1
391
+ return True
392
+
393
+ def _tool(self, call_id: str) -> ToolLine | None:
394
+ for turn in reversed(self._turns):
395
+ if call_id in turn.tools:
396
+ return turn.tools[call_id]
397
+ return None
398
+
399
+ def _latest_file_tool(self) -> ToolLine | None:
400
+ turn = self.followed
401
+ if turn is None:
402
+ return None
403
+ for tool in reversed(list(turn.tools.values())):
404
+ if tool.name == "file_system_tool":
405
+ return tool
406
+ return None
407
+
408
+ # ---- trace ----
409
+
410
+ def _trace(self, event: dict) -> None:
411
+ kind = str(event.get("type") or "")
412
+ at = int(event.get("at") or 0)
413
+ if self.trace_start is None:
414
+ self.trace_start = at
415
+ last = self.trace[-1] if self.trace else None
416
+ # Deltas arrive many to a sentence: one line counts them.
417
+ if kind.endswith(".delta") and last is not None and last.type == kind:
418
+ last.count += 1
419
+ last.what = _one_line(last.what + str(event.get("text") or ""))
420
+ return
421
+ self.trace.append(TraceLine(seq=_event_key(event)[1], at=at, type=kind, what=describe(event)))
422
+
423
+ # ---- reducer ----
424
+
425
+ def _rebuild(self) -> None:
426
+ # How far each turn's text was revealed is the screen's, not the events': keep it.
427
+ revealed = {t.key: t.revealed for t in self._turns}
428
+ self._turns = []
429
+ self._open = None
430
+ for e in self._events:
431
+ self._apply(e)
432
+ for t in self._turns:
433
+ if t.key in revealed:
434
+ t.revealed = revealed[t.key] if revealed[t.key] is None else min(revealed[t.key], t.text_chars)
435
+
436
+ def _turn_for(self, event: dict) -> TurnView:
437
+ if self._open is None:
438
+ src, seq = _event_key(event)
439
+ # Its `turn.started` is not in what was read back.
440
+ self._open = TurnView(key=(f"~{src}", seq), src=None, started_at=event.get("at") or 0)
441
+ self._add_turn(self._open)
442
+ return self._open
443
+
444
+ def _add_turn(self, turn: TurnView) -> None:
445
+ self._turns.append(turn)
446
+ # Only the latest few turns can be followed; the rest are on screen already.
447
+ if len(self._turns) > _KEPT_TURNS:
448
+ del self._turns[:-_KEPT_TURNS]
449
+
450
+ def _apply(self, event: dict) -> None:
451
+ kind = str(event.get("type") or "")
452
+ if kind.startswith("subagent.") or kind.startswith("question."):
453
+ return
454
+ if kind == "turn.started":
455
+ src, seq = _event_key(event)
456
+ self._open = TurnView(
457
+ key=(src, seq), src=src or None, name=str(event.get("name") or ""),
458
+ started_at=event.get("at") or 0,
459
+ )
460
+ self._add_turn(self._open)
461
+ return
462
+ if kind not in TURN_OWN and kind != "notice":
463
+ return
464
+ turn = self._turn_for(event)
465
+ if (
466
+ turn.src and kind in TURN_OWN and event.get("src") != turn.src
467
+ and not (kind == "turn.finished" and event.get("status"))
468
+ ):
469
+ return
470
+ if kind == "turn.finished":
471
+ turn.finished = {"ok": bool(event.get("ok")), "error": str(event.get("error") or ""), "at": event.get("at") or 0}
472
+ self._open = None
473
+ return
474
+ if kind == "step.started":
475
+ if isinstance(event.get("step"), int):
476
+ turn.step = event["step"]
477
+ return
478
+ last = turn.items[-1] if turn.items else None
479
+ step = event.get("step")
480
+ if kind in ("text.delta", "reasoning.delta"):
481
+ block_kind = "text" if kind == "text.delta" else "reasoning"
482
+ text = str(event.get("text") or "")
483
+ if not (isinstance(last, Block) and last.kind == block_kind and last.step == step):
484
+ last = turn.new_block(block_kind, step)
485
+ last.chars += len(text)
486
+ if block_kind == "text":
487
+ last.text += text
488
+ turn.text_chars += len(text)
489
+ elif not last.dropped and len(last.text) < THOUGHT_CHARS:
490
+ last.text = (last.text + text)[:THOUGHT_CHARS]
491
+ return
492
+ call_id = str(event.get("call_id") or "")
493
+ if kind == "notice":
494
+ text = str(event.get("text") or "")
495
+ tool = turn.tools.get(call_id) if call_id else None
496
+ if tool is not None:
497
+ tool.note = text
498
+ tool.notes.append(text)
499
+ del tool.notes[:-NOTES_KEPT]
500
+ elif text:
501
+ turn.new_block("notice", None).text = text
502
+ return
503
+ if not call_id:
504
+ return
505
+ tool = turn.tools.get(call_id)
506
+ if tool is None:
507
+ tool = ToolLine(
508
+ call_id=call_id, name=str(event.get("name") or "tool"), step=step,
509
+ parent=event.get("parent_call_id"),
510
+ )
511
+ if call_id in self._edits:
512
+ tool.edits = self._edits[call_id]
513
+ turn.tools[call_id] = tool
514
+ turn.tool_total += 1
515
+ turn._add(tool)
516
+ if event.get("name"):
517
+ tool.name = str(event["name"])
518
+ if event.get("label"):
519
+ tool.label = str(event["label"])
520
+ if event.get("hosted"):
521
+ tool.hosted = True
522
+ if kind == "tool.ready":
523
+ turn.keep_args(tool, event.get("args"), event.get("args_chars"), event.get("args_truncated"))
524
+ if tool.state == "forming":
525
+ tool.state = "ready"
526
+ elif kind == "tool.running":
527
+ tool.state = "running"
528
+ elif kind == "tool.finished":
529
+ tool.state = "ok" if event.get("ok") else "fail"
530
+ tool.error = str(event.get("error") or "")
531
+ tool.duration_ms = event.get("duration_ms")
532
+ tool.note = ""