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.
Files changed (139) hide show
  1. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/run_activity.py +59 -0
  4. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/cli.py +6 -0
  5. kiwi_code-0.0.505/src/kiwi_runtime/fs/edits.py +185 -0
  6. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/handler.py +10 -2
  7. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/ui.py +23 -0
  8. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/main.py +26 -9
  9. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_activity_view.py +42 -3
  10. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard.py +68 -2
  11. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard_widgets.py +46 -1
  12. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/slash_commands.py +1 -1
  13. kiwi_code-0.0.505/src/kiwi_tui/terminal_theme.py +91 -0
  14. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/conftest.py +6 -0
  15. kiwi_code-0.0.505/tests/test_reply_selection.py +69 -0
  16. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_run_activity.py +81 -0
  17. kiwi_code-0.0.505/tests/test_runtime_edit_diffs.py +130 -0
  18. kiwi_code-0.0.505/tests/test_terminal_theme.py +19 -0
  19. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_tui_palette.py +19 -2
  20. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/uv.lock +1 -1
  21. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/.github/workflows/publish.yml +0 -0
  22. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/.github/workflows/test.yml +0 -0
  23. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/.gitignore +0 -0
  24. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/.python-version +0 -0
  25. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/Makefile +0 -0
  26. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/README.md +0 -0
  27. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/docs/runtime_audit.html +0 -0
  28. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/docs/social_login_e2e.md +0 -0
  29. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/scripts/set_edition.py +0 -0
  30. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/scripts/ws_auth_check.py +0 -0
  31. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/common/__init__.py +0 -0
  32. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/common/banner.py +0 -0
  33. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/__init__.py +0 -0
  34. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/_edition.py +0 -0
  35. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/auth.py +0 -0
  36. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/autocode.py +0 -0
  37. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/checkpoints.py +0 -0
  38. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/cli.py +0 -0
  39. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/client.py +0 -0
  40. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/commands.py +0 -0
  41. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/edition.py +0 -0
  42. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/human_questions.py +0 -0
  43. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/io_threads.py +0 -0
  44. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/logger.py +0 -0
  45. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/models.py +0 -0
  46. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/oauth.py +0 -0
  47. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/runtime_manager.py +0 -0
  48. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/server.py +0 -0
  49. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/subagents.py +0 -0
  50. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_cli/terminal_mode.py +0 -0
  51. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/__init__.py +0 -0
  52. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/__main__.py +0 -0
  53. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/auth.py +0 -0
  54. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/checkpoints.py +0 -0
  55. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/commands.py +0 -0
  56. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/constants.py +0 -0
  57. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/__init__.py +0 -0
  58. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/batch.py +0 -0
  59. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/codelens.py +0 -0
  60. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/context.py +0 -0
  61. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/paths.py +0 -0
  62. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/read.py +0 -0
  63. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/search.py +0 -0
  64. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/text.py +0 -0
  65. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/upload.py +0 -0
  66. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/fs/write.py +0 -0
  67. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/handlers.py +0 -0
  68. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/link.py +0 -0
  69. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/main.py +0 -0
  70. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/restricted.py +0 -0
  71. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/sessions.py +0 -0
  72. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/state.py +0 -0
  73. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/tui_bridge.py +0 -0
  74. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_runtime/uploads.py +0 -0
  75. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/__init__.py +0 -0
  76. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/inline_file_picker.py +0 -0
  77. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/latex_unicode.py +0 -0
  78. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/random_words.py +0 -0
  79. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/role_mode.py +0 -0
  80. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/runtime_agent.py +0 -0
  81. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/__init__.py +0 -0
  82. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/attach_content.py +0 -0
  83. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/command_result.py +0 -0
  84. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/detach_files.py +0 -0
  85. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/file_browser.py +0 -0
  86. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/help.py +0 -0
  87. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/id_picker.py +0 -0
  88. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/login.py +0 -0
  89. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  90. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  91. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/slash_picker.py +0 -0
  92. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard_css.py +0 -0
  93. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_dashboard_helpers.py +0 -0
  94. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  95. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_status_line.py +0 -0
  96. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
  97. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/status_words.py +0 -0
  98. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/term_app.py +0 -0
  99. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/ui_settings.py +0 -0
  100. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/widgets.py +0 -0
  101. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/src/kiwi_tui/worktrees.py +0 -0
  102. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/test_hello.py +0 -0
  103. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/__init__.py +0 -0
  104. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_account_server.py +0 -0
  105. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_autocode_actions.py +0 -0
  106. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_batch_fs_tool.py +0 -0
  107. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_checkpoints.py +0 -0
  108. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_cli_help.py +0 -0
  109. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_command_tips.py +0 -0
  110. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_copy_path_fs_tool.py +0 -0
  111. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_fs_edit_fidelity.py +0 -0
  112. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_human_questions.py +0 -0
  113. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_imports.py +0 -0
  114. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_io_threads.py +0 -0
  115. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_login_social.py +0 -0
  116. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_math_and_tables.py +0 -0
  117. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_oauth.py +0 -0
  118. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_read_file_streaming.py +0 -0
  119. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_reexec_kiwi.py +0 -0
  120. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_role_mode_injection.py +0 -0
  121. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_run_steering.py +0 -0
  122. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_cleanup_per_account.py +0 -0
  123. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_connection.py +0 -0
  124. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_fixes.py +0 -0
  125. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_imports_on_windows.py +0 -0
  126. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_log_trimming.py +0 -0
  127. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_runtime_social_login.py +0 -0
  128. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_search_in_files_fs_tool.py +0 -0
  129. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_slash_commands.py +0 -0
  130. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_stats_command.py +0 -0
  131. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_status_line.py +0 -0
  132. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_subagents.py +0 -0
  133. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_term_dashboard_ui.py +0 -0
  134. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_terminal_mode.py +0 -0
  135. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_toasts.py +0 -0
  136. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_tokens.py +0 -0
  137. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_tui_interactive_runtime.py +0 -0
  138. {kiwi_code-0.0.504 → kiwi_code-0.0.505}/tests/test_unified_diff.py +0 -0
  139. {kiwi_code-0.0.504 → 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.504
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.504"
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"
@@ -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
- return handler(FsRequest(msg, request_id, mode, allowed_dirs, chunked_writes))
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
- #: The theme a new install starts with, and `/theme reset` returns to. `DEFAULT_THEME`
313
- #: stays Kiwi's own palette, and the fallback when a theme cannot be applied.
314
- INITIAL_THEME = "tokyo-night"
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.INITIAL_THEME
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 or self.INITIAL_THEME
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 in {"dark", "black", "default"}:
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", "tokyo-night")
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()