kiwi-code 0.0.504__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.
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/PKG-INFO +1 -1
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/pyproject.toml +1 -1
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/run_activity.py +59 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/cli.py +6 -0
- kiwi_code-0.0.505/src/kiwi_runtime/fs/edits.py +185 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/handler.py +10 -2
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/ui.py +23 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/main.py +26 -9
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_activity_view.py +42 -3
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard.py +68 -2
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard_widgets.py +46 -1
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/slash_commands.py +1 -1
- kiwi_code-0.0.505/src/kiwi_tui/terminal_theme.py +91 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/conftest.py +6 -0
- kiwi_code-0.0.505/tests/test_reply_selection.py +69 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_run_activity.py +81 -0
- kiwi_code-0.0.505/tests/test_runtime_edit_diffs.py +130 -0
- kiwi_code-0.0.505/tests/test_terminal_theme.py +19 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_tui_palette.py +19 -2
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/uv.lock +1 -1
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/.gitignore +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/.python-version +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/Makefile +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/README.md +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/docs/runtime_audit.html +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/docs/social_login_e2e.md +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/scripts/set_edition.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/scripts/ws_auth_check.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/common/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/common/banner.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/_edition.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/autocode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/edition.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/human_questions.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/io_threads.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/oauth.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/subagents.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/auth.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/checkpoints.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/commands.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/constants.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/batch.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/codelens.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/context.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/paths.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/read.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/search.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/text.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/upload.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/write.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/handlers.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/link.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/restricted.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/sessions.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/state.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/tui_bridge.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/uploads.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/latex_unicode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/role_mode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard_css.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard_helpers.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_status_line.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/ui_settings.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/test_hello.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_account_server.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_autocode_actions.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_command_tips.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_fs_edit_fidelity.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_human_questions.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_io_threads.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_login_social.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_math_and_tables.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_oauth.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_role_mode_injection.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_run_steering.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_cleanup_per_account.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_connection.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_fixes.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_imports_on_windows.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_social_login.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_stats_command.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_status_line.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_subagents.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_term_dashboard_ui.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_toasts.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_worktrees.py +0 -0
|
@@ -37,6 +37,8 @@ NOTES_KEPT = 3
|
|
|
37
37
|
#: The thinking blocks of a turn whose text is kept, and how much of each.
|
|
38
38
|
THOUGHTS_KEPT = 20
|
|
39
39
|
THOUGHT_CHARS = 8000
|
|
40
|
+
#: The file changes whose diffs are kept, the latest only; older ones keep their counts.
|
|
41
|
+
DIFFS_KEPT = 30
|
|
40
42
|
#: The trace lines kept for the trace panel.
|
|
41
43
|
TRACE_LINES = 200
|
|
42
44
|
_TRACE_TEXT = 220
|
|
@@ -61,6 +63,9 @@ class ToolLine:
|
|
|
61
63
|
args: str = "" # the server's preview of its arguments, as indented JSON
|
|
62
64
|
args_chars: int | None = None
|
|
63
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)
|
|
64
69
|
|
|
65
70
|
|
|
66
71
|
@dataclass
|
|
@@ -252,6 +257,10 @@ class RunActivityView:
|
|
|
252
257
|
self._followed: tuple[str, int] | None = None
|
|
253
258
|
self._following = False
|
|
254
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)
|
|
255
264
|
self.trace_start: int | None = None
|
|
256
265
|
#: Counts every change, so a redraw can tell whether there is anything new.
|
|
257
266
|
self.version = 0
|
|
@@ -348,6 +357,54 @@ class RunActivityView:
|
|
|
348
357
|
turn = self.followed
|
|
349
358
|
return turn is not None and turn.finished is not None
|
|
350
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
|
+
|
|
351
408
|
# ---- trace ----
|
|
352
409
|
|
|
353
410
|
def _trace(self, event: dict) -> None:
|
|
@@ -451,6 +508,8 @@ class RunActivityView:
|
|
|
451
508
|
call_id=call_id, name=str(event.get("name") or "tool"), step=step,
|
|
452
509
|
parent=event.get("parent_call_id"),
|
|
453
510
|
)
|
|
511
|
+
if call_id in self._edits:
|
|
512
|
+
tool.edits = self._edits[call_id]
|
|
454
513
|
turn.tools[call_id] = tool
|
|
455
514
|
turn.tool_total += 1
|
|
456
515
|
turn._add(tool)
|
|
@@ -42,6 +42,7 @@ from kiwi_runtime.ui import (
|
|
|
42
42
|
BackgroundWriter,
|
|
43
43
|
C,
|
|
44
44
|
print_banner,
|
|
45
|
+
print_edit,
|
|
45
46
|
print_section,
|
|
46
47
|
print_status,
|
|
47
48
|
)
|
|
@@ -361,6 +362,11 @@ def _run_until_stopped(loop: asyncio.AbstractEventLoop, runtime) -> None:
|
|
|
361
362
|
|
|
362
363
|
terminal = sys.stdout
|
|
363
364
|
sys.stdout = BackgroundWriter(terminal)
|
|
365
|
+
if getattr(terminal, "isatty", lambda: False)():
|
|
366
|
+
# Each change made to a file shows its diff here; not in a log file.
|
|
367
|
+
from kiwi_runtime.fs import edits
|
|
368
|
+
|
|
369
|
+
edits.show = print_edit
|
|
364
370
|
|
|
365
371
|
try:
|
|
366
372
|
loop.run_until_complete(runtime)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""The diff of each change the runtime makes to a file, for the TUI to show under the tool
|
|
2
|
+
call that made it.
|
|
3
|
+
|
|
4
|
+
One JSON line per change, in `~/.kiwi/edits/<run_id>.jsonl`:
|
|
5
|
+
|
|
6
|
+
{"v": 1, "at": 1760…, "run_id": "…", "call_id": "call_8f2a", "op": "replace_in_file",
|
|
7
|
+
"path": "src/app.py", "added": 2, "removed": 1, "diff": "@@ -3,3 +3,4 @@\\n…",
|
|
8
|
+
"truncated": false, "created": false, "deleted": false}
|
|
9
|
+
|
|
10
|
+
The backend names the tool call (`call_id`) and run (`run_id`) in each request; a backend
|
|
11
|
+
that does not falls back to the run this runtime is bound to. Recording a change never
|
|
12
|
+
stops the change: anything that goes wrong here is skipped.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextvars
|
|
17
|
+
import difflib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
#: The operations that change a file's content.
|
|
27
|
+
MUTATING = frozenset({
|
|
28
|
+
"write_file", "append_file", "replace_in_file", "replace_line_range", "insert_at_line",
|
|
29
|
+
"apply_unified_diff", "file_write_finish", "delete_path",
|
|
30
|
+
})
|
|
31
|
+
#: Files larger than this are not read to diff (a log or a data dump is not worth it).
|
|
32
|
+
MAX_FILE_BYTES = 2_000_000
|
|
33
|
+
#: A diff is kept to this many lines; the counts still cover all of it.
|
|
34
|
+
MAX_DIFF_LINES = 400
|
|
35
|
+
#: A run's record file is trimmed to its latest changes once it grows past this.
|
|
36
|
+
MAX_RECORD_BYTES = 1_000_000
|
|
37
|
+
KEEP_RECORDS = 200
|
|
38
|
+
|
|
39
|
+
#: Called with each record as it is made, e.g. to print the diff in the runtime's terminal.
|
|
40
|
+
show: Callable[[dict[str, Any]], None] | None = None
|
|
41
|
+
|
|
42
|
+
_lock = threading.Lock()
|
|
43
|
+
# The tool call a batch belongs to, for the operations inside it, which do not name it.
|
|
44
|
+
_LINK: contextvars.ContextVar[tuple[str, str] | None] = contextvars.ContextVar("kiwi_edit_link", default=None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def edits_dir() -> Path:
|
|
48
|
+
try:
|
|
49
|
+
from kiwi_cli.checkpoints import kiwi_home_dir
|
|
50
|
+
|
|
51
|
+
return kiwi_home_dir() / "edits"
|
|
52
|
+
except Exception:
|
|
53
|
+
return Path.home() / ".kiwi" / "edits"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def edits_path(run_id: str) -> Path:
|
|
57
|
+
return edits_dir() / f"{re.sub(r'[^A-Za-z0-9_.-]', '_', run_id)}.jsonl"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def link(msg: dict[str, Any]) -> contextvars.Token | None:
|
|
61
|
+
"""While a request runs, what it says about its tool call and run, so the operations
|
|
62
|
+
of a batch inside it are recorded against the same call."""
|
|
63
|
+
call_id, run_id = str(msg.get("call_id") or ""), str(msg.get("run_id") or "")
|
|
64
|
+
if not (call_id or run_id):
|
|
65
|
+
return None
|
|
66
|
+
return _LINK.set((call_id, run_id))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def unlink(token: contextvars.Token | None) -> None:
|
|
70
|
+
if token is not None:
|
|
71
|
+
_LINK.reset(token)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def before(msg: dict[str, Any], *, mode: str, allowed_dirs: list[str] | None, chunked_writes: dict) -> tuple[str, str | None] | None:
|
|
75
|
+
"""The file a change is about to touch and its text now (None if it cannot be read as
|
|
76
|
+
text), or None when the request changes no file."""
|
|
77
|
+
try:
|
|
78
|
+
op = msg.get("type")
|
|
79
|
+
if op not in MUTATING:
|
|
80
|
+
return None
|
|
81
|
+
if op == "file_write_finish":
|
|
82
|
+
state = chunked_writes.get(msg.get("upload_id"))
|
|
83
|
+
path = getattr(state, "path", None)
|
|
84
|
+
else:
|
|
85
|
+
from kiwi_runtime.restricted import validate_fs_path
|
|
86
|
+
|
|
87
|
+
raw = msg.get("path")
|
|
88
|
+
if not raw:
|
|
89
|
+
return None
|
|
90
|
+
ok, path, _reason = validate_fs_path(raw, mode, allowed_dirs)
|
|
91
|
+
if not ok:
|
|
92
|
+
return None
|
|
93
|
+
if not path or os.path.isdir(path):
|
|
94
|
+
return None
|
|
95
|
+
return str(path), _read_text(str(path))
|
|
96
|
+
except Exception:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def after(msg: dict[str, Any], watched: tuple[str, str | None]) -> None:
|
|
101
|
+
"""Record the change a successful request made to the file `before` watched."""
|
|
102
|
+
try:
|
|
103
|
+
path, old = watched
|
|
104
|
+
if old is None:
|
|
105
|
+
return # not text, or too large to diff
|
|
106
|
+
new = "" if msg.get("type") == "delete_path" else _read_text(path)
|
|
107
|
+
if new is None or new == old:
|
|
108
|
+
return
|
|
109
|
+
call_id, run_id = _LINK.get() or ("", "")
|
|
110
|
+
call_id = str(msg.get("call_id") or call_id)
|
|
111
|
+
run_id = str(msg.get("run_id") or run_id or _bound_run_id())
|
|
112
|
+
if not run_id:
|
|
113
|
+
return
|
|
114
|
+
record = _record(msg.get("type"), path, old, new, call_id=call_id, run_id=run_id)
|
|
115
|
+
_append(edits_path(run_id), record)
|
|
116
|
+
if show is not None:
|
|
117
|
+
show(record)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _record(op: Any, path: str, old: str, new: str, *, call_id: str, run_id: str) -> dict[str, Any]:
|
|
123
|
+
lines = list(difflib.unified_diff(
|
|
124
|
+
old.splitlines(), new.splitlines(), lineterm="", n=3,
|
|
125
|
+
))[2:] # without the ---/+++ header: the path is shown on its own
|
|
126
|
+
added = sum(1 for line in lines if line.startswith("+"))
|
|
127
|
+
removed = sum(1 for line in lines if line.startswith("-"))
|
|
128
|
+
return {
|
|
129
|
+
"v": 1,
|
|
130
|
+
"at": time.time(),
|
|
131
|
+
"run_id": run_id,
|
|
132
|
+
"call_id": call_id,
|
|
133
|
+
"op": str(op or ""),
|
|
134
|
+
"path": _display_path(path),
|
|
135
|
+
"added": added,
|
|
136
|
+
"removed": removed,
|
|
137
|
+
"diff": "\n".join(lines[:MAX_DIFF_LINES]),
|
|
138
|
+
"truncated": len(lines) > MAX_DIFF_LINES,
|
|
139
|
+
"created": old == "" and op != "delete_path",
|
|
140
|
+
"deleted": op == "delete_path",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _read_text(path: str) -> str | None:
|
|
145
|
+
"""The file's text; "" for a file not there yet; None for one too large or not text."""
|
|
146
|
+
try:
|
|
147
|
+
if not os.path.exists(path):
|
|
148
|
+
return ""
|
|
149
|
+
if os.path.getsize(path) > MAX_FILE_BYTES:
|
|
150
|
+
return None
|
|
151
|
+
with open(path, "rb") as f:
|
|
152
|
+
data = f.read()
|
|
153
|
+
if b"\0" in data[:8192]:
|
|
154
|
+
return None
|
|
155
|
+
return data.decode("utf-8")
|
|
156
|
+
except (OSError, UnicodeDecodeError):
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _display_path(path: str) -> str:
|
|
161
|
+
try:
|
|
162
|
+
rel = os.path.relpath(path)
|
|
163
|
+
return path if rel.startswith("..") else rel
|
|
164
|
+
except ValueError:
|
|
165
|
+
return path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _bound_run_id() -> str:
|
|
169
|
+
try:
|
|
170
|
+
from kiwi_runtime.checkpoints import _get_current_run_id
|
|
171
|
+
|
|
172
|
+
return _get_current_run_id()
|
|
173
|
+
except Exception:
|
|
174
|
+
return ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _append(target: Path, record: dict[str, Any]) -> None:
|
|
178
|
+
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
179
|
+
with _lock:
|
|
180
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
with open(target, "a", encoding="utf-8") as f:
|
|
182
|
+
f.write(line)
|
|
183
|
+
if target.stat().st_size > MAX_RECORD_BYTES:
|
|
184
|
+
kept = target.read_text(encoding="utf-8").splitlines(keepends=True)[-KEEP_RECORDS:]
|
|
185
|
+
target.write_text("".join(kept), encoding="utf-8")
|
|
@@ -7,7 +7,7 @@ from concurrent.futures import ProcessPoolExecutor
|
|
|
7
7
|
from concurrent.futures.process import BrokenProcessPool
|
|
8
8
|
from typing import Any, Callable
|
|
9
9
|
|
|
10
|
-
from kiwi_runtime.fs import batch, codelens, paths, read, search, upload, write
|
|
10
|
+
from kiwi_runtime.fs import batch, codelens, edits, paths, read, search, upload, write
|
|
11
11
|
from kiwi_runtime.fs.context import ChunkedWriteState, FsRequest
|
|
12
12
|
|
|
13
13
|
FS_MAX_CONCURRENCY = 8
|
|
@@ -62,14 +62,22 @@ def _handle_fs_request_sync(
|
|
|
62
62
|
"message": f"Unsupported file operation type: {msg_type}",
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
# A change to a file is recorded with its diff, for the TUI to show under the call.
|
|
66
|
+
token = edits.link(msg)
|
|
67
|
+
watched = edits.before(msg, mode=mode, allowed_dirs=allowed_dirs, chunked_writes=chunked_writes)
|
|
65
68
|
try:
|
|
66
|
-
|
|
69
|
+
result = handler(FsRequest(msg, request_id, mode, allowed_dirs, chunked_writes))
|
|
67
70
|
except Exception as e:
|
|
68
71
|
return {
|
|
69
72
|
"request_id": request_id,
|
|
70
73
|
"success": False,
|
|
71
74
|
"message": str(e),
|
|
72
75
|
}
|
|
76
|
+
finally:
|
|
77
|
+
edits.unlink(token)
|
|
78
|
+
if watched is not None and isinstance(result, dict) and result.get("success"):
|
|
79
|
+
edits.after(msg, watched)
|
|
80
|
+
return result
|
|
73
81
|
|
|
74
82
|
|
|
75
83
|
# Pure-Python scans over many files. In a thread they would share the interpreter with the
|
|
@@ -234,3 +234,26 @@ def print_pty_log(session_id: str, message: str, level: str = "info"):
|
|
|
234
234
|
else:
|
|
235
235
|
icon = f"{C}~{RESET}"
|
|
236
236
|
_safe_print(f" {icon} {tag} {message}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
#: A diff printed under its request is kept to this many lines.
|
|
241
|
+
SHOWN_DIFF_LINES = 40
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def print_edit(record: dict) -> None:
|
|
245
|
+
"""The diff of a change made to a file, under the request that made it."""
|
|
246
|
+
added, removed = record.get("added") or 0, record.get("removed") or 0
|
|
247
|
+
what = "new file" if record.get("created") else "deleted" if record.get("deleted") else "edited"
|
|
248
|
+
lines = [
|
|
249
|
+
f" {GREY}{what}{RESET} {BOLD}{record.get('path', '')}{RESET} "
|
|
250
|
+
f"{GREEN}+{added}{RESET} {RED}-{removed}{RESET}"
|
|
251
|
+
]
|
|
252
|
+
diff = str(record.get("diff") or "").splitlines()
|
|
253
|
+
for line in diff[:SHOWN_DIFF_LINES]:
|
|
254
|
+
color = GREEN if line.startswith("+") else RED if line.startswith("-") else C if line.startswith("@@") else GREY
|
|
255
|
+
lines.append(f" {color}{line}{RESET}")
|
|
256
|
+
more = len(diff) - SHOWN_DIFF_LINES
|
|
257
|
+
if more > 0 or record.get("truncated"):
|
|
258
|
+
lines.append(f" {GREY}… more lines not shown{RESET}")
|
|
259
|
+
_safe_print("\n".join(lines))
|
|
@@ -309,9 +309,20 @@ class AutobotsTUI(App):
|
|
|
309
309
|
|
|
310
310
|
|
|
311
311
|
DEFAULT_THEME = "kiwi-black"
|
|
312
|
-
#:
|
|
313
|
-
#: stays Kiwi's own palette, and the fallback
|
|
314
|
-
|
|
312
|
+
#: What a new install starts with, and `/theme reset` returns to: the terminal's own
|
|
313
|
+
#: look (see `initial_theme`). `DEFAULT_THEME` stays Kiwi's own palette, and the fallback
|
|
314
|
+
#: when a theme cannot be applied.
|
|
315
|
+
INITIAL_THEME = "terminal"
|
|
316
|
+
|
|
317
|
+
def initial_theme(self) -> str:
|
|
318
|
+
"""The theme that follows the terminal: its background and colours, light or dark
|
|
319
|
+
as the terminal is. Asked once, before the TUI takes over the terminal."""
|
|
320
|
+
theme = getattr(self, "_terminal_theme", None)
|
|
321
|
+
if theme is None:
|
|
322
|
+
from kiwi_tui.terminal_theme import terminal_theme
|
|
323
|
+
|
|
324
|
+
theme = self._terminal_theme = terminal_theme()
|
|
325
|
+
return theme
|
|
315
326
|
|
|
316
327
|
def _register_kiwi_theme(self) -> None:
|
|
317
328
|
"""Register Kiwi's default theme with Textual's theme system."""
|
|
@@ -347,17 +358,17 @@ class AutobotsTUI(App):
|
|
|
347
358
|
settings = load_ui_settings()
|
|
348
359
|
stored = settings.get("theme") if isinstance(settings, dict) else None
|
|
349
360
|
if isinstance(stored, str) and stored.strip():
|
|
350
|
-
return stored.strip()
|
|
361
|
+
return self.initial_theme() if stored.strip().lower() == "terminal" else stored.strip()
|
|
351
362
|
|
|
352
363
|
t = str(getattr(self.config, "theme", "") or "").strip()
|
|
353
364
|
tl = t.lower()
|
|
354
|
-
if tl in {"dark", "default"}:
|
|
355
|
-
return self.
|
|
365
|
+
if tl in {"dark", "default", "terminal", ""}:
|
|
366
|
+
return self.initial_theme()
|
|
356
367
|
if tl == "black":
|
|
357
368
|
return self.DEFAULT_THEME
|
|
358
369
|
if tl in {"light"}:
|
|
359
370
|
return "textual-light"
|
|
360
|
-
return t
|
|
371
|
+
return t
|
|
361
372
|
|
|
362
373
|
def set_theme_preference(self, theme_name: str, *, persist: bool = True) -> tuple[bool, str]:
|
|
363
374
|
"""Set the active theme and (optionally) persist it to ~/.kiwi/settings.json."""
|
|
@@ -366,8 +377,12 @@ class AutobotsTUI(App):
|
|
|
366
377
|
return False, "Usage: /theme set <name>"
|
|
367
378
|
|
|
368
379
|
norm = raw
|
|
380
|
+
saved = None
|
|
369
381
|
nl = raw.lower()
|
|
370
|
-
if nl
|
|
382
|
+
if nl == "terminal":
|
|
383
|
+
# Follow the terminal: remembered as such, so the next start asks it again.
|
|
384
|
+
norm, saved = self.initial_theme(), "terminal"
|
|
385
|
+
elif nl in {"dark", "black", "default"}:
|
|
371
386
|
norm = self.DEFAULT_THEME
|
|
372
387
|
elif nl in {"light"}:
|
|
373
388
|
norm = "textual-light"
|
|
@@ -383,9 +398,11 @@ class AutobotsTUI(App):
|
|
|
383
398
|
|
|
384
399
|
if persist:
|
|
385
400
|
try:
|
|
386
|
-
update_ui_settings({"theme": norm})
|
|
401
|
+
update_ui_settings({"theme": saved or norm})
|
|
387
402
|
except Exception:
|
|
388
403
|
pass
|
|
404
|
+
if saved == "terminal":
|
|
405
|
+
return True, f"Theme follows your terminal ({norm})"
|
|
389
406
|
return True, f"Theme set to: {norm}"
|
|
390
407
|
def _is_dark_theme(self) -> bool:
|
|
391
408
|
"""Best-effort detection of whether the active theme is dark."""
|
|
@@ -25,6 +25,8 @@ SHOWN_ITEMS = 14
|
|
|
25
25
|
SHOWN_TOOLS = 6
|
|
26
26
|
#: Lines of a tool's arguments drawn when it is open.
|
|
27
27
|
SHOWN_ARG_LINES = 15
|
|
28
|
+
#: Lines of each file's diff drawn when its tool line is open.
|
|
29
|
+
SHOWN_DIFF_LINES = 40
|
|
28
30
|
#: Lines of a thinking block drawn when it is open.
|
|
29
31
|
SHOWN_THOUGHT_LINES = 40
|
|
30
32
|
#: How streamed text is revealed: it arrives in bursts, and is shown at one calm, steady
|
|
@@ -128,6 +130,13 @@ def _tool_text(tool: ToolLine, *, live: bool, cyan: str, depth: int, opened: boo
|
|
|
128
130
|
line.append(" preparing" if tool.state == "forming" else " queued", style="dim")
|
|
129
131
|
if running and tool.note and not opened:
|
|
130
132
|
line.append(f" {tool.note}", style="dim")
|
|
133
|
+
if tool.edits:
|
|
134
|
+
if len(tool.edits) == 1:
|
|
135
|
+
line.append(f" {tool.edits[0].get('path') or ''}", style="dim")
|
|
136
|
+
added = sum(int(e.get("added") or 0) for e in tool.edits)
|
|
137
|
+
removed = sum(int(e.get("removed") or 0) for e in tool.edits)
|
|
138
|
+
line.append(f" +{added}", style="green")
|
|
139
|
+
line.append(f" −{removed}", style="red")
|
|
131
140
|
took = duration(tool.duration_ms)
|
|
132
141
|
if took:
|
|
133
142
|
line.append(f" {took}", style="dim")
|
|
@@ -139,24 +148,54 @@ def _tool_text(tool: ToolLine, *, live: bool, cyan: str, depth: int, opened: boo
|
|
|
139
148
|
|
|
140
149
|
|
|
141
150
|
def _tool_details(tool: ToolLine, pad: str) -> Text:
|
|
142
|
-
"""What Agent Studio shows when a tool line is opened
|
|
151
|
+
"""What Agent Studio shows when a tool line is opened, and the diff of each file the
|
|
152
|
+
call changed."""
|
|
143
153
|
out = Text()
|
|
144
154
|
if tool.error:
|
|
145
155
|
out.append(f"\n{pad}{tool.error}", style="red")
|
|
156
|
+
for edit in tool.edits:
|
|
157
|
+
out.append_text(_diff_text(edit, pad))
|
|
146
158
|
for note in tool.notes:
|
|
147
159
|
out.append(f"\n{pad}• {note}", style="dim")
|
|
148
|
-
if tool.args:
|
|
160
|
+
if tool.args and not tool.edits: # an edit's diff says what its arguments did, better
|
|
149
161
|
lines = tool.args.splitlines()
|
|
150
162
|
for line in lines[:SHOWN_ARG_LINES]:
|
|
151
163
|
out.append(f"\n{pad}{line}", style="dim")
|
|
152
164
|
if len(lines) > SHOWN_ARG_LINES:
|
|
153
165
|
out.append(f"\n{pad}… {len(lines) - SHOWN_ARG_LINES} more lines", style="dim italic")
|
|
154
|
-
if tool.args_truncated and tool.args_chars:
|
|
166
|
+
if tool.args_truncated and tool.args_chars and not tool.edits:
|
|
155
167
|
out.append(f"\n{pad}Arguments shortened · {tool.args_chars} characters in full", style="dim italic")
|
|
156
168
|
out.append(f"\n{pad}{tool.name} · {tool.call_id}", style="dim")
|
|
157
169
|
return out
|
|
158
170
|
|
|
159
171
|
|
|
172
|
+
def _diff_text(edit: dict, pad: str) -> Text:
|
|
173
|
+
"""One file's change: its path, then its diff in red and green."""
|
|
174
|
+
out = Text()
|
|
175
|
+
path = str(edit.get("path") or "")
|
|
176
|
+
what = " (new file)" if edit.get("created") else " (deleted)" if edit.get("deleted") else ""
|
|
177
|
+
out.append(f"\n{pad}{path}{what}", style="bold")
|
|
178
|
+
out.append(f" +{int(edit.get('added') or 0)}", style="green")
|
|
179
|
+
out.append(f" −{int(edit.get('removed') or 0)}", style="red")
|
|
180
|
+
diff = str(edit.get("diff") or "")
|
|
181
|
+
if not diff:
|
|
182
|
+
out.append(f"\n{pad} (diff no longer kept: only the latest changes are)", style="dim italic")
|
|
183
|
+
return out
|
|
184
|
+
lines = diff.splitlines()
|
|
185
|
+
for line in lines[:SHOWN_DIFF_LINES]:
|
|
186
|
+
style = (
|
|
187
|
+
"green" if line.startswith("+") else
|
|
188
|
+
"red" if line.startswith("-") else
|
|
189
|
+
"cyan dim" if line.startswith("@@") else
|
|
190
|
+
"dim"
|
|
191
|
+
)
|
|
192
|
+
out.append(f"\n{pad} {line}", style=style)
|
|
193
|
+
more = len(lines) - SHOWN_DIFF_LINES
|
|
194
|
+
if more > 0 or edit.get("truncated"):
|
|
195
|
+
out.append(f"\n{pad} … {max(more, 0)} more lines" if more > 0 else f"\n{pad} … the rest of the diff is not kept", style="dim italic")
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
160
199
|
def activity_renderable(
|
|
161
200
|
turn: TurnView,
|
|
162
201
|
*,
|
|
@@ -117,6 +117,16 @@ def _lighten(color: str, amount: float = 0.6) -> str:
|
|
|
117
117
|
return "#%02x%02x%02x" % tuple(round(c + (255 - c) * amount) for c in (r, g, b))
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
def _edits_path(run_id: str):
|
|
121
|
+
"""Where the user's runtime records the diffs of its changes to files for `run_id`."""
|
|
122
|
+
try:
|
|
123
|
+
from kiwi_runtime.fs.edits import edits_path
|
|
124
|
+
|
|
125
|
+
return edits_path(run_id)
|
|
126
|
+
except Exception:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
120
130
|
class TermDashboardScreen(Screen):
|
|
121
131
|
BINDINGS = [
|
|
122
132
|
Binding("escape", "dismiss_or_clear", "Dismiss", show=False, priority=True),
|
|
@@ -365,7 +375,14 @@ class TermDashboardScreen(Screen):
|
|
|
365
375
|
)
|
|
366
376
|
|
|
367
377
|
self._scroll_poll_timer = self.set_interval(0.2, self._poll_transcript_scroll)
|
|
378
|
+
# Rows can grow after they are laid out (a slow machine, a row updated on mount):
|
|
379
|
+
# a transcript that follows its end stays there.
|
|
380
|
+
root = self.query_one("#term-scroll-transcript", VerticalScroll)
|
|
381
|
+
self.watch(root, "virtual_size", self._keep_at_end, init=False)
|
|
368
382
|
|
|
383
|
+
def _keep_at_end(self) -> None:
|
|
384
|
+
if self._follow_tail and not getattr(self, "_lazy_loading", False) and not self._near_bottom():
|
|
385
|
+
self._scroll_end_root()
|
|
369
386
|
|
|
370
387
|
def _poll_transcript_scroll(self) -> None:
|
|
371
388
|
if getattr(self, "_lazy_loading", False):
|
|
@@ -3302,7 +3319,9 @@ class TermDashboardScreen(Screen):
|
|
|
3302
3319
|
current = getattr(self.app, "theme", None)
|
|
3303
3320
|
cur = str(current).strip() if current is not None else ""
|
|
3304
3321
|
|
|
3305
|
-
rows: list[dict[str, str]] = [
|
|
3322
|
+
rows: list[dict[str, str]] = [
|
|
3323
|
+
{"label": " terminal (follows your terminal's colours)", "run_id": "terminal"}
|
|
3324
|
+
]
|
|
3306
3325
|
for t in themes:
|
|
3307
3326
|
marker = "*" if t == cur else " "
|
|
3308
3327
|
rows.append({"label": f" {marker} {t}", "run_id": t})
|
|
@@ -3322,7 +3341,7 @@ class TermDashboardScreen(Screen):
|
|
|
3322
3341
|
return
|
|
3323
3342
|
theme_name = args[1]
|
|
3324
3343
|
elif sub == "reset":
|
|
3325
|
-
theme_name = getattr(self.app, "INITIAL_THEME", "
|
|
3344
|
+
theme_name = getattr(self.app, "INITIAL_THEME", "terminal")
|
|
3326
3345
|
else:
|
|
3327
3346
|
theme_name = args[0]
|
|
3328
3347
|
|
|
@@ -4849,6 +4868,7 @@ class TermDashboardScreen(Screen):
|
|
|
4849
4868
|
|
|
4850
4869
|
_last_render = -1
|
|
4851
4870
|
_last_render_at = 0.0
|
|
4871
|
+
redraw_due = [False]
|
|
4852
4872
|
self._activity_view = activity
|
|
4853
4873
|
self._stream_state = "connecting"
|
|
4854
4874
|
# A turn that has finished while prompts are still queued behind it: its text is
|
|
@@ -4869,6 +4889,16 @@ class TermDashboardScreen(Screen):
|
|
|
4869
4889
|
try:
|
|
4870
4890
|
now = time.monotonic()
|
|
4871
4891
|
if not force and (now - _last_render_at) < 0.03:
|
|
4892
|
+
# Too soon after the last draw: draw this change at the end of the pause
|
|
4893
|
+
# rather than drop it (it may be the last of a burst).
|
|
4894
|
+
if not redraw_due[0]:
|
|
4895
|
+
redraw_due[0] = True
|
|
4896
|
+
|
|
4897
|
+
def _due() -> None:
|
|
4898
|
+
redraw_due[0] = False
|
|
4899
|
+
_render(force=True)
|
|
4900
|
+
|
|
4901
|
+
asyncio.get_running_loop().call_later(0.035, _due)
|
|
4872
4902
|
return
|
|
4873
4903
|
turn = activity.followed
|
|
4874
4904
|
if turn is None or (not force and activity.version == _last_render):
|
|
@@ -5230,6 +5260,9 @@ class TermDashboardScreen(Screen):
|
|
|
5230
5260
|
if kind in ("subagent.started", "subagent.finished"):
|
|
5231
5261
|
self._poke_subagents()
|
|
5232
5262
|
return
|
|
5263
|
+
if kind in ("tool.finished", "turn.finished"):
|
|
5264
|
+
# A file the call changed has been recorded by now.
|
|
5265
|
+
_read_edits()
|
|
5233
5266
|
if not was_finished and activity.followed_finished():
|
|
5234
5267
|
# The turn is over: fetch its saved result now rather than at the next poll.
|
|
5235
5268
|
asyncio.create_task(_fetch_final())
|
|
@@ -5252,6 +5285,38 @@ class TermDashboardScreen(Screen):
|
|
|
5252
5285
|
paced: set = set()
|
|
5253
5286
|
joining = False # set while the first read-back is taken
|
|
5254
5287
|
|
|
5288
|
+
# The diffs of the changes the user's runtime makes to files, as it records them.
|
|
5289
|
+
edit_file = _edits_path(run_id)
|
|
5290
|
+
edit_offset = 0
|
|
5291
|
+
|
|
5292
|
+
def _read_edits() -> bool:
|
|
5293
|
+
"""Take the records added since the last read; whether any reached a call."""
|
|
5294
|
+
nonlocal edit_offset
|
|
5295
|
+
if edit_file is None:
|
|
5296
|
+
return False
|
|
5297
|
+
try:
|
|
5298
|
+
size = edit_file.stat().st_size
|
|
5299
|
+
if size < edit_offset:
|
|
5300
|
+
edit_offset = 0 # trimmed to its latest records: read it again
|
|
5301
|
+
if size == edit_offset:
|
|
5302
|
+
return False
|
|
5303
|
+
with open(edit_file, "rb") as f:
|
|
5304
|
+
f.seek(edit_offset)
|
|
5305
|
+
data = f.read()
|
|
5306
|
+
except OSError:
|
|
5307
|
+
return False
|
|
5308
|
+
end = data.rfind(b"\n")
|
|
5309
|
+
if end < 0:
|
|
5310
|
+
return False # a line still being written
|
|
5311
|
+
edit_offset += end + 1
|
|
5312
|
+
changed = False
|
|
5313
|
+
for line in data[:end].splitlines():
|
|
5314
|
+
try:
|
|
5315
|
+
changed = activity.add_edit(json.loads(line)) or changed
|
|
5316
|
+
except ValueError:
|
|
5317
|
+
continue
|
|
5318
|
+
return changed
|
|
5319
|
+
|
|
5255
5320
|
def _pace_new_turn() -> None:
|
|
5256
5321
|
"""A turn seen for the first time is revealed from its start; one that was
|
|
5257
5322
|
already under way when we joined shows what it had written at once."""
|
|
@@ -5304,6 +5369,7 @@ class TermDashboardScreen(Screen):
|
|
|
5304
5369
|
else:
|
|
5305
5370
|
_stream_trouble(f"reading it back failed, HTTP {code}")
|
|
5306
5371
|
activity.start_following()
|
|
5372
|
+
_read_edits()
|
|
5307
5373
|
# Rejoining a turn under way: what it had written already shows at once.
|
|
5308
5374
|
joining = True
|
|
5309
5375
|
_pace_new_turn()
|